Make the AI Wait: Build Explicit Floor Control for a Tencent RTC Voice Companion A developer built a TypeScript coordinator for Tencent RTC voice companions that gives users explicit control over conversational turn-taking, addressing the problem of automatic endpoint detection interrupting users mid-thought. The implementation supports two input modes—automatic and hold—and rejects stale LLM responses, stops output on interruption, preserves drafts across disconnects, and exposes recoverable failures. The coordinator is provider-agnostic, integrating with Tencent RTC's real-time voice layer and multiple LLM providers. A real-time voice companion can produce an answer quickly and still be exhausting to use. The problem appears when a user pauses to think. Automatic endpoint detection interprets the silence as the end of a turn, sends an incomplete thought to the LLM, and starts speaking just as the user finds the next sentence. For developers using AI as a rubber duck, tutor, or creative companion, this creates a surprisingly personal tension: the machine's fluency starts setting the pace. A pause feels like a mistake even though pausing is often where the useful reasoning happens. The counterintuitive fix is not to make every stage faster. It is to give the user explicit control of the conversational floor. In this tutorial, we'll build a TypeScript coordinator with two input modes: The implementation will also reject stale LLM responses, stop output on interruption, preserve drafts across disconnects, and expose recoverable failures instead of silently resetting the conversation. Tencent RTC provides the real-time voice layer for Conversational AI scenarios and can be connected to multiple LLM providers. Its Conversational AI overview is the starting point for the media and AI integration. This tutorial keeps the application policy independent from provider-specific callbacks. These acceptance cases are more useful than a vague requirement such as “support natural turn-taking”: | Situation | Required behavior | |---|---| | User pauses in Automatic mode | A verified endpoint may submit the draft | | User pauses in Hold mode | Keep collecting; do not call the LLM | | User selects Ask AI | Commit exactly the visible draft | | User speaks while the agent is answering | Stop playback and invalidate the old turn | | An invalidated LLM response arrives later | Ignore it | | The network disconnects | Cancel active work but retain the unsubmitted draft | | The LLM fails | Show Retry and Edit controls | | Speech synthesis fails | Keep the accepted text available for reading or replay | The important distinction is that silence is an observation, not always consent to submit . A production voice companion has several independently fallible parts: microphone │ ▼ RTC/media transport │ ▼ speech recognition ──► application turn coordinator │ ▼ moderation/policy │ ▼ OpenAI or another LLM │ ▼ speech synthesis │ ▼ RTC playback Do not represent this entire pipeline with one isTalking boolean. The RTC layer transports media. Speech recognition produces text and endpoint observations. The LLM generates a candidate answer. Speech synthesis produces output audio. Your application owns whether a draft may be submitted and whether a late result still belongs to the active turn. Tencent RTC's Large Language Model configuration guide https://trtc.io/document/68338 documents connecting OpenAI-compatible models and using request identification for routing and observability. Keep those identifiers aligned with your application turn IDs, but do not let provider configuration become your source of conversational state. mkdir patient-voice-companion cd patient-voice-companion npm init -y npm install openai npm install --save-dev typescript tsx @types/node npx tsc --init mkdir src npm pkg set scripts.test="tsx --test src/ .test.ts" The coordinator below has no dependency on a specific RTC, recognition, or synthesis callback name. Integration adapters will translate provider events into this application-owned vocabulary. Create src/turn.ts : export type InputMode = "automatic" | "hold"; export type Phase = | "offline" | "capturing" | "thinking" | "speaking" | "recoverable"; export type PendingTurn = { epoch: number; prompt: string; requestId: string; attempt: number; answer?: string; }; export type TurnState = { sessionId: string; phase: Phase; mode: InputMode; epoch: number; draft: string; pending?: PendingTurn; error?: "llm" | "synthesis"; }; export type Event = | { type: "CONNECTED" } | { type: "DISCONNECTED" } | { type: "SET MODE"; mode: InputMode } | { type: "TRANSCRIPT FINAL"; text: string } | { type: "ENDPOINT DETECTED" } | { type: "ASK" } | { type: "USER SPEECH STARTED" } | { type: "LLM OK"; epoch: number; answer: string } | { type: "LLM FAILED"; epoch: number } | { type: "SPEECH FINISHED"; epoch: number } | { type: "SPEECH FAILED"; epoch: number } | { type: "RETRY" } | { type: "DISCARD" }; export type Effect = | { kind: "requestLLM"; epoch: number; prompt: string; requestId: string; } | { kind: "cancelLLM"; epoch: number } | { kind: "speak"; epoch: number; text: string } | { kind: "stopPlayback" } | { kind: "publishStatus"; message: string }; export type Transition = { state: TurnState; effects: Effect ; }; export const initialState = sessionId: string : TurnState = { sessionId, phase: "offline", mode: "automatic", epoch: 0, draft: "" } ; epoch is the admission token for asynchronous work. An LLM or synthesis result is valid only when its epoch matches the current state. A request ID is for routing and diagnostics; the epoch determines whether an answer may still be used. Those are related concerns, but they are not interchangeable. Continue in src/turn.ts : js function submit state: TurnState : Transition { const prompt = state.draft.trim ; if prompt { return { state, effects: { kind: "publishStatus", message: "Nothing to ask yet." } }; } const epoch = state.epoch + 1; const requestId = ${state.sessionId}:turn-${epoch}:attempt-1 ; return { state: { ...state, phase: "thinking", epoch, draft: "", error: undefined, pending: { epoch, prompt, requestId, attempt: 1 } }, effects: { kind: "requestLLM", epoch, prompt, requestId } }; } function interrupt state: TurnState : Transition { const effects: Effect = ; if state.phase === "thinking" && state.pending { effects.push { kind: "cancelLLM", epoch: state.pending.epoch } ; } if state.phase === "speaking" { effects.push { kind: "stopPlayback" } ; } return { state: { ...state, phase: "capturing", epoch: state.epoch + 1, pending: undefined, error: undefined }, effects }; } export function transition state: TurnState, event: Event : Transition { switch event.type { case "CONNECTED": return { state: { ...state, phase: "capturing" }, effects: }; case "DISCONNECTED": { const stopped = interrupt state ; return { state: { ...stopped.state, phase: "offline" }, effects: stopped.effects }; } case "SET MODE": return { state: { ...state, mode: event.mode }, effects: { kind: "publishStatus", message: event.mode === "hold" ? "Holding the floor. Silence will not submit." : "Automatic turn submission enabled." } }; case "TRANSCRIPT FINAL": return { state: { ...state, draft: state.draft, event.text.trim .filter Boolean .join " " }, effects: }; case "ENDPOINT DETECTED": return state.mode === "automatic" ? submit state : { state, effects: }; case "ASK": return submit state ; case "USER SPEECH STARTED": return state.phase === "thinking" || state.phase === "speaking" ? interrupt state : { state, effects: }; case "LLM OK": if event.epoch == state.epoch || state.pending { return { state, effects: }; } return { state: { ...state, phase: "speaking", pending: { ...state.pending, answer: event.answer } }, effects: { kind: "speak", epoch: event.epoch, text: event.answer } }; case "LLM FAILED": if event.epoch == state.epoch || state.pending { return { state, effects: }; } return { state: { ...state, phase: "recoverable", error: "llm" }, effects: { kind: "publishStatus", message: "The AI did not answer. Retry, edit, or discard this turn." } }; case "SPEECH FINISHED": if event.epoch == state.epoch return { state, effects: }; return { state: { ...state, phase: "capturing", pending: undefined, error: undefined }, effects: }; case "SPEECH FAILED": if event.epoch == state.epoch || state.pending?.answer { return { state, effects: }; } return { state: { ...state, phase: "recoverable", error: "synthesis" }, effects: { kind: "publishStatus", message: "Audio playback failed. The text answer is still available." } }; case "RETRY": { if state.pending || state.error == "llm" { return { state, effects: }; } const attempt = state.pending.attempt + 1; const requestId = ${state.sessionId}:turn-${state.epoch}:attempt-${attempt} ; const pending = { ...state.pending, attempt, requestId }; return { state: { ...state, phase: "thinking", pending, error: undefined }, effects: { kind: "requestLLM", epoch: state.epoch, prompt: pending.prompt, requestId } }; } case "DISCARD": return interrupt state ; } } There are two deliberate asymmetries here: That second rule matters because cancellation is not proof that remote computation stopped. The response may still arrive. The effect runner connects the deterministic policy to real services: export interface VoicePorts { complete input: { prompt: string; requestId: string; } : Promise