Appearance
Semantic end-of-turn detection (Smart-Turn)
Status: wired into the live conversational loop, gated behind config and default-off. The detector module lives under src/backends/turn/, the model server is servers/turn_server.py, and the listener integration is in ConversationalListener.captureTurn().
Why energy/VAD isn't enough
A mid-thought pause and a finished-turn pause are acoustically identical — both are silence. Energy/frequency VAD (which is exactly what the sox silence gate already does) can only ever wait a fixed amount of silence and then guess "done." That fixed timer is the whole problem: too short cuts you off mid-thought, too long feels laggy. The distinguishing signal lives in the prosody and linguistic completeness leading into the pause, not in the energy envelope — which is why this needs a (tiny) model, and why ChatGPT/Gemini use semantic turn detection too.
Why
Without Smart-Turn, turn-taking is decided by quiet alone: the streaming VAD (on by default) ends the turn after vad.hangover_ms of silence, and with vad.enabled: false the plain silence gate does the same with silenceDuration. Either way it's a timer — it cuts users off when they pause mid-thought, and waits too long when they're clearly done. Smart-Turn is a small ONNX classifier that, given the buffered speech, predicts whether the turn is semantically complete — so we can end snappily when the user is done and keep the mic open through natural pauses.
Design adapted (ideas, not code — flowcat is Rust, Apache-2.0) from flowcat's TurnAnalyzer / TurnSilenceTracker, themselves a port of pipecat's BaseSmartTurn.
What's in the spike
A provider seam matching the STT/TTS/LLM pattern:
SilenceTracker(silence-tracker.ts) — pure, deterministic silence accumulation. Works with no model: once speech is seen, silence paststopSecsforces an end-of-turn. This is the safety floor.SmartTurnProvider(smart-turn.ts) —TurnDetectorover HTTP (Cicero's Python-ML convention; reusesnet.tshost/port).predict(samples, rate)→{complete, probability}. Degrades gracefully: any server error returns an incomplete prediction, so the silence floor governs and a model hiccup never stalls the conversation.decideEndOfTurn(policy.ts) — the combiner: model-complete (prob ≥ threshold) → end now; else silence-timeout → end (hard ceiling); else → wait.createTurnDetector(config)(index.ts) — factory / extension point.
All pure logic is unit-tested with fakes — no audio, no model required.
The model server (servers/turn_server.py)
Smart-Turn v3 = Whisper-tiny encoder + a shallow linear head (8M params, ~8MB int8 ONNX, ~12ms CPU inference). The server loads pipecat-ai/smart-turn-v3 (smart-turn-v3.2-cpu.onnx) and exposes the wire contract smart-turn.ts expects:
POST /predict {model, sample_rate, audio:number[]}→{prediction, probability, is_complete}GET /healthandGET /→{"status":"ok"}
Preprocessing matches pipecat's reference inference exactly: keep the last 8s of audio, WhisperFeatureExtractor(chunk_length=8) → [1, 80, 800] log-mel, do_normalize=True. The v3.2-cpu checkpoint bakes the sigmoid in, so the output is already a probability in [0,1] that the turn is complete.
The daemon manages the server like STT does (smart-turn.ts start() → startManagedServer), launching it from a dedicated .venv-turn. It runs on :8087 (8086 is the dashboard). Create the small Python 3.11 environment once:
sh
uv venv .venv-turn --python 3.11
uv pip install --python .venv-turn -r requirements/turn.txtThe same commands work on macOS, Linux, and Windows. Passing the environment directory lets uv find either POSIX bin/python or Windows Scripts/python.exe without merging Smart-Turn into the larger STT/MLX graph.
For upgrades from older checkouts, the launcher uses the first available interpreter in .venv-turn, .venv-stt, then .venv. The latter two are migration-only fallbacks and emit a deprecation warning at every Smart-Turn start; they prevent an existing voice box from silently losing semantic turn detection on restart. Move the installed stack into its dedicated environment with the commands above. Once .venv-turn is usable, the shared environments are no longer selected.
How the listener uses it (captureTurn())
Each recordUntilSilence() return is a candidate end-of-turn:
- Transcribe the segment; decode the wav to mono
Float32Arrayanddetector.predict(samples, 16000). decideEndOfTurn({ prediction, silenceForced, threshold }):endTurn→ finalize the turn (join segments, hand off to STT/brain).waiting→ reopen the mic for a bounded grace window and append the continuation;silenceForced(grace budget exhausted) is the hard ceiling.
The grace re-record uses a wall-clock deadline that only fires while the file is still silent — so if the user really stopped we give up after grace_max_duration, but once speech is flowing sox stops on natural silence and a long continuation is never truncated.
Health-gated: the grace loop engages only if detector.health() passes at activation; if the server is down (or dies mid-session) it reverts to plain silence detection, so the feature is a safe no-op without a running server.
Enabling it
Add a turn block to ~/.cicero/config.yaml (all fields optional; shown with defaults). enabled: false by default:
yaml
turn:
enabled: true # master switch (default false)
backend: smart-turn # default
port: 8087 # model server port (8086 is the dashboard)
model: pipecat-ai/smart-turn-v3
threshold: 0.6 # P(complete) at/above which the turn ends
grace_attempts: 2 # max re-record rounds when the model says "not done"
grace_max_duration: 3 # seconds to wait for the user to resume per grace round
timeout_ms: 10000 # absolute /predict deadline, including response bodyTuning tip (local-mic conversational mode): turn detection composes with a shorter quiet window. On the local mic's default path that window is the streaming VAD's vad.hangover_ms — lower it (e.g. 300) for snappy turn-taking; silence_duration only governs when vad.enabled: false, where "0.5" is the equivalent move. Either way the grace loop catches the mid-thought cutoffs a short window would otherwise cause. These knobs do not apply to the browser path: the web-voice page runs its own client-side VAD with a fixed hangover, and Smart-Turn rides on top of whatever the client sends.
This touches the load-bearing listen loop, so it stays gated behind verify-before-stacking — confirm the conversational voice path on hardware first.