Stop Sending Half-Sentences to OpenAI in a Tencent RTC Voice Companion A developer published a Python tutorial building an "utterance commit gate" for Tencent RTC Conversational AI voice applications, a state-machine controller that waits for a configurable quiet window before committing a transcript turn to an LLM. The controller tracks six states (LISTENING, SETTLING, THINKING, MODERATING, SPEAKING, RECOVERING), correlates downstream LLM, moderation and speech-synthesis operations by request ID, and makes recovery explicit, using only the Python standard library on Python 3.11+. Voice-companion demos invite an obvious question: Which model makes the smartest or most charming character? That is rarely the first production decision. A capable model still feels broken if your application sends it half a sentence, repeats a finalized transcript, or plays a response after the user has already started a new turn. The practical tension is between responsiveness and certainty: This tutorial builds a small Python utterance commit gate for a Tencent RTC Conversational AI application. It waits for a configurable quiet window, correlates every downstream operation, and makes recovery explicit. The result is not a smarter model. It is a voice pipeline that gives the model coherent turns. Keep the media and AI responsibilities separate: Microphone │ ▼ Tencent RTC media transport │ ▼ Speech recognition ── partial/final segments │ ▼ Utterance commit gate ← this tutorial │ ├── LLM request ├── output moderation └── speech synthesis │ ▼ RTC audio playback Tencent RTC's Conversational AI scenario supports real-time voice interaction with multiple LLM providers. Its LLM configuration documentation also describes connecting OpenAI-compatible models and carrying request identifiers for routing and observability: The Python types below are deliberately application-owned. They are not Tencent RTC SDK API names . Your adapter translates actual speech, model, moderation, synthesis, and RTC callbacks into these events. Our controller recognizes six states: | State | Meaning | Permitted next step | |---|---|---| | LISTENING | Collecting partial or final transcript segments | Wait or begin settling | | SETTLING | All known segments are final; quiet timer is running | Commit or accept another segment | | THINKING | One LLM request owns the turn | Moderate its result or cancel it | | MODERATING | The generated draft is being checked | Speak it or enter recovery | | SPEAKING | Approved audio is being presented | Finish or be interrupted | | RECOVERING | The current operation has an uncertain or failed outcome | Resume explicitly | Three invariants matter more than the exact state names: This example uses only the Python standard library and Python 3.11 or newer. mkdir rtc-turn-commit cd rtc-turn-commit touch companion.py test companion.py Add the controller to companion.py : python from future import annotations from dataclasses import dataclass from enum import Enum, auto from typing import Optional class Mode Enum : LISTENING = auto SETTLING = auto THINKING = auto MODERATING = auto SPEAKING = auto RECOVERING = auto STOPPED = auto @dataclass frozen=True class Transcript: epoch: int segment id: str revision: int text: str final: bool @dataclass class StoredSegment: revision: int text: str final: bool order: int @dataclass frozen=True class Effect: kind: str request id: Optional str = None text: Optional str = None class TurnCommitter: def init self, settle ms: int = 350, transcript timeout ms: int = 8 000, - None: if settle ms < 0: raise ValueError "settle ms must be non-negative" if transcript timeout ms <= settle ms: raise ValueError "transcript timeout must exceed settle time" self.settle ms = settle ms self.transcript timeout ms = transcript timeout ms self.mode = Mode.LISTENING self.epoch = 1 self. segments: dict str, StoredSegment = {} self. consumed segment ids: set str = set self. next order = 0 self. first segment at: Optional int = None self. settle deadline: Optional int = None self. turn number = 0 self. active request: Optional str = None @property def active request self - Optional str : return self. active request def on transcript self, event: Transcript, now ms: int - list Effect : if self.mode not in Mode.LISTENING, Mode.SETTLING : return if event.epoch = self.epoch: return if event.segment id in self. consumed segment ids: return previous = self. segments.get event.segment id if previous and event.revision <= previous.revision: return if previous is None: order = self. next order self. next order += 1 else: order = previous.order self. segments event.segment id = StoredSegment revision=event.revision, text=event.text.strip , final=event.final, order=order, if self. first segment at is None: self. first segment at = now ms if self. segments and all s.final for s in self. segments.values : self.mode = Mode.SETTLING self. settle deadline = now ms + self.settle ms else: self.mode = Mode.LISTENING self. settle deadline = None return def tick self, now ms: int - list Effect : if self. first segment at is not None and now ms - self. first segment at = self.transcript timeout ms and self.mode in Mode.LISTENING, Mode.SETTLING : self. clear transcript self.mode = Mode.RECOVERING return Effect "status", text="transcript timeout" if self.mode = Mode.SETTLING or self. settle deadline is None or now ms < self. settle deadline : return ordered = sorted self. segments.items , key=lambda item: item 1 .order text = " ".join segment.text for , segment in ordered if segment.text text = " ".join text.split for segment id, in ordered: self. consumed segment ids.add segment id self. clear transcript if not text: self.mode = Mode.LISTENING return self. turn number += 1 request id = f"session-{self.epoch}-turn-{self. turn number}" self. active request = request id self.mode = Mode.THINKING return Effect "generate", request id=request id, text=text def on llm result self, request id: str, draft: str - list Effect : if self.mode = Mode.THINKING or request id = self. active request: return self.mode = Mode.MODERATING return Effect "moderate", request id=request id, text=draft def on moderation result self, request id: str, allowed: bool, approved text: str = "", - list Effect : if self.mode = Mode.MODERATING or request id = self. active request: return if not allowed: self. active request = None self.mode = Mode.RECOVERING return Effect "status", text="reply blocked" self.mode = Mode.SPEAKING return Effect "speak", request id=request id, text=approved text def on user speech started self - list Effect : effects: list Effect = if self.mode in Mode.THINKING, Mode.MODERATING : effects.append Effect "cancel generation", self. active request elif self.mode == Mode.SPEAKING: effects.append Effect "stop speech", self. active request if self.mode in Mode.THINKING, Mode.MODERATING, Mode.SPEAKING : self. active request = None self.mode = Mode.LISTENING return effects def on speech finished self, request id: str - list Effect : if self.mode = Mode.SPEAKING or request id = self. active request: return self. active request = None self.mode = Mode.LISTENING return def on stage error self, request id: str, stage: str - list Effect : if request id = self. active request: return self. active request = None self.mode = Mode.RECOVERING return Effect "status", text=f"{stage} failed" def on disconnect self - list Effect : effects: list Effect = if self.mode in Mode.THINKING, Mode.MODERATING : effects.append Effect "cancel generation", self. active request elif self.mode == Mode.SPEAKING: effects.append Effect "stop speech", self. active request self.epoch += 1 self. active request = None self. consumed segment ids.clear self. clear transcript self.mode = Mode.RECOVERING effects.append Effect "status", text="connection lost" return effects def resume self - None: if self.mode == Mode.RECOVERING: self.mode = Mode.LISTENING def stop self - None: self. active request = None self. clear transcript self.mode = Mode.STOPPED def clear transcript self - None: self. segments.clear self. first segment at = None self. settle deadline = None Add deterministic tests to test companion.py : python import unittest from companion import Effect, Mode, Transcript, TurnCommitter class TurnCommitterTests unittest.TestCase : def test two final segments become one model request self - None: c = TurnCommitter settle ms=300 c.on transcript Transcript c.epoch, "a", 1, "Could you explain", True , now ms=0, self.assertEqual c.tick 299 , A second final segment restarts the settling window. c.on transcript Transcript c.epoch, "b", 1, "Python generators?", True , now ms=250, self.assertEqual c.tick 549 , self.assertEqual c.tick 550 , Effect "generate", "session-1-turn-1", "Could you explain Python generators?", , self.assertEqual c.mode, Mode.THINKING def test duplicate revision cannot create a second turn self - None: c = TurnCommitter settle ms=100 event = Transcript c.epoch, "segment-1", 2, "Hello", True c.on transcript event, now ms=0 first = c.tick 100 self.assertEqual len first , 1 c.on user speech started c.on transcript event, now ms=200 self.assertEqual c.tick 500 , def test interruption invalidates late model output self - None: c = TurnCommitter settle ms=100 c.on transcript Transcript c.epoch, "a", 1, "Tell me a story", True , now ms=0, request = c.tick 100 0 .request id self.assertEqual c.on user speech started , Effect "cancel generation", request , Cancellation is best-effort. The old callback may still arrive. self.assertEqual c.on llm result request, "Once upon a time" , self.assertEqual c.mode, Mode.LISTENING def test reconnect rejects old transcript callbacks self - None: c = TurnCommitter settle ms=100 old epoch = c.epoch c.on disconnect c.resume c.on transcript Transcript old epoch, "late", 1, "Old audio", True , now ms=0, self.assertEqual c.tick 500 , self.assertEqual c.mode, Mode.LISTENING def test partial transcript eventually enters recovery self - None: c = TurnCommitter settle ms=100, transcript timeout ms=1 000, c.on transcript Transcript c.epoch, "a", 1, "unfinished", False , now ms=0, self.assertEqual c.tick 1 000 , Effect "status", text="transcript timeout" , self.assertEqual c.mode, Mode.RECOVERING if name == " main ": unittest.main Run the suite: python -m unittest -v You should see five passing tests. More importantly, the tests prove behavioral properties rather than relying on real-time sleeps: Keep provider-specific code outside the controller. A simplified dispatcher might look like this: python async def apply effect effect, services, controller : try: if effect.kind == "generate": draft = await services.llm.generate prompt=effect.text, correlation id=effect.request id, for next effect in controller.on llm result effect.request id, draft, : await apply effect next effect, services, controller elif effect.kind == "moderate": result = await services.moderation.check effect.text next effects = controller.on moderation result effect.request id, allowed=result.allowed, approved text=result.approved text, for next effect in next effects: await apply effect next effect, services, controller elif effect.kind == "speak": await services.speech.play text=effect.text, correlation id=effect.request id, elif effect.kind == "cancel generation": await services.llm.cancel effect.request id elif effect.kind == "stop speech": await services.speech.stop effect.request id elif effect.kind == "status": services.ui.show status effect.text except Exception: Classify the actual stage in production rather than using one broad exception handler. for recovery effect in controller.on stage error effect.request id, effect.kind, : services.ui.show status recovery effect.text services.llm , services.moderation , and services.speech are ports owned by your application. Map correlation id to the request-identifier mechanism supported by your configured integration; do not assume the pseudocode argument name is an SDK field. The live event flow is then: on transcript . tick from your application timer. on user speech started when your turn-detection policy confirms a new user turn. on speech finished only after playback completion is confirmed. on disconnect before attempting to rejoin. resume after the media and recognition path is ready again. The Tencent RTC Social Entertainment solution https://trtc.io/solutions/social-entertainment includes AI virtual companions and character dialogue among its scenarios. The commit gate applies equally to a playful character, an assistant, or an AI host because it controls turns rather than personality. Do not copy 350 ms into production and call it solved. It is an initial configuration value, not a universal latency target. Choose it with a replay set containing your application's actual conversational patterns: | Observation | Likely adjustment | Trade-off | |---|---|---| | Clause-ending pauses create separate requests | Increase the settling window | Slower response start | | Complete commands feel delayed | Decrease the settling window | More premature commits | | Recognition emits several final segments per sentence | Keep segment aggregation enabled | Requires stable ordering and IDs | | Very long partials never finalize | Review recognition behavior and timeout policy | Recovery may ask the user to repeat | Record at least these timestamps per request identifier: first transcript at last final segment at utterance committed at llm started at llm finished at moderation finished at speech playback started at speech playback finished at This separates distinct delays. A single “AI latency” number cannot tell you whether time was spent waiting for a turn boundary, generating text, checking the output, synthesizing speech, or starting playback. The transcript timeout enters RECOVERING and emits transcript timeout . Show a visible and, when possible, accessible prompt such as “I didn't catch the end of that.” Do not submit the partial text as though it were confirmed. Provider cancellation may fail or arrive too late. The request-ID check is the authoritative defense: a callback without current ownership produces no speech. Do not silently bypass the stage. Move to recovery, keep the draft out of synthesis, and let the user retry. For a social companion, moderation and user-visible control belong in the runtime design, not only in the prompt. The generated answer may be valid while its delivery outcome is unknown. Show a failure state and return to listening only through an explicit recovery action. Automatically generating a new answer could duplicate content. Increment the epoch before rejoining. Old ASR callbacks are then structurally incapable of forming a new request. Start a fresh turn rather than pretending an interrupted utterance continued seamlessly. This in-memory example intentionally does not resume an in-flight turn. A restarted service should create a new epoch and expose the interruption. If you later persist state, store the epoch, active request identifier, mode, and committed transcript together; restoring only the transcript can replay a turn without restoring its ownership rules. Run these drills with real audio and your configured providers: A model can demonstrably generate engaging character dialogue. That does not demonstrate that a live companion can identify a completed turn, recover from a partition, or respect an interruption. The human decision underneath the model comparison is where to spend the next engineering cycle. Before changing models or expanding the character prompt, inspect ten imperfect voice sessions. If you find split sentences, duplicate requests, or stale replies, add a commit gate and correlation logging first. Model evaluation becomes more meaningful once every candidate receives the same coherent input turns. Disclosure: This article was produced in connection with Tencent RTC. Official Tencent RTC documentation was used as the implementation reference.