@demystify/voice-client
v0.3.0
Published
Browser/edge client for dmstfy-voice-core: typed events, WebSocket transport, barge-in aware voice sessions
Readme
@demystify/voice-client
Browser/edge client for dmstfy-voice-core voice agents: typed events, WebSocket transport (binary PCM + JSON events), barge-in aware.
import { voiceClient } from "@demystify/voice-client";
const call = await voiceClient.start({ agent: "arkivo-support", baseUrl, token });
call.on("interruption", () => ui.showListening());
call.on("transcript.final", ({ text, role }) => ui.append(role, text));
call.sendAudio(micChunk);
call.end();Speaker integration (barge-in critical)
Audio arrives faster than real time, so your app keeps a local speaker buffer.
On barge-in the server sends playback.clear; the SDK surfaces it as a
playback_clear event and your speaker must flush/abort its buffer — the
audio it holds belongs to the interrupted utterance and must never play:
const speaker = createPcmSpeaker(); // your AudioWorklet/queue implementation
call.on("audio", ({ data }) => speaker.enqueue(data)); // s16le mono PCM
call.on("playback_clear", () => speaker.flush()); // agent goes silent NOW
call.on("agent_speaking", ({ speaking }) => ui.setTalking(speaking));The server also paces audio in ~50 ms sub-chunks so the local buffer stays
shallow — but without handling playback_clear, an interrupt would still leave
up to a sub-chunk (plus your device latency) of stale audio playing.
Events: agent_speaking · user_speaking · interruption · playback_clear
· transcript.partial / transcript.final · tool_call · session.ended
(+ audio, error).
Auth: voiceClient.start performs the join handshake by sending
{"type":"auth","token":...} as the first WebSocket message — the short-lived
join token never appears in the URL (URLs leak into proxy/server logs).
Microphone capture, echo cancellation & why WebRTC is v2 (honest notes)
This SDK moves PCM over a WebSocket; capturing the mic is your app's job
(getUserMedia + AudioWorklet, downsample to s16le mono 16 kHz). Be clear
about what that does and does not give you:
- What you get.
getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } })enables the browser's AEC/NS/AGC on the capture path. With a headset this is a complete solution and barge-in works exactly as advertised. - What you do NOT reliably get. Browser AEC needs a reference signal of what the device is playing. That reference is wired up dependably for WebRTC playout; audio you play yourself through the Web Audio API (as a WS client must) is only sometimes included, varying by browser/OS. So in an open-speaker setup the mic can re-capture the agent's own voice, the server VAD hears "speech", and the agent can barge in on itself.
- v1 mitigations, in order of preference: use headsets for demos; keep
echoCancellation: trueanyway (it often helps, it never hurts); raise the agent'sbarge_in.max_backchannel_energy/ VAD threshold; or gate yoursendAudiowhileagent_speakingis true if your product tolerates half-duplex turn-taking. - Why WebRTC is v2, not v1. WebRTC buys the standardized AEC-with-playout
path (echo-safe speakerphone), jitter buffers + Opus/FEC over UDP instead of
TCP head-of-line blocking, and NAT traversal. It also costs an SFU/ICE
deployment story and a second transport implementation on both ends. v1
proves the hard part — the interruption pipeline and the client-visible
stop — over the simplest transport that can carry it; the WebRTC seams are
already reserved (
WebRtcTransportOptionshere, a Pipecat-based sketch behind the runtime'spipecatextra) so v2 swaps the transport, not the architecture.
WebSocketTransport — exactly what it is
WebSocketTransport (the default) speaks the dmstfy-voice-core control-plane
session WebSocket. Facts, from the code on both ends:
- Endpoint.
voiceClient.startfirst callsPOST {baseUrl}/v1/sessionson the control plane; the response'sjoin.ws_urlis/v1/sessions/{session_id}/ws, and the transport connects tows(s)://<baseUrl host>/v1/sessions/{session_id}/ws(FastAPI@app.websocketroute in the voice-core runtime), optionally with a?traceparent=query param to continue the session trace. - Handshake. The first frame the client sends must be the text frame
{"type":"auth","token":"<join token>"}— the token is never in the URL. The server closes with4401on bad auth,4404for an unknown/ended session, and4409on a second join (one session = one call; tokens can't be replayed into a live session). - Framing. Binary frames carry s16le mono PCM audio in both directions
(mic upstream via
sendAudio, agent audio downstream, surfaced as theaudioevent). Text frames carry JSON events: upstream{"type":"auth"}and{"type":"end"}; downstreamagent_speaking,user_speaking,interruption,playback.clear(emitted to your app asplayback_clear),transcript.partial,transcript.final,tool_call,session.ended— typed inVoiceClientEvents. - Browser-ready: yes. It uses the standard
WebSocketAPI (globalThis.WebSocket,binaryType = "arraybuffer") — nowsnpm dependency. It runs unchanged in browsers and in Node ≥ 22 (globalWebSocket); an optionalmakeWebSocketconstructor argument injects a custom factory for other runtimes. - What it does NOT do. It is not a WebRTC media transport: no
getUserMedia, no audio capture or playback, no acoustic echo cancellation, no jitter buffer, no automatic reconnection. Your app captures the mic and plays the PCM (see the speaker-integration section above); a dropped socket ends the call (session.ended, reasontransport_closed).
MockTransport is the in-memory twin for tests. WebRTC is v2 —
WebRtcTransportOptions is reserved, unimplemented. See the module README in
modules/dmstfy-voice-core/ for the full picture.
Writing your own Transport
Transport is a public seam (pass it as voiceClient.start({ transport })).
The contract, as WebSocketTransport/MockTransport implement it and as
Call consumes it:
type TransportMessage =
| { kind: "audio"; data: ArrayBuffer } // inbound agent audio (s16le PCM)
| { kind: "event"; event: Record<string, unknown> }; // inbound parsed JSON event
interface Transport {
connect(url: string): Promise<void>; // resolve when the link is up, reject on failure
sendAudio(pcm: ArrayBuffer | Uint8Array): void; // outbound mic audio
sendEvent(event: Record<string, unknown>): void; // outbound JSON control event
onMessage(fn: (message: TransportMessage) => void): void; // register (additive)
onClose(fn: () => void): void; // register (additive)
close(): void; // tear the link down
}Lifecycle and obligations for an implementation:
voiceClient.startcallsconnect(url)once, then immediatelysendEvent({ type: "auth", token }), then constructs theCall(which registers itsonMessage/onClosehandlers). Your transport must therefore accept handler registration after connect and deliver only messages that arrive after registration (the auth reply and everything else comes later).onMessage/onCloseare additive: keep a list, call every registered handler per message, in registration order. They are never unregistered.- Inbound binary must be dispatched as
{ kind: "audio", data: ArrayBuffer }; inbound structured events as{ kind: "event", event }withevent.typeset (that's whatCallswitches on). Deliverplayback.clearimmediately — it is latency-critical for barge-in. - Fire the
onClosehandlers exactly once when the link terminates for any reason (localclose()included);Callturns that intosession.endedwith reasontransport_closedif the server hadn't already ended the session. sendAudio/sendEventare only called afterconnecthas resolved (the SDK awaits it). InWebSocketTransport, a send beforeconnect()is a silent no-op (this.ws?.send) and a send after close is discarded by the underlying socket; matching that is sufficient. Reconnection is out of scope for a v1 transport: a session is one call.
MockTransport in src/transport.ts is a complete minimal reference
(plus serverEvent/serverAudio test helpers).
