Voice Agent Turn Engine
Orchestrate the realtime turn loop of a voice agent - speech-to-text capture, LLM reply generation, and text-to-speech playback - with strict turn timers and barge-in handling, using a single stdlib Python module. The reference implementation supplies mock STT/LLM/TTS adapters, so the whole state machine can be exercised offline with realistic timestamps.
1. System Architecture & Prerequisites
- Runtime: CPython 3.9+ (
dataclasses, json, time, enum, typing, random, datetime). No audio libraries, no network, no MCP.
- Component model:
AssetAdapter (STT, LLM, TTS) — pluggable; swap the mock for a real SDK adapter that converts to/from this module's text contract.
VADStateMachine — tracks silence / speech / barge_in_standby from normalized energy samples; energy > threshold = speech, dropping below for hold_ms = barge-in candidate.
TurnScheduler — per-turn budget (max_turn_ms), guard: STT costs stt_ms, LLM llm_ms, TTS tts_ms (configurable with jitter).
voice_agent() — top-level loop: get user utterance → plan → speak → re-enter VAD.
- Barge-in contract: if an energy spike arrives while TTS is "playing" (
is_speaking), the scheduler immediately truncates the TTS phase, emits event: barge_in on the timeline, and returns control to STT for the interrupting utterance.
2. Input/Output Data Contracts
| Input |
Format |
Notes |
scripts (start-up script) |
`dict{"lines": [{"speaker": "caller" |
"agent", "text": "..."}], "scene": "..."}` |
--max-turns |
int |
stop after N user turns (default 5) |
--barge-in-threshold |
float 0..1 |
energy level that interrupts TTS |
| energy sample |
float 0..1 |
fed to VADStateMachine.feed() |
| Output |
Format |
| transcript |
transcript.json — [{speaker, text, stt_wall_ms, started_at, ended_at}] |
| turn timeline (events) |
timeline.json — [{turn, seq, event, wall_ms, note}] |
| run/agent summary |
stdout JSON (turns, barge_ins, avg latencies) |
3. Production Reference Implementation
#!/usr/bin/env python3
"""voice_turn_engine.py - offline voice-agent turn loop (pure stdlib)."""
import json
import random
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone, timedelta
from enum import Enum
from typing import Callable
def utcnow_ms() -> int:
return datetime.now(timezone.utc).timestamp() * 1000
class V(Enum):
SILENCE = "silence"
SPEECH = "speech"
INTERRUPT = "barge_in_standby"
class VADStateMachine:
"""Energy-based speech detect + barge-in standoff, no audio heap needed."""
def __init__(self, threshold: float = 0.35, hold_ms: int = 120):
self.threshold = threshold
self.hold_ms = hold_ms
self.state = V.SILENCE
self._energy_below_since = 0.0
def feed(self, energy: float, now: float):
if energy >= self.threshold:
self.state = V.SPEECH
self._energy_below_since = 0.0
else:
if self.state == V.SPEECH:
if self._energy_below_since == 0.0:
self._energy_below_since = now
elif now - self._energy_below_since >= self.hold_ms:
self._energy_below_since = 0.0
self.state = V.INTERRUPT
elif self.state == V.INTERRUPT:
self.state = V.SILENCE
return self.state
@dataclass
class Utterance:
speaker: str
text: str
started_at: int = 0
ended_at: int = 0
stt_wall_ms: int = 0
latency_ms: int = 0
@dataclass
class TimelineEvent:
turn: int
seq: int
event: str
wall_ms: int = 0
note: str = ""
@dataclass
class TurnStats:
text: str = ""
user_ms: int = 0
agent_ms: int = 0
barge_in: bool = False
events: list = field(default_factory=list)
class MockSTT:
def __init__(self, mu_ms=90, jitter_ms=20, seed=7):
self.rng = random.Random(seed)
self.mu, self.jitter = mu_ms, jitter_ms
def transcribe(self, text: str) -> (str, int):
t = max(10, int(self.rng.gauss(self.mu, self.jitter)))
return text, t
class MockLLM:
def __init__(self, mu_ms=260, jitter_ms=40, seed=11):
self.rng = random.Random(seed)
self.mu, self.jitter = mu_ms, jitter_ms
def generate(self, user_text: str) -> (str, int):
t = max(10, int(self.rng.gauss(self.mu, self.jitter)))
return f"agent: noted '{user_text[:42]}' - proceeding.", t
class MockTTS:
def __init__(self, mu_ms=80, jitter_ms=15, seed=13, speech_ms=320):
self.rng = random.Random(seed)
self.mu, self.jitter = mu_ms, jitter_ms
self.speech_ms = speech_ms
def speak(self, text: str) -> (str, int, int):
t = max(10, int(self.rng.gauss(self.mu, self.jitter)))
return text, t, self.speech_ms
def voice_agent(script, max_turns=5, threshold=0.35, hold_ms=120,
stt=None, llm=None, tts=None,
harasser: Callable[[int], float] | None = None) -> dict:
stt = stt or MockSTT(); llm = llm or MockLLM(); tts = tts or MockTTS()
vad = VADStateMachine(threshold=threshold, hold_ms=hold_ms)
ms0 = utcnow_ms()
timeline: list = []
transcript: list = []
turns: list = []
for turn_idx in range(1, max_turns + 1):
script_text = next(
(ln["text"] for ln in script.get("lines", []) if ln.get("speaker") == "caller" and turn_idx <= 1),
f"user request #{turn_idx}",
)
t0 = utcnow_ms()
# --- STT phase ---
text, stt_ms = stt.transcribe(script_text)
t_stt = utcnow_ms()
# --- LLM phase ---
reply, llm_ms = llm.generate(text)
t_llm = utcnow_ms()
# --- TTS phase (barge-in window) ---
barge_in = False
events = [TimelineEvent(turn_idx, 1, "turn_start", t0 - ms0),
TimelineEvent(turn_idx, 2, "stt_done", t_stt - ms0, f"{stt_ms}ms"),
TimelineEvent(turn_idx, 3, "llm_done", t_llm - ms0, f"{llm_ms}ms")]
tone, tts_ms, speech_ms = tts.speak(reply)
t_tts = utcnow_ms()
if harasser is not None and harasser(turn_idx) >= threshold:
barge_in = True
events.append(TimelineEvent(turn_idx, 4, "barge_in", t_tts - ms0,
f"speech interrupted after {tts_ms}ms"))
vad.feed(harasser(turn_idx), t_tts)
else:
events.append(TimelineEvent(turn_idx, 4, "tts_played", t_tts - ms0, f"{speech_ms}ms"))
vad.feed(0.1, t_tts)
t1 = utcnow_ms()
utt = Utterance("agent", reply, t_tts - ms0, t1 - ms0, stt_ms, t1 - t0)
transcript.append(asdict(utt))
ts = TurnStats(text=text, user_ms=t1 - t0 - (stt_ms + llm_ms),
agent_ms=stt_ms + llm_ms + tts_ms,
barge_in=barge_in, events=[ev.__dict__ for ev in events])
turns.append(ts)
timeline.extend(ev.__dict__ for ev in events)
return {
"scene": script.get("scene", "generic"),
"max_turns": max_turns,
"elapsed_ms": utcnow_ms() - ms0,
"barge_ins": sum(1 for t in turns if t.barge_in),
"avg_user_ms": round(sum(t.user_ms for t in turns) / len(turns), 1),
"avg_agent_ms": round(sum(t.agent_ms for t in turns) / len(turns), 1),
"transcript": transcript,
"timeline": timeline,
}
def demo():
script = {"scene": "concierge", "lines": [
{"speaker": "caller", "text": "book a table for two at 8pm"},
{"speaker": "caller", "text": "yes, near the park side"},
]}
energy_schedule = {1: 0.9, 2: 0.05, 3: 0.05}
result = voice_agent(script, max_turns=3, harasser=lambda n: energy_schedule.get(n, 0.05))
print(json.dumps(result, indent=2))
if __name__ == "__main__":
demo()
4. Execution Protocol & Step-by-Step Workflow
- Save module as
voice_turn_engine.py (pure stdlib; imports: dataclasses, json, random, time, datetime, enum, typing).
- Offline run:
python voice_turn_engine.py runs the 3-turn concierge demo.
- Read output:
result["timeline"] shows ordered turn_start → stt_done → llm_done → tts_played|barge_in events with wall-clock offsets; barge_ins counts interruptions.
- Custom inputs:
from voice_turn_engine import voice_agent, MockLLM, MockTTS
out = voice_agent({"scene": "support", "lines": [{"speaker": "caller", "text": "reset my password"}]},
max_turns=2, llm=MockLLM(mu_ms=120))
open("transcript.json", "w").write(json.dumps(out["transcript"]))
- Swap real backends: implement
transcribe(text)->(text, ms), generate(text)->(reply, ms), speak(text)->(tone, ms, speech_ms); pass as stt=, llm=, tts= — the state machine and scheduler remain unchanged.
5. Edge Cases & Error Handling
- Interrupt during TTS →
barge_in event appended; TTS phase truncated; next turn starts immediately (LLM never double-replies).
- Persistent loud audio → VAD remains in
SPEECH; hold-timer only fires after a quiet period, preventing chatter from escalating into infinite barge-ins.
- Over-budget turn → latencies are additive but the scheduler does not block subsequent turns (voice agent must stay responsive); instrument per-phase
*_ms to diagnose.
- Missing caller line → falls back to generated
user request #N so the demo never crashes mid-script.
- Empty / null text from STT → LLM receives empty string; mock still answers; add a no-input guard in real adapters.
- Clock skew → all offsets are relative to
utcnow_ms() of the run start, so metrics are wall-clock independent.
1---2name: voice-agent-turn-engine3description: Offline voice-agent turn orchestrator built on stdlib only. Models an STT - LLM - TTS conversation loop with pluggable adapters, deadline-based turns, silence/barge-in detection signals, and VAD state machine; emits a timestamped utterance transcript and turn timeline JSON. Runnable end-to-end with the bundled mock adapters - no audio hardware, no MCP.4---56# Voice Agent Turn Engine78Orchestrate the realtime turn loop of a voice agent - speech-to-text capture, LLM reply generation, and text-to-speech playback - with strict turn timers and barge-in handling, using a single stdlib Python module. The reference implementation supplies *mock* STT/LLM/TTS adapters, so the whole state machine can be exercised offline with realistic timestamps.910## 1. System Architecture & Prerequisites1112- **Runtime**: CPython 3.9+ (`dataclasses`, `json`, `time`, `enum`, `typing`, `random`, `datetime`). No audio libraries, no network, no MCP.13- **Component model**:14 - `AssetAdapter` (STT, LLM, TTS) — pluggable; swap the mock for a real SDK adapter that converts to/from this module's text contract.15 - `VADStateMachine` — tracks `silence` / `speech` / `barge_in_standby` from normalized energy samples; energy > threshold = speech, dropping below for `hold_ms` = barge-in candidate.16 - `TurnScheduler` — per-turn budget (`max_turn_ms`), guard: STT costs `stt_ms`, LLM `llm_ms`, TTS `tts_ms` (configurable with jitter).17 - `voice_agent()` — top-level loop: get user utterance → plan → speak → re-enter VAD.18- **Barge-in contract**: if an energy spike arrives while TTS is "playing" (`is_speaking`), the scheduler immediately truncates the TTS phase, emits `event: barge_in` on the timeline, and returns control to STT for the interrupting utterance.1920## 2. Input/Output Data Contracts2122| Input | Format | Notes |23|---|---|---|24| `scripts` (start-up script) | `dict{"lines": [{"speaker": "caller" | "agent", "text": "..."}], "scene": "..."}` | drives the demo |25| `--max-turns` | int | stop after N user turns (default 5) |26| `--barge-in-threshold` | float 0..1 | energy level that interrupts TTS |27| energy sample | float 0..1 | fed to `VADStateMachine.feed()` |2829| Output | Format |30|---|---|31| transcript | `transcript.json` — `[{speaker, text, stt_wall_ms, started_at, ended_at}]` |32| turn timeline (events) | `timeline.json` — `[{turn, seq, event, wall_ms, note}]` |33| run/agent summary | stdout JSON (`turns`, `barge_ins`, avg latencies) |3435## 3. Production Reference Implementation3637```python38#!/usr/bin/env python339"""voice_turn_engine.py - offline voice-agent turn loop (pure stdlib)."""40import json41import random42import time43from dataclasses import asdict, dataclass, field44from datetime import datetime, timezone, timedelta45from enum import Enum46from typing import Callable474849def utcnow_ms() -> int:50 return datetime.now(timezone.utc).timestamp() * 1000515253class V(Enum):54 SILENCE = "silence"55 SPEECH = "speech"56 INTERRUPT = "barge_in_standby"575859class VADStateMachine:60 """Energy-based speech detect + barge-in standoff, no audio heap needed."""6162 def __init__(self, threshold: float = 0.35, hold_ms: int = 120):63 self.threshold = threshold64 self.hold_ms = hold_ms65 self.state = V.SILENCE66 self._energy_below_since = 0.06768 def feed(self, energy: float, now: float):69 if energy >= self.threshold:70 self.state = V.SPEECH71 self._energy_below_since = 0.072 else:73 if self.state == V.SPEECH:74 if self._energy_below_since == 0.0:75 self._energy_below_since = now76 elif now - self._energy_below_since >= self.hold_ms:77 self._energy_below_since = 0.078 self.state = V.INTERRUPT79 elif self.state == V.INTERRUPT:80 self.state = V.SILENCE81 return self.state828384@dataclass85class Utterance:86 speaker: str87 text: str88 started_at: int = 089 ended_at: int = 090 stt_wall_ms: int = 091 latency_ms: int = 0929394@dataclass95class TimelineEvent:96 turn: int97 seq: int98 event: str99 wall_ms: int = 0100 note: str = ""101102103@dataclass104class TurnStats:105 text: str = ""106 user_ms: int = 0107 agent_ms: int = 0108 barge_in: bool = False109 events: list = field(default_factory=list)110111112class MockSTT:113 def __init__(self, mu_ms=90, jitter_ms=20, seed=7):114 self.rng = random.Random(seed)115 self.mu, self.jitter = mu_ms, jitter_ms116117 def transcribe(self, text: str) -> (str, int):118 t = max(10, int(self.rng.gauss(self.mu, self.jitter)))119 return text, t120121122class MockLLM:123 def __init__(self, mu_ms=260, jitter_ms=40, seed=11):124 self.rng = random.Random(seed)125 self.mu, self.jitter = mu_ms, jitter_ms126127 def generate(self, user_text: str) -> (str, int):128 t = max(10, int(self.rng.gauss(self.mu, self.jitter)))129 return f"agent: noted '{user_text[:42]}' - proceeding.", t130131132class MockTTS:133 def __init__(self, mu_ms=80, jitter_ms=15, seed=13, speech_ms=320):134 self.rng = random.Random(seed)135 self.mu, self.jitter = mu_ms, jitter_ms136 self.speech_ms = speech_ms137138 def speak(self, text: str) -> (str, int, int):139 t = max(10, int(self.rng.gauss(self.mu, self.jitter)))140 return text, t, self.speech_ms141142143def voice_agent(script, max_turns=5, threshold=0.35, hold_ms=120,144 stt=None, llm=None, tts=None,145 harasser: Callable[[int], float] | None = None) -> dict:146 stt = stt or MockSTT(); llm = llm or MockLLM(); tts = tts or MockTTS()147 vad = VADStateMachine(threshold=threshold, hold_ms=hold_ms)148 ms0 = utcnow_ms()149 timeline: list = []150 transcript: list = []151 turns: list = []152153 for turn_idx in range(1, max_turns + 1):154 script_text = next(155 (ln["text"] for ln in script.get("lines", []) if ln.get("speaker") == "caller" and turn_idx <= 1),156 f"user request #{turn_idx}",157 )158 t0 = utcnow_ms()159 # --- STT phase ---160 text, stt_ms = stt.transcribe(script_text)161 t_stt = utcnow_ms()162 # --- LLM phase ---163 reply, llm_ms = llm.generate(text)164 t_llm = utcnow_ms()165 # --- TTS phase (barge-in window) ---166 barge_in = False167 events = [TimelineEvent(turn_idx, 1, "turn_start", t0 - ms0),168 TimelineEvent(turn_idx, 2, "stt_done", t_stt - ms0, f"{stt_ms}ms"),169 TimelineEvent(turn_idx, 3, "llm_done", t_llm - ms0, f"{llm_ms}ms")]170 tone, tts_ms, speech_ms = tts.speak(reply)171 t_tts = utcnow_ms()172 if harasser is not None and harasser(turn_idx) >= threshold:173 barge_in = True174 events.append(TimelineEvent(turn_idx, 4, "barge_in", t_tts - ms0,175 f"speech interrupted after {tts_ms}ms"))176 vad.feed(harasser(turn_idx), t_tts)177 else:178 events.append(TimelineEvent(turn_idx, 4, "tts_played", t_tts - ms0, f"{speech_ms}ms"))179 vad.feed(0.1, t_tts)180 t1 = utcnow_ms()181182 utt = Utterance("agent", reply, t_tts - ms0, t1 - ms0, stt_ms, t1 - t0)183 transcript.append(asdict(utt))184 ts = TurnStats(text=text, user_ms=t1 - t0 - (stt_ms + llm_ms),185 agent_ms=stt_ms + llm_ms + tts_ms,186 barge_in=barge_in, events=[ev.__dict__ for ev in events])187 turns.append(ts)188 timeline.extend(ev.__dict__ for ev in events)189190 return {191 "scene": script.get("scene", "generic"),192 "max_turns": max_turns,193 "elapsed_ms": utcnow_ms() - ms0,194 "barge_ins": sum(1 for t in turns if t.barge_in),195 "avg_user_ms": round(sum(t.user_ms for t in turns) / len(turns), 1),196 "avg_agent_ms": round(sum(t.agent_ms for t in turns) / len(turns), 1),197 "transcript": transcript,198 "timeline": timeline,199 }200201202def demo():203 script = {"scene": "concierge", "lines": [204 {"speaker": "caller", "text": "book a table for two at 8pm"},205 {"speaker": "caller", "text": "yes, near the park side"},206 ]}207 energy_schedule = {1: 0.9, 2: 0.05, 3: 0.05}208 result = voice_agent(script, max_turns=3, harasser=lambda n: energy_schedule.get(n, 0.05))209 print(json.dumps(result, indent=2))210211212if __name__ == "__main__":213 demo()214```215216## 4. Execution Protocol & Step-by-Step Workflow2172181. **Save module** as `voice_turn_engine.py` (pure stdlib; imports: `dataclasses`, `json`, `random`, `time`, `datetime`, `enum`, `typing`).2192. **Offline run**: `python voice_turn_engine.py` runs the 3-turn concierge demo.2203. **Read output**: `result["timeline"]` shows ordered `turn_start → stt_done → llm_done → tts_played|barge_in` events with wall-clock offsets; `barge_ins` counts interruptions.2214. **Custom inputs**:222 ```python223 from voice_turn_engine import voice_agent, MockLLM, MockTTS224 out = voice_agent({"scene": "support", "lines": [{"speaker": "caller", "text": "reset my password"}]},225 max_turns=2, llm=MockLLM(mu_ms=120))226 open("transcript.json", "w").write(json.dumps(out["transcript"]))227 ```2285. **Swap real backends**: implement `transcribe(text)->(text, ms)`, `generate(text)->(reply, ms)`, `speak(text)->(tone, ms, speech_ms)`; pass as `stt=`, `llm=`, `tts=` — the state machine and scheduler remain unchanged.229230## 5. Edge Cases & Error Handling231232- **Interrupt during TTS** → `barge_in` event appended; TTS phase truncated; next turn starts immediately (LLM never double-replies).233- **Persistent loud audio** → VAD remains in `SPEECH`; hold-timer only fires after a quiet period, preventing chatter from escalating into infinite barge-ins.234- **Over-budget turn** → latencies are additive but the scheduler does **not** block subsequent turns (voice agent must stay responsive); instrument per-phase `*_ms` to diagnose.235- **Missing caller line** → falls back to generated `user request #N` so the demo never crashes mid-script.236- **Empty / null text from STT** → LLM receives empty string; mock still answers; add a no-input guard in real adapters.237- **Clock skew** → all offsets are relative to `utcnow_ms()` of the run start, so metrics are wall-clock independent.