Duplex Mode Setup Guide
View SourceThis guide explains how to implement a production-style duplex voice loop with Synaptic.
Duplex mode is best when you want:
- continuous microphone capture
- automatic end-of-turn by VAD/silence
- barge-in (user interrupt while assistant is speaking)
- backend-orchestrated STT/TTS and workflow control
1. Architecture and ownership
In duplex mode:
- your client owns: microphone, VAD, playback UX
- Synaptic owns: session lifecycle, STT/TTS adapters, workflow resume, normalized events
Headless output note:
- built-in providers now default to one TTS generation per assistant turn for more consistent tone
- assistant text still streams during workflow execution, but assistant audio may not begin until the workflow emits
:stream_done - segmented per-sentence TTS remains an internal fallback for adapters without capability metadata
Key server module path:
Synaptic.Voice.Sessions.Headless(mode: :duplex)
Key lifecycle notes:
workflow_waiting_for_humandoes not automatically mean "safe to listen"- listening turn entry is gated by output completion and playback drain
- interruptions should call
cancel_output, not regularend_turn
2. Prerequisites
Server:
OPENAI_API_KEYfor OpenAI duplexGEMINI_API_KEYfor Gemini duplexELEVENLABS_API_KEYfor ElevenLabs duplexSynaptic.Voiceconfig set (provider defaults optional, per-session overrides supported)
Client:
getUserMediamicrophone permissionMediaRecordersupport (or equivalent capture pipeline)AudioContextplayback capability
3. Session bootstrap
Start a session from your server boundary (controller/live view/channel):
{:ok, %{session_id: session_id, run_id: run_id}} =
Synaptic.Voice.start_session(MyWorkflow, %{},
provider: :openai, # or :gemini / :eleven_labs
mode: :duplex,
keep_alive: true
)
:ok = Synaptic.Voice.subscribe_session(session_id)
:ok = Synaptic.subscribe(run_id)Session payloads you should track per client:
session_idrun_id- current
statusfrom:duplex_state_changed
4. Client capture and end-turn protocol
Recommended capture loop:
- open microphone stream once
- start recorder with short timeslices (for example 200-300ms)
- run local VAD to decide speech/noise/silence
- push speech chunks with
duplex_audio_chunk - when silence threshold is met, stop recorder and finalize turn
Important protocol detail:
- never send
duplex_end_turnbefore final recorder chunks have flushed - wait for recorder stop + pending chunk flush completion
- include a timeout guard to avoid hanging if flush completion never arrives
Why:
- incomplete WebM/Opus container upload can trigger STT 400 invalid/corrupted audio errors
5. Barge-in protocol
When user speech is detected during assistant output:
- stop local playback immediately
- send interrupt event (
duplex_interrupt) - server calls
Synaptic.Voice.cancel_output(session_id) - continue capturing user speech
- finalize interrupted user turn with normal end-turn flow
Do not treat barge-in as regular silence-based end-turn only. It must go through cancel-output semantics to keep lifecycle clean.
6. Required server events to handle
From {:synaptic_voice_event, envelope}:
:duplex_state_changed-> drive UI status:input_partial_text/:input_final_text-> transcript:assistant_text_chunk/:assistant_text_done-> assistant transcript stream:assistant_audio_chunk/:assistant_audio_done-> playback:duplex_interruption-> interruption telemetry/UX:session_error-> recoverable errors and user messaging:session_stopped-> teardown
For playback drain gating:
- send a client ack back (
duplex_playback_drained) after audio is actually drained locally
Important timing note:
- do not assume
:assistant_audio_chunkwill begin immediately after the first:assistant_text_chunk - for built-in providers, audio commonly starts after
:assistant_text_donebecause TTS now defaults to full-turn synthesis
7. Error handling and recovery defaults
Recommended user-facing classification:
source: :stt+empty_transcript-> "No speech recognized. Please try again."source: :stt+transcription_failed-> "No voice detected. Please try again."source: :tts-> "Audio output failed. Please try again."source: :resume-> "Unable to continue the conversation. Please try again."
Recovery behavior:
- keep session alive unless stop/terminal error
- transition to listening when recoverable
- clear turn flags on STT/TTS/resume error paths
8. Lifecycle checklist
Before shipping duplex:
- confirm assistant playback drain ack is wired and received
- confirm end-turn flush waits for final recorder chunk
- confirm barge-in uses
cancel_outputpath - confirm UI tolerates delayed first audio while assistant text is already streaming
- confirm client suppresses stale audio after interrupt
- confirm next-turn TTS still plays after cancel
- confirm no atom creation from client log/event names
- confirm teardown clears timers, streams, and audio nodes
9. Verification checklist
Run these scenarios end-to-end:
- normal turn 1 -> turn 2 -> turn 3 without reconnect
- long assistant response interrupted early by barge-in
- interrupted user question returns spoken (not text-only) answer
- silence does not continuously flood upstream processing
- transient STT/TTS failure recovers back to usable listening state
10. Reference implementation
See internal sample app:
tmp/voice_lab
Core files:
- server bridge:
tmp/voice_lab/lib/voice_lab_web/live/home_live.ex - client hook:
tmp/voice_lab/assets/js/app.js - core session engine:
lib/synaptic/voice/sessions/headless.ex
Frontend-focused companion guide: