--- name: vobiz-audio-streams description: Stream live call audio bidirectionally over WebSocket for AI voice agents, real-time transcription, and custom audio pipelines. license: MIT compatibility: Vobiz REST API v1, WebSocket transport, L16 / mu-law. metadata: author: vobiz version: "1.1" --- # Vobiz Audio Streams skill Use this for **AI voice agents, real-time transcription, custom STT/TTS pipelines, or any workflow that needs raw audio frames** rather than just recordings. ## How it works 1. During a call, your VobizXML answer handler returns a `wss://your-server/ws` element. The WebSocket URL is the element's **text content**, not a `url=""` attribute. 2. Vobiz opens a WebSocket to your server and sends a single `start` event with `callId`, `streamId`, `tracks`, and `mediaFormat` nested under `start`. 3. Audio frames flow Vobiz → your server as `media` events (~50 per second per track, 20 ms each). 4. For bidirectional streams, your server sends `playAudio` events back, plus `checkpoint`, `clearAudio`, and `stop` control messages. 5. When the call ends, the WebSocket **closes** - there is no inbound `stop` event. ## Two ways to start a stream | Approach | When to use | |---|---| | **`` XML element** | The call is being set up by your answer handler. Best for AI agents that own the call from the first ring. This is the documented path for full-duplex voice agents. | | **REST `POST .../Stream/`** | The call is already live and you want to attach (or fork) audio mid-call - e.g. start transcription after a transfer, or stream a leg that is in a ``. Returns a `stream_id` you can later list/get/stop. | REST streams are forks: `bidirectional` over REST still requires `audio_track=inbound`, and **on the REST route `bidirectional` must be the JSON string `"true"`** - a JSON boolean `true` is accepted but silently produces a one-way stream (`audio-streams/start-audio-stream`). Both paths share the same WebSocket event protocol. ## REST endpoints Base: `https://api.vobiz.ai/api/v1`. Auth headers: `X-Auth-ID`, `X-Auth-Token`. | Op | Method + Path | Notes | |---|---|---| | Start stream | `POST /Account/{auth_id}/Call/{call_uuid}/Stream/` | Body needs `service_url`; returns `stream_id`. The doc page shows `202 Accepted`, `openapi.yaml` says `200` - treat either as success. | | List streams | `GET /Account/{auth_id}/Call/{call_uuid}/Stream/` | Active + stopped, `limit` (default 20) / `offset` (default 0). | | Get stream | `GET /Account/{auth_id}/Call/{call_uuid}/Stream/{stream_id}/` | Full Stream object; `404` if unknown. | | Stop one stream | `DELETE /Account/{auth_id}/Call/{call_uuid}/Stream/{stream_id}/` | `204`; `404` if unknown, already stopped, or on another call. Leaves other forks running. | | Stop all streams | `DELETE /Account/{auth_id}/Call/{call_uuid}/Stream/` | `204`, idempotent; tears down every fork on the call. Documented on `audio-streams/stop-all-audio-streams` (not in `openapi.yaml`). | Request fields on start: `service_url` (required), `audio_track` (`inbound`/`outbound`/`both`, default `both`), `bidirectional` (string `"true"`/`"false"`), `content_type`, `stream_timeout` (default 86400), `status_callback_url`, `status_callback_method`, `extra_headers`. `openapi.yaml` still carries a legacy `audio_format: [pcm, mulaw]` enum; `content_type` is the real field (`OPENAPI-GAPS.md` item 17). ## Audio format Treat the two directions separately. They are configured independently and may differ. - **Inbound (Vobiz → you)** - set with `contentType` (XML) / `content_type` (REST): `audio/x-l16;rate=8000` (default), `audio/x-l16;rate=16000`, or `audio/x-mulaw;rate=8000`. **24 kHz is not a valid inbound format** - `rate=24000` on `` connects but never delivers usable media. Vobiz reports the selected inbound format in `start.mediaFormat`; decode every `media.payload` from that. - **Outbound (you → Vobiz)** - set per `playAudio` event with `media.contentType` (`audio/x-l16` or `audio/x-mulaw`) and `media.sampleRate` (L16: `8000`, `16000`, or `24000`; μ-law: `8000`). The payload bytes must genuinely match the declared rate - changing the JSON number does not resample. - L16 is signed 16-bit linear PCM, mono. Byte order is not pinned down consistently across the docs (RFC 2586 says big-endian; `integrations/pipecat` reports some accounts transport little-endian and ships a `VOBIZ_L16_ENDIAN` switch; `integrations/gemini-live` sends little-endian). If L16 frames arrive at the expected size but STT returns nothing, flip the byte order. μ-law avoids the question entirely. - 20 ms frame = 160 bytes μ-law @ 8 kHz, 320 bytes L16 @ 8 kHz, 640 bytes L16 @ 16 kHz, 960 bytes L16 @ 24 kHz. Send `playAudio` in roughly 20-60 ms chunks (a recommendation, not a protocol rule). - Frames are base64-encoded raw samples inside `media.payload` / `playAudio.media.payload`. Never send WAV/MP3 container headers. - 24 kHz playback does not guarantee 24 kHz at the handset - the PSTN/SIP leg may use a lower-rate codec. ## WebSocket events **Vobiz → your server** - `start` - one handshake frame. IDs live inside the nested `start` object: `data.start.streamId`, `data.start.callId`, `data.start.accountId`, `data.start.tracks`, `data.start.mediaFormat`. Top-level `sequenceNumber` starts at 0 and increments per message. - `media` - base64 audio frame; top-level `streamId`, then `media.track`, `media.timestamp`, `media.chunk`, `media.payload`. Individual `media` events do not repeat the format. - `playedStream` - ack that audio queued before your `checkpoint` finished playing. Shape is just `event` + `name` (no `streamId`). Match on `name`. - `clearedAudio` - ack that `clearAudio` flushed the playback queue: `{ event, streamId, sequenceNumber }`. **Your server → Vobiz** (require `bidirectional="true"`; all carry the `streamId` from `start`) - `playAudio` - queue a chunk: `{ event, streamId, media: { contentType, sampleRate, payload } }`. - `checkpoint` - mark end of an utterance: `{ event, streamId, name }`; ack arrives as `playedStream`. - `clearAudio` - barge-in flush: `{ event, streamId }`; ack arrives as `clearedAudio`. - `stop` - end the stream from your side: `{ event, streamId }`. Vobiz then runs the next XML element, or hangs up with `HangupCauseCode=4010` ("End Of XML Instructions") if there is none. ## HTTP status callbacks (separate channel) If you set `statusCallbackUrl`, Vobiz POSTs form-encoded lifecycle events - distinct from WebSocket events. Every callback carries `Event`, `StreamID`, `CallUUID`, `From`, `To`, `Timestamp`, and an `Error` field (empty on healthy events): - `Event=StartStream` - WebSocket established (carries `ServiceURL`). - `Event=PlayedStream` - checkpoint reached (carries `Name`); only fires if playback completed. - `Event=ClearedAudio` - fires on every barge-in flush; a busy call produces many of these. - `Event=DegradedStream` (`Error=connection degraded`) - media path deteriorating while the stream is still up. - `Event=DroppedStream` (`Error=connection disconnected with the remote service`) - also what you get when your own application closes the socket. - `Event=StopStream` - stream ended. **Only reliably fires for server-initiated stops** (your `stop` command or a REST `DELETE`) - not for caller-hangup or mid-call kill. The authoritative "call is over" signal across all paths is the `Event=Hangup` POST to your call's `hangup_url`. Full payloads: `xml/stream#status-callback-events`. ## When to search docs - "Start mid-call over REST" → `audio-streams/start-audio-stream`; Stream fields → `audio-streams/stream-object` - "Stop one fork vs all forks" → `audio-streams/stop-audio-stream`, `audio-streams/stop-all-audio-streams` - "`` attributes / status callbacks / maxRetries / streamTimeout / extraHeaders" → `xml/stream` - "Which sample rate in which direction" → `xml/stream/audio-formats`, `concepts/streaming-websockets` - "Handshake, end-of-stream detection, reconnects" → `xml/stream/initiate` - "Full event protocol both directions" → `xml/stream/stream-events` - "Playing audio mid-stream" → `xml/stream/play-audio` - "Clearing queued TTS on interruption / barge-in" → `xml/stream/clear-audio` - "Checkpoint / playback acks" → `xml/stream/checkpoint-event` - "Ending the stream from your side" → `xml/stream/stop-event` - "Record and stream at the same time" → `xml/record/stream-with-record` - "Pipe audio to a model or framework" → `integrations/websockets`, `integrations/pipecat`, `integrations/gemini-live`, `integrations/deepgram`, `integrations/dograh`, `integrations/rapida-ai` - "Firewall rules for an IP-restricted WebSocket server" → https://www.vobiz.ai/docs/concepts/ip-whitelisting#websocket-streaming (inbound TCP 443 from the media fleet; the RTP rule does not cover it) ## Pitfalls - **No inbound `stop` event.** When the call ends, the WebSocket simply **closes** after the last `media` frame. Do not wait for `{ "event": "stop" }` from Vobiz - you will wait forever. Treat WS `close` as your end-of-stream signal and flush buffers there. - **`StopStream` callback is conditional.** It fires reliably only when *you* stop the stream (WebSocket `stop` or REST `DELETE`). It is *not* observed for caller-hangup or mid-call kills. Use the `hangup_url` `Event=Hangup` webhook as the authoritative end-of-call signal. - **IDs are nested.** Read `data.start.streamId` / `data.start.callId`, not `data.streamId`, on the `start` event. - **`playedStream` is conditional.** No ack arrives if a `clearAudio` or disconnect interrupted the queued audio. Always have a timeout fallback - never block conversation logic on a checkpoint ack. Use unique checkpoint names per utterance. - **Gate new playback on `clearedAudio`.** `playAudio` sent immediately after `clearAudio` can race the flush and be partially dropped. Wait for the ack (with a short timeout), then enqueue the replacement. - **`bidirectional` excludes `both`/`outbound`.** With `bidirectional="true"` you must use `audioTrack="inbound"` (or omit it). `audioTrack="both"` + `bidirectional="true"` either hangs up with "End Of XML Instructions" or sends `start` and then never sends media. To capture both legs, use two one-way streams. - **`keepCallAlive` needs `bidirectional="true"`.** Without `keepCallAlive`, a `` with no following elements ends the XML and hangs up instantly. Use it instead of a ``. - **Inbound and outbound formats are independent.** `start.mediaFormat` tells you how to *decode* `media`. It does not set the `playAudio` format - declare that per event, and make the bytes match the declared `contentType`/`sampleRate`. Mismatched rate, stereo, or a WAV header = slow, fast, garbled, or silent playback. - **Never configure 24 kHz inbound.** `contentType="audio/x-l16;rate=24000"` is accepted by the XML parser but no usable media arrives. For a 24 kHz TTS engine use 16 kHz in, 24 kHz out on `playAudio`. - **REST `bidirectional` is a string.** Send `"bidirectional": "true"`; a JSON boolean silently yields a one-way stream and your `playAudio` is discarded. - **Never detect end-of-call from `media.payload`.** All-`0xFF` (base64 all-`/`) bytes are just silence and appear mid-call. Use the WS `close` event. - **Chunk size matters for barge-in.** Send ~20-60 ms chunks so a `clearAudio` flush is responsive. Large chunks add latency that `clearAudio` cannot recover. - **`wss://` in production.** `ws://` is for local testing only. The URL must be publicly reachable; if it is IP-restricted, allow inbound TCP 443 from the media fleet per https://www.vobiz.ai/docs/concepts/ip-whitelisting#websocket-streaming. - **`stop` is terminal.** After you send `stop`, the WS closes immediately - any queued `playAudio`/`checkpoint`/`clearAudio` sends are dropped. Send a farewell + `checkpoint`, wait for `playedStream`, then `stop`. - **Reconnects.** `maxRetries` (XML, 0-10, default 0) controls how many times Vobiz retries a failed/dropped WebSocket. Each retry replays a fresh `start` with a **new `streamId`** and the same `callId` - key per-call state on `callId` and make your `start` handler idempotent. With the default `0`, a dropped socket is terminal. - **`extraHeaders` are not socket credentials.** `integrations/deepgram` reports they surface only on `statusCallbackUrl` payloads (`X-VH-`) and the `start` frame's `extra_headers` stays `"{}"`. Put a secret in the `wss://` URL path instead if you need to authenticate the socket. - **Malformed answer XML is not an HTTP error.** Vobiz accepts the `200`, then drops the call; the CDR shows `hangup_cause_name: "Invalid Answer XML"`. A bare `&` in a URL with two query parameters is enough - XML-escape interpolated values. - **`playAudio` bypasses the conference mixer.** It is injected into the leg the stream rides, so `deaf` cannot silence it and a stream can only address its own leg (`xml/stream/play-audio`). ## Complete bidirectional voice-agent recipe A minimal full-duplex loop: greet on `start`, transcribe inbound `media`, barge-in with `clearAudio`, respond with `playAudio` + `checkpoint`, end with `stop`. ```javascript Node.js bidirectional agent skeleton const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // Outbound playback format is independent of start.mediaFormat. // Pick what your TTS actually emits: L16 8000/16000/24000 or mu-law 8000. const OUT = { contentType: 'audio/x-l16', sampleRate: 8000 }; const FRAME_BYTES = OUT.sampleRate * 2 * 0.02; // 20 ms of 16-bit mono L16 wss.on('connection', (ws) => { let streamId = null; let mediaFormat = null; // { encoding, sampleRate } - how to DECODE inbound media let botSpeaking = false; let awaitingClear = false; let checkpointTimer = null; ws.on('message', async (raw) => { const data = JSON.parse(raw); switch (data.event) { case 'start': streamId = data.start.streamId; // IDs are NESTED mediaFormat = data.start.mediaFormat; // decode inbound media with this await speak(ws, await tts('Hi, how can I help?'), 'greeting'); break; case 'media': { const pcm = Buffer.from(data.media.payload, 'base64'); // format per mediaFormat // Barge-in: caller talks while bot is talking (debounce with a real VAD). if (botSpeaking && isSpeech(pcm)) { awaitingClear = true; ws.send(JSON.stringify({ event: 'clearAudio', streamId })); botSpeaking = false; } feedTranscriber(pcm, mediaFormat); // your STT; on final transcript -> respond() break; } case 'clearedAudio': // Playback queue flushed; only now is it safe to enqueue the new response. awaitingClear = false; break; case 'playedStream': // Utterance finished. Clear any fallback timer. if (checkpointTimer) { clearTimeout(checkpointTimer); checkpointTimer = null; } botSpeaking = false; if (data.name === 'farewell') { ws.send(JSON.stringify({ event: 'stop', streamId })); } break; } }); ws.on('close', () => { // Canonical end-of-stream. Flush recordings/transcripts here. teardown(streamId); }); async function respond(text, name) { await speak(ws, await tts(text), name); } async function speak(ws, pcmBuffer, name) { botSpeaking = true; for (let i = 0; i < pcmBuffer.length; i += FRAME_BYTES) { ws.send(JSON.stringify({ event: 'playAudio', streamId, // required on every command media: { contentType: OUT.contentType, sampleRate: OUT.sampleRate, // must match the real bytes payload: pcmBuffer.subarray(i, i + FRAME_BYTES).toString('base64'), }, })); } ws.send(JSON.stringify({ event: 'checkpoint', streamId, name })); // Fallback: never block forever on a (conditional) playedStream ack. checkpointTimer = setTimeout(() => { botSpeaking = false; }, 15000); } }); ``` Pair it with this answer handler: ```xml VobizXML answer_url response wss://your-server.com/ws ```