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:

  • Commit too early and OpenAI receives incomplete thoughts.
  • Wait too long and the companion feels unresponsive.
  • Retry carelessly and the character answers twice.

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.

What we are building

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.

The state contract

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:

  1. There is at most one active request identifier.
  2. A callback is accepted only if it matches the active request and state.
  3. Reconnection creates a new session epoch, so old transcript callbacks cannot become new turns.

Create the project

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:

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 

Reproduce the races before connecting a microphone

Add deterministic tests to test_companion.py:

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.modeSource:https://dev.to/susiewang/stop-sending-half-sentences-to-openai-in-a-tencent-rtc-voice-companion-3ni6

Related posts