> Fetch clean Markdown by appending `.md` to any page URL under https://signalwire.com/docs or requesting it with the HTTP header `Accept: text/markdown`. The root index at https://signalwire.com/docs/llms.txt lists the available documentation indexes. # Stream call audio > Stream a live call to your own WebSocket server with SWML, Relay, or REST, then save or process the audio as it arrives, and play audio back into the call on a bidirectional stream. [make-and-receive-calls]: /docs/platform/voice/make-and-receive-calls [prepare-calls]: /docs/platform/voice/make-and-receive-calls#prepare-for-your-first-call [answer-swml]: /docs/platform/voice/make-and-receive-calls#answer-a-call-via-swml [place-relay]: /docs/platform/voice/make-and-receive-calls#place-a-call-via-websocket-relay [caller-id]: /docs/platform/voice/how-to-set-caller-id-or-cnam [trial-mode]: /docs/platform/trial-mode [webhooks]: /docs/platform/webhooks [server-sdks]: /docs/server-sdks [relay-guide]: /docs/server-sdks/guides/relay-client [swml-intro]: /docs/swml [call-commands]: /docs/apis/rest/calls/call-commands [stream-status-callback]: /docs/apis/rest/webhooks/stream-status-callback [swml-stream]: /docs/swml/reference/calling/stream [swml-stop-stream]: /docs/swml/reference/calling/stop-stream [swml-connect]: /docs/swml/reference/calling/connect [swml-answer]: /docs/swml/reference/calling/answer [sip-addresses]: /docs/platform/addresses#sip-addresses [swml-join-conference]: /docs/swml/reference/calling/join-conference Call streaming sends the audio of a live call to a WebSocket server you run, so your app can transcribe, analyze, record, or answer it in near real time. Start by streaming a call to your own phone into a small server that prints what arrives, then save or process the audio, and talk back to the caller on a bidirectional stream. ## Choose an approach Each approach starts and stops streams in its own way. All of them stream to the same kind of WebSocket server, so the server code on this page works with every approach. Follow the section for your app's approach: * [SWML][swml-intro]: answer calls to your number with a document your server serves. Add `stream` for a one-way stream, or `connect` the call to your server for a bidirectional stream. Optionally send stream events to a webhook. * [WebSocket (Relay)][relay-guide]: start and stop a stream from code that holds a connection to the call. Stream events arrive in your code, so no webhook is needed. * [REST](#stream-a-call-already-in-progress-via-rest): start and stop a one-way stream by call ID from any process. ## Prepare for streaming Start with working call setup from [Make and receive calls][make-and-receive-calls]. Have these values ready: * Your Space URL, such as `.signalwire.com`. * Your Project ID and an API token with the **Voice** permission. * A phone number in your Space, and a phone you can call it from and answer. * A machine that can run a small server, and a tunnel such as [ngrok](https://ngrok.com/) that gives it a public URL. > **Stream URLs must use wss\://** > > SignalWire connects to your server over a secure WebSocket only, and rejects a plain `ws://` URL. > During development, run the server locally and put a TLS tunnel in front of it, as > [Run a WebSocket server](#run-a-websocket-server) shows. > **Trial projects only call and receive calls from verified numbers** > > A [trial project][trial-mode] only places calls to and receives calls from numbers you've > purchased or verified, and can't call internationally. [Prepare for your first call][prepare-calls] > shows how to verify the phone you'll test with. Replace these values in the samples you run: | Value | Replace with | | ---------------------------- | --------------------------------------------------------------------------------------------------------------- | | `` | Your Space's subdomain in `.signalwire.com` | | `` | Your Project ID | | `` | Your API token with the Voice permission, used only in server code | | `` | The public hostname of your server's tunnel | | `` | Your server's stream URL, `wss:///stream` | | `` | A password you choose for the SWML your server serves | | `` | Your SignalWire phone number, or a [verified caller ID][caller-id], in E.164 format, when Relay places the call | | `` | The phone you'll answer, in E.164 format, when Relay places the call | | `` | The ID of a live call, when you start a stream over REST | ## How call streaming works When a stream starts, SignalWire opens a WebSocket connection to your server and sends it the call's audio as the call happens, in 20 ms frames. What happens to the audio next is up to your server. It can: * [Save it to a file](#save-call-audio-to-a-file), as a recording you control. * [Forward it to another service](#buffer-audio-for-processing), such as a speech-to-text or sentiment API, and act on what comes back. * [Send audio back into the call](#send-audio-back-on-a-bidirectional-stream), such as a recorded prompt or the output of a voice agent. Sending audio back needs a bidirectional stream. A one-way stream gives your server a copy of the call's audio while the call carries on as normal, and anything your server sends is ignored. A bidirectional stream connects the call to your server as if the server were the other party: the caller hears what your server sends, and the call stays connected to your server until your server closes the connection or the call ends. SignalWire sends the same messages whichever approach starts the stream, so one server works with all of them. A call can run several one-way streams at once, each with its own `control_id`. ## Run a WebSocket server Every stream in this guide connects to a server you run, at the `/stream` path. Start with one that prints what arrives, so you can see a stream working before you write any processing code. #### Run the stream server This server prints the call's ID and the stream's format when the stream starts, then a line for each second of audio it receives on each track. #### Python — FastAPI ```python # Install: python -m pip install fastapi "uvicorn[standard]" # Save as stream_server.py and run: python stream_server.py import json import uvicorn from fastapi import FastAPI, WebSocket app = FastAPI() FRAMES_PER_SECOND = 50 # each media message carries 20 ms of audio @app.websocket("/stream") async def stream(websocket: WebSocket): await websocket.accept() frames = {} async for message in websocket.iter_text(): event = json.loads(message) if event["event"] == "start": start = event["start"] media_format = start["mediaFormat"] print(f"Stream started for call {start['callSid']}: tracks {start['tracks']}, " f"{media_format['encoding']} at {media_format['sampleRate']} Hz") elif event["event"] == "media": track = event["media"]["track"] frames[track] = frames.get(track, 0) + 1 if frames[track] % FRAMES_PER_SECOND == 0: print(f"{track}: {frames[track] // FRAMES_PER_SECOND} s of audio") elif event["event"] == "stop": print("Stream stopped") uvicorn.run(app, host="0.0.0.0", port=8080) ``` #### JavaScript — ws ```javascript // Install: npm install ws // Save as stream-server.mjs and run: node stream-server.mjs import { WebSocketServer } from "ws"; const FRAMES_PER_SECOND = 50; // each media message carries 20 ms of audio const streams = new WebSocketServer({ port: 8080, path: "/stream" }); console.log("Listening on port 8080"); streams.on("connection", (socket) => { const frames = new Map(); socket.on("message", (data) => { const event = JSON.parse(data.toString()); if (event.event === "start") { const { callSid, tracks, mediaFormat } = event.start; console.log(`Stream started for call ${callSid}: tracks ${tracks}, ` + `${mediaFormat.encoding} at ${mediaFormat.sampleRate} Hz`); } else if (event.event === "media") { const track = event.media.track; const count = (frames.get(track) ?? 0) + 1; frames.set(track, count); if (count % FRAMES_PER_SECOND === 0) { console.log(`${track}: ${count / FRAMES_PER_SECOND} s of audio`); } } else if (event.event === "stop") { console.log("Stream stopped"); } }); }); ``` #### Give the server a public URL In a second terminal, put a tunnel in front of port `8080`: ```bash ngrok http 8080 ``` ngrok prints an `https://` **Forwarding** URL. Its hostname is your ``, and your `` is `wss:///stream`. Keep the server and the tunnel running while you test; a new tunnel usually means a new hostname. ### Run an echo server for bidirectional streams A bidirectional stream needs a server that sends audio back. This one returns each `media` payload unchanged, so the caller hears their own voice a moment later. It confirms that audio flows in both directions before you plug in a real speech pipeline. Run it in place of the stream server, on the same port and tunnel, when you reach a "Talk back" section. #### Python — FastAPI ```python # Install: python -m pip install fastapi "uvicorn[standard]" # Save as echo_server.py and run: python echo_server.py import json import uvicorn from fastapi import FastAPI, WebSocket app = FastAPI() @app.websocket("/stream") async def stream(websocket: WebSocket): await websocket.accept() stream_sid = None async for message in websocket.iter_text(): event = json.loads(message) if event["event"] == "start": stream_sid = event["start"]["streamSid"] print(f"Echoing stream {stream_sid}") elif event["event"] == "media": await websocket.send_text(json.dumps({ "event": "media", "streamSid": stream_sid, "media": {"payload": event["media"]["payload"]}, })) elif event["event"] == "stop": print("Stream stopped") uvicorn.run(app, host="0.0.0.0", port=8080) ``` #### JavaScript — ws ```javascript // Install: npm install ws // Save as echo-server.mjs and run: node echo-server.mjs import { WebSocketServer } from "ws"; const streams = new WebSocketServer({ port: 8080, path: "/stream" }); console.log("Listening on port 8080"); streams.on("connection", (socket) => { let streamSid; socket.on("message", (data) => { const event = JSON.parse(data.toString()); if (event.event === "start") { streamSid = event.start.streamSid; console.log(`Echoing stream ${streamSid}`); } else if (event.event === "media") { socket.send(JSON.stringify({ event: "media", streamSid, media: { payload: event.media.payload }, })); } else if (event.event === "stop") { console.log("Stream stopped"); } }); }); ``` ## Stream call audio via SWML SWML has two ways to stream a call, one for each type of stream: * To record, transcribe, or analyze a call while it carries on as normal, use [`stream`][swml-stream]. It starts a one-way stream and moves on to the next method at once, so the rest of the document runs while audio streams. * To put your own voice agent or speech pipeline on the call, use [`connect`][swml-connect] with `to` set to `stream:`. It bridges the call to your server for a bidirectional stream, and the document waits there until the stream ends. ### Stream a call via SWML When someone calls your number, SignalWire fetches the call's SWML from your server and runs it. Your server can serve that document and accept the stream on the same port, so the one tunnel from [Run a WebSocket server](#run-a-websocket-server) covers both. #### Serve the SWML from your server Stop the stream server and run this one in its place. It's the same stream server, plus a SWML document at its root URL that streams the call, keeps it open for twenty seconds while you talk, and says goodbye. The document is served only to requests that carry the username `signalwire` and the password you set. #### Python — SWML builder ```python # Install: python -m pip install signalwire-sdk==3.4.1 # Save as stream_app.py and run: python stream_app.py import json import uvicorn from fastapi import FastAPI, WebSocket from signalwire import SWMLBuilder, SWMLService service = SWMLService( name="stream-call", basic_auth=("signalwire", ""), schema_validation=False, ) swml = SWMLBuilder(service) service.add_verb("stream", {"url": ""}) swml.say("Your call is being streamed. Say a few words.").sleep(20000).say("Goodbye.") app = FastAPI() # SignalWire fetches the call's SWML from the server's root URL. app.include_router(service.as_router()) FRAMES_PER_SECOND = 50 # each media message carries 20 ms of audio # SignalWire streams the call's audio to /stream. @app.websocket("/stream") async def stream(websocket: WebSocket): await websocket.accept() frames = {} async for message in websocket.iter_text(): event = json.loads(message) if event["event"] == "start": start = event["start"] media_format = start["mediaFormat"] print(f"Stream started for call {start['callSid']}: tracks {start['tracks']}, " f"{media_format['encoding']} at {media_format['sampleRate']} Hz") elif event["event"] == "media": track = event["media"]["track"] frames[track] = frames.get(track, 0) + 1 if frames[track] % FRAMES_PER_SECOND == 0: print(f"{track}: {frames[track] // FRAMES_PER_SECOND} s of audio") elif event["event"] == "stop": print("Stream stopped") uvicorn.run(app, host="0.0.0.0", port=8080) ``` #### JavaScript — SWML builder ```javascript // Install: npm install @signalwire/sdk@2.0.5 @hono/node-server ws // Save as stream-app.mjs and run: node stream-app.mjs import { serve } from "@hono/node-server"; import { SWMLService } from "@signalwire/sdk"; import { WebSocketServer } from "ws"; const service = new SWMLService({ name: "stream-call", basicAuth: ["signalwire", ""], }); const swml = service.getBuilder(); swml.addVerb("stream", { url: "" }); swml.say("Your call is being streamed. Say a few words.").sleep(20000).say("Goodbye."); // SignalWire fetches the call's SWML from the server's root URL. const server = serve({ fetch: service.getApp().fetch, port: 8080 }); console.log("Listening on port 8080"); const FRAMES_PER_SECOND = 50; // each media message carries 20 ms of audio // SignalWire streams the call's audio to /stream. const streams = new WebSocketServer({ server, path: "/stream" }); streams.on("connection", (socket) => { const frames = new Map(); socket.on("message", (data) => { const event = JSON.parse(data.toString()); if (event.event === "start") { const { callSid, tracks, mediaFormat } = event.start; console.log(`Stream started for call ${callSid}: tracks ${tracks}, ` + `${mediaFormat.encoding} at ${mediaFormat.sampleRate} Hz`); } else if (event.event === "media") { const track = event.media.track; const count = (frames.get(track) ?? 0) + 1; frames.set(track, count); if (count % FRAMES_PER_SECOND === 0) { console.log(`${track}: ${count / FRAMES_PER_SECOND} s of audio`); } } else if (event.event === "stop") { console.log("Stream stopped"); } }); }); ``` The [Server SDKs][server-sdks] build the document with the SWML builder and serve it through `SWMLService`, which your app mounts next to its `/stream` route. Check that the document is reachable through the tunnel: ```bash curl -u "signalwire:" "https:///" ``` The response is the SWML document, starting with `{"version": "1.0.0"`. A `401` means the password doesn't match the one in your server. #### Point your phone number at the server Create a SWML Script that fetches the document from your server: 1. Open **My Resources**, select **+ Add**, then **Script**, then **SWML Script**. 2. Name it **Stream call** and leave **Used For** set to **Calling**. 3. Under **Handle Calls Using**, choose **External URL** and enter `https://signalwire:@/` in **Primary Script URL**. 4. Select **Create**. Then assign the script to your phone number: Open **Phone Numbers**, select the number, and select **Edit Settings**. Under **Inbound Call Settings**, select **Assign Resource**, choose the Resource, and save. To assign from the Resource instead, open its **Addresses & Phone Numbers** tab, select **+ Add**, then **Phone Number**, and pick a number you own. To do both in code, see [Answer a call via SWML][answer-swml]. #### Call your number and speak Call your SignalWire number from your phone and say a few words. The server prints a `started` line as the call connects, then a line for each second of audio: ```text Stream started for call 76ac3c36-56da-4a3e-a0d6-b5f8df6da9ad: tracks ['inbound'], audio/x-mulaw at 8000 Hz inbound: 1 s of audio inbound: 2 s of audio ``` After twenty seconds you hear "Goodbye.", the call ends, and the server prints `Stream stopped`. The stream carries only the `inbound` track, your voice, until you [choose other tracks](#stream-options-track-and-custom-parameters). If it doesn't work, see [Troubleshoot call streaming](#troubleshoot-call-streaming). The same document in YAML, to paste into a hosted SWML Script instead: ```yaml version: 1.0.0 sections: main: - stream: url: - play: url: say:Your call is being streamed. Say a few words. - sleep: 20000 - play: url: say:Goodbye. ``` ### Talk back on a bidirectional stream via SWML This document plays an announcement, connects the call to your server's `/stream` route, and hangs up once your server closes the connection. In your SWML server, replace the document with this one, and the `/stream` handler with the [echo server's](#run-an-echo-server-for-bidirectional-streams). Set `to` to your stream URL with a `stream:` prefix. The stream's settings, such as `codec`, `realtime`, and `name`, sit alongside `to`. **`Python — SWML builder`** ```python title="Python — SWML builder" swml = SWMLBuilder(service) ( swml.say("You are connected to the echo server. Speak, and you will hear yourself.") .connect(to="stream:", codec="PCMU", realtime=True, name="echo") .hangup() ) ``` **`JavaScript — SWML builder`** ```javascript title="JavaScript — SWML builder" const swml = service.getBuilder(); swml .say("You are connected to the echo server. Speak, and you will hear yourself.") .connect({ to: "stream:", codec: "PCMU", realtime: true, name: "echo" }) .hangup(); ``` **`YAML`** ```yaml title="YAML" version: 1.0.0 sections: main: - play: url: say:You are connected to the echo server. Speak, and you will hear yourself. - connect: to: stream: codec: PCMU realtime: true name: echo - hangup: {} ``` **`JSON`** ```json title="JSON" { "version": "1.0.0", "sections": { "main": [ { "play": { "url": "say:You are connected to the echo server. Speak, and you will hear yourself." } }, { "connect": { "to": "stream:", "codec": "PCMU", "realtime": true, "name": "echo" } }, { "hangup": {} } ] } } ``` Call your number and speak. You hear the announcement, then your own voice echoed back a moment after you say each word. Hang up to end the call; the server prints `Stream stopped`. When your server closes the connection, `connect` finishes and the document continues with the next method, here `hangup`. Put a `play` or any other method there to keep the call going instead. To stream a whole conference in both directions, give [`join_conference`][swml-join-conference] a `stream` object; its reference lists the settings it accepts. ### Stop a stream via SWML A one-way stream runs until the call ends unless you stop it. Give `stream` a `control_id`, and run [`stop_stream`][swml-stop-stream] with the same value when your server has what it needs. In your SWML server, replace the document lines with these to stop the stream after twenty seconds and keep the call open: **`Python — SWML builder`** ```python title="Python — SWML builder" {1,3} service.add_verb("stream", {"url": "", "control_id": "my-stream"}) swml.say("Your call is being streamed. Say a few words.").sleep(20000) service.add_verb("stop_stream", {"control_id": "my-stream"}) swml.say("The stream has stopped. Goodbye.") ``` **`JavaScript — SWML builder`** ```javascript title="JavaScript — SWML builder" {1,3} swml.addVerb("stream", { url: "", control_id: "my-stream" }); swml.say("Your call is being streamed. Say a few words.").sleep(20000); swml.addVerb("stop_stream", { control_id: "my-stream" }); swml.say("The stream has stopped. Goodbye."); ``` **`YAML`** ```yaml title="YAML" {6,10-11} version: 1.0.0 sections: main: - stream: url: control_id: my-stream - play: url: say:Your call is being streamed. Say a few words. - sleep: 20000 - stop_stream: control_id: my-stream - play: url: say:The stream has stopped. Goodbye. ``` **`JSON`** ```json title="JSON" {8,13} { "version": "1.0.0", "sections": { "main": [ { "stream": { "url": "", "control_id": "my-stream" } }, { "play": { "url": "say:Your call is being streamed. Say a few words." } }, { "sleep": 20000 }, { "stop_stream": { "control_id": "my-stream" } }, { "play": { "url": "say:The stream has stopped. Goodbye." } } ] } } ``` The server prints `Stream stopped` before you hear "The stream has stopped." Omit `control_id` in both places to stop the most recently started stream. A bidirectional stream has no `stop_stream`; your server ends it by closing the connection. ### Track a stream via SWML Add `status_url` to `stream` to receive a JSON webhook when the stream starts and another when it finishes. On a `stream:` `connect`, `status_url` must be an `https://` URL; it is notified only if the stream connects. See the [webhooks guide][webhooks] for endpoint setup and testing. | Webhook field | Value | | ------------------- | --------------------------------------------------- | | `event_type` | `calling.call.stream` | | `params.call_id` | The call ID | | `params.control_id` | The stream's `control_id` | | `params.state` | `streaming` when the stream starts, then `finished` | | `params.url` | The stream's WebSocket URL | ```json { "event_type": "calling.call.stream", "params": { "call_id": "2e1e66e5-5d07-413d-9668-55542992eec0", "control_id": "my-stream", "state": "streaming", "url": "wss://example.com/audio-stream" } } ``` On a one-way stream, `streaming` means SignalWire started the stream, not that your server accepted the connection, so confirm the connection from your server's output. The [stream status callback reference][stream-status-callback] lists every field. ## Stream call audio via WebSocket (Relay) Start a one-way stream on an answered call with `call.stream()`. It returns a stream action that keeps the `control_id` for you; call its `stop()` method to end the stream. For a bidirectional stream, pass a device of type `stream` to `call.connect()`. It must be the only device in the list. ### Stream a call via WebSocket (Relay) Dial your phone, start a stream, and keep the call open for twenty seconds while you talk. Call setup follows [Place a call via WebSocket (Relay)][place-relay]. #### Run the call program #### Python — Relay client ```python # Install: python -m pip install signalwire-sdk==3.4.1 # Save as stream_call_relay.py and run: python stream_call_relay.py import asyncio from signalwire.relay import RelayClient client = RelayClient( project="", token="", host=".signalwire.com", contexts=["default"], ) async def main(): async with client: call = await client.dial( devices=[[{ "type": "phone", "params": { "from_number": "", "to_number": "", "timeout": 30, }, }]], ) await call.stream(url="") await call.play([{ "type": "tts", "params": {"text": "Your call is being streamed. Say a few words."}, }]) await asyncio.sleep(20) async def hang_up_after_playback(_event): if call.state != "ended": await call.hangup() await call.play( [{"type": "tts", "params": {"text": "Goodbye."}}], on_completed=hang_up_after_playback, ) await call.wait_for_ended() asyncio.run(main()) ``` #### TypeScript — Relay client ```typescript // Install: npm install @signalwire/sdk@2.0.5 // This sample also runs as JavaScript: save as stream-call-relay.mjs, // then run: node stream-call-relay.mjs import { RelayClient } from "@signalwire/sdk"; const client = new RelayClient({ project: "", token: "", host: ".signalwire.com", contexts: ["default"], }); await client.connect(); try { const call = await client.dial([[{ type: "phone", params: { from_number: "", to_number: "", timeout: 30, }, }]]); await call.stream(""); await call.play([ { type: "tts", text: "Your call is being streamed. Say a few words." }, ]); await new Promise((resolve) => setTimeout(resolve, 20_000)); await call.play([{ type: "tts", text: "Goodbye." }], { onCompleted: async () => { if (call.state !== "ended") await call.hangup(); }, }); await call.waitForEnded(); } finally { await client.disconnect(); } ``` #### Answer and speak Answer your phone and say a few words. The stream server prints a `started` line, then a line for each second of `inbound` audio. After twenty seconds you hear "Goodbye.", the call ends, and the server prints `Stream stopped`. If it doesn't work, see [Troubleshoot call streaming](#troubleshoot-call-streaming). ### Talk back on a bidirectional stream via WebSocket (Relay) Run the [echo server](#run-an-echo-server-for-bidirectional-streams) instead of the stream server. This program dials, plays an announcement, then connects the call to your server through a `stream` device. `connect()` returns as soon as SignalWire accepts the request, so the program follows the bridge through `calling.call.connect` events and hangs up when the bridge ends. #### Python — Relay client ```python # Install: python -m pip install signalwire-sdk==3.4.1 # Save as echo_call_relay.py and run: python echo_call_relay.py import asyncio from signalwire.relay import ConnectEvent, RelayClient client = RelayClient( project="", token="", host=".signalwire.com", contexts=["default"], ) async def main(): async with client: call = await client.dial( devices=[[{ "type": "phone", "params": { "from_number": "", "to_number": "", "timeout": 30, }, }]], ) async def on_connect(event: ConnectEvent): print(f"Stream bridge: {event.connect_state}") # The bridge ends when your server closes the connection. if event.connect_state in ("disconnected", "failed") and call.state != "ended": await call.hangup() call.on("calling.call.connect", on_connect) announcement = await call.play([{ "type": "tts", "params": {"text": "You are connected to the echo server. Speak, and you will hear yourself."}, }]) await announcement.wait() await call.connect( devices=[[{ "type": "stream", "params": { "url": "", "codec": "PCMU", "realtime": True, "name": "echo", }, }]], ) await call.wait_for_ended() asyncio.run(main()) ``` #### TypeScript — Relay client ```typescript // Install: npm install @signalwire/sdk@2.0.5 // This sample also runs as JavaScript: save as echo-call-relay.mjs, // then run: node echo-call-relay.mjs import { RelayClient } from "@signalwire/sdk"; const client = new RelayClient({ project: "", token: "", host: ".signalwire.com", contexts: ["default"], }); await client.connect(); try { const call = await client.dial([[{ type: "phone", params: { from_number: "", to_number: "", timeout: 30, }, }]]); call.on("calling.call.connect", async (event) => { const state = event.params.connect_state; console.log(`Stream bridge: ${state}`); // The bridge ends when your server closes the connection. if ((state === "disconnected" || state === "failed") && call.state !== "ended") { await call.hangup(); } }); const announcement = await call.play([ { type: "tts", text: "You are connected to the echo server. Speak, and you will hear yourself." }, ]); await announcement.wait(); await call.connect([[{ type: "stream", params: { url: "", codec: "PCMU", realtime: true, name: "echo", }, }]]); await call.waitForEnded(); } finally { await client.disconnect(); } ``` Answer and speak to hear your voice echoed back. The program prints `connected` while audio flows, then `disconnected` when you hang up, or `failed` if SignalWire couldn't reach your URL. The call stays bridged to your server until your server closes the connection or the call ends. This program hangs up when the bridge ends; to keep the call going instead, play audio or connect it elsewhere. ### Stop a stream via WebSocket (Relay) Keep the action that `call.stream()` returns, and call its `stop()` method when your server has what it needs. In the [Stream a call via WebSocket (Relay)](#stream-a-call-via-websocket-relay) program, replace the stream and sleep lines with these: **`Python — Relay client`** ```python title="Python — Relay client" {1,7} stream = await call.stream(url="") await call.play([{ "type": "tts", "params": {"text": "Your call is being streamed. Say a few words."}, }]) await asyncio.sleep(20) await stream.stop() ``` **`TypeScript — Relay client`** ```typescript title="TypeScript — Relay client" {1,6} const stream = await call.stream(""); await call.play([ { type: "tts", text: "Your call is being streamed. Say a few words." }, ]); await new Promise((resolve) => setTimeout(resolve, 20_000)); await stream.stop(); ``` To stop a stream from a process that doesn't hold the call, use the REST [`calling.stream.stop`](#stream-a-call-already-in-progress-via-rest) command with the stream's `control_id`, which the action exposes as `control_id` in Python and `controlId` in TypeScript. ### Track a stream via WebSocket (Relay) Stream events arrive in your code over the same connection: | Event | What to read | Typical action | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | | `calling.call.stream` | `state` is `streaming` when the stream starts, then `finished`; `control_id` identifies the stream | Log it, or clean up when the stream finishes | | `calling.call.connect` | `connect_state` moves from `connecting` to `connected` while audio flows, then `disconnected`, or `failed` if SignalWire couldn't reach your URL | Retry or hang up on `failed` | To log one-way stream events, register a handler before you call `call.stream()`: **`Python — Relay client`** ```python title="Python — Relay client" from signalwire.relay import StreamEvent def on_stream(event: StreamEvent): print(f"Stream {event.control_id} is {event.state}") call.on("calling.call.stream", on_stream) ``` **`TypeScript — Relay client`** ```typescript title="TypeScript — Relay client" call.on("calling.call.stream", (event) => { console.log(`Stream ${event.params.control_id} is ${event.params.state}`); }); ``` As with the SWML webhook, `streaming` means SignalWire started the stream, not that your server accepted the connection. ## Stream a call already in progress via REST When a process that doesn't hold the call needs to stream it, send the `calling.stream` command with the answered call's ID, a `control_id`, and the stream's `url`. The call ID is the `id` from the `dial` response, or the `call_id` delivered to your SWML endpoint or status webhook. To try it, make a call from the [first run](#stream-a-call-via-swml), and while it's up, run this program with the call ID your stream server printed. The [Server SDKs][server-sdks] wrap both commands; this program streams the call for ten seconds, then sends `calling.stream.stop` with the same `control_id`: #### Python — REST client ```python # Install: python -m pip install signalwire-sdk==3.4.1 # Save as stream_rest.py and run: python stream_rest.py import time from signalwire.rest import RestClient client = RestClient( project="", token="", host=".signalwire.com", ) client.calling.stream( "", url="", control_id="rest-stream", ) time.sleep(10) client.calling.stream_stop("", control_id="rest-stream") print("Stream stopped") ``` #### TypeScript — REST client ```typescript // Install: npm install @signalwire/sdk@2.0.5 // This sample also runs as JavaScript: save as stream-rest.mjs, // then run: node stream-rest.mjs import { RestClient } from "@signalwire/sdk"; const client = new RestClient({ project: "", token: "", host: ".signalwire.com", }); await client.calling.stream("", { url: "", control_id: "rest-stream", }); await new Promise((resolve) => setTimeout(resolve, 10_000)); await client.calling.streamStop("", { control_id: "rest-stream" }); console.log("Stream stopped"); ``` The server prints a second `started` line for the REST stream, and `Stream stopped` ten seconds later while the call carries on. The other stream settings, such as `track`, `codec`, and `status_url`, sit alongside `url`. In Python, pass `status_url` through `extras`. The command doesn't echo the `control_id`, so keep the value you sent. To call the REST API directly, send these requests to [Call commands][call-commands]: ### Request POST https\://%7BYour\_Space\_Name%7D.signalwire.com/api/calling/calls **`calling.stream`** ```curl calling.stream curl -X POST https://{your_space_name}.signalwire.com/api/calling/calls \ -H "Content-Type: application/json" \ -u ":" \ -d '{ "command": "calling.stream", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "params": { "control_id": "stream-control-1", "url": "wss://example.com/stream", "track": "inbound_track" } }' ``` **`calling.stream`** ```python calling.stream import requests url = "https://{your_space_name}.signalwire.com/api/calling/calls" payload = { "command": "calling.stream", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "params": { "control_id": "stream-control-1", "url": "wss://example.com/stream", "track": "inbound_track" } } headers = { "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers, auth=("", "")) print(response.json()) ``` **`calling.stream`** ```javascript calling.stream const url = 'https://{your_space_name}.signalwire.com/api/calling/calls'; const credentials = btoa(":"); const options = { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/json' }, body: '{"command":"calling.stream","id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","params":{"control_id":"stream-control-1","url":"wss://example.com/stream","track":"inbound_track"}}' }; try { const response = await fetch(url, options); const data = await response.json(); console.log(data); } catch (error) { console.error(error); } ``` **`calling.stream`** ```go calling.stream package main import ( "fmt" "strings" "net/http" "io" ) func main() { url := "https://{your_space_name}.signalwire.com/api/calling/calls" payload := strings.NewReader("{\n \"command\": \"calling.stream\",\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n \"params\": {\n \"control_id\": \"stream-control-1\",\n \"url\": \"wss://example.com/stream\",\n \"track\": \"inbound_track\"\n }\n}") req, _ := http.NewRequest("POST", url, payload) req.SetBasicAuth("", "") req.Header.Add("Content-Type", "application/json") res, _ := http.DefaultClient.Do(req) defer res.Body.Close() body, _ := io.ReadAll(res.Body) fmt.Println(res) fmt.Println(string(body)) } ``` **`calling.stream`** ```ruby calling.stream require 'uri' require 'net/http' url = URI("https://{your_space_name}.signalwire.com/api/calling/calls") http = Net::HTTP.new(url.host, url.port) http.use_ssl = true request = Net::HTTP::Post.new(url) request.basic_auth("", "") request["Content-Type"] = 'application/json' request.body = "{\n \"command\": \"calling.stream\",\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n \"params\": {\n \"control_id\": \"stream-control-1\",\n \"url\": \"wss://example.com/stream\",\n \"track\": \"inbound_track\"\n }\n}" response = http.request(request) puts response.read_body ``` **`calling.stream`** ```java calling.stream import com.mashape.unirest.http.HttpResponse; import com.mashape.unirest.http.Unirest; HttpResponse response = Unirest.post("https://{your_space_name}.signalwire.com/api/calling/calls") .basicAuth("", "") .header("Content-Type", "application/json") .body("{\n \"command\": \"calling.stream\",\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n \"params\": {\n \"control_id\": \"stream-control-1\",\n \"url\": \"wss://example.com/stream\",\n \"track\": \"inbound_track\"\n }\n}") .asString(); ``` **`calling.stream`** ```php calling.stream request('POST', 'https://{your_space_name}.signalwire.com/api/calling/calls', [ 'body' => '{ "command": "calling.stream", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "params": { "control_id": "stream-control-1", "url": "wss://example.com/stream", "track": "inbound_track" } }', 'headers' => [ 'Content-Type' => 'application/json', ], 'auth' => ['', ''], ]); echo $response->getBody(); ``` **`calling.stream`** ```csharp calling.stream using RestSharp; using RestSharp.Authenticators; var client = new RestClient("https://{your_space_name}.signalwire.com/api/calling/calls"); client.Authenticator = new HttpBasicAuthenticator("", ""); var request = new RestRequest(Method.POST); request.AddHeader("Content-Type", "application/json"); request.AddParameter("application/json", "{\n \"command\": \"calling.stream\",\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n \"params\": {\n \"control_id\": \"stream-control-1\",\n \"url\": \"wss://example.com/stream\",\n \"track\": \"inbound_track\"\n }\n}", ParameterType.RequestBody); IRestResponse response = client.Execute(request); ``` **`calling.stream`** ```swift calling.stream import Foundation let credentials = Data(":".utf8).base64EncodedString() let headers = [ "Authorization": "Basic \(credentials)", "Content-Type": "application/json" ] let parameters = [ "command": "calling.stream", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "params": [ "control_id": "stream-control-1", "url": "wss://example.com/stream", "track": "inbound_track" ] ] as [String : Any] let postData = JSONSerialization.data(withJSONObject: parameters, options: []) let request = NSMutableURLRequest(url: NSURL(string: "https://{your_space_name}.signalwire.com/api/calling/calls")! as URL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10.0) request.httpMethod = "POST" request.allHTTPHeaderFields = headers request.httpBody = postData as Data let session = URLSession.shared let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in if (error != nil) { print(error as Any) } else { let httpResponse = response as? HTTPURLResponse print(httpResponse) } }) dataTask.resume() ``` ### Request POST https\://%7BYour\_Space\_Name%7D.signalwire.com/api/calling/calls **`calling.stream.stop`** ```curl calling.stream.stop curl -X POST https://{your_space_name}.signalwire.com/api/calling/calls \ -H "Content-Type: application/json" \ -u ":" \ -d '{ "command": "calling.stream.stop", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "params": { "control_id": "stream-control-1" } }' ``` **`calling.stream.stop`** ```python calling.stream.stop import requests url = "https://{your_space_name}.signalwire.com/api/calling/calls" payload = { "command": "calling.stream.stop", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "params": { "control_id": "stream-control-1" } } headers = { "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers, auth=("", "")) print(response.json()) ``` **`calling.stream.stop`** ```javascript calling.stream.stop const url = 'https://{your_space_name}.signalwire.com/api/calling/calls'; const credentials = btoa(":"); const options = { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/json' }, body: '{"command":"calling.stream.stop","id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","params":{"control_id":"stream-control-1"}}' }; try { const response = await fetch(url, options); const data = await response.json(); console.log(data); } catch (error) { console.error(error); } ``` **`calling.stream.stop`** ```go calling.stream.stop package main import ( "fmt" "strings" "net/http" "io" ) func main() { url := "https://{your_space_name}.signalwire.com/api/calling/calls" payload := strings.NewReader("{\n \"command\": \"calling.stream.stop\",\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n \"params\": {\n \"control_id\": \"stream-control-1\"\n }\n}") req, _ := http.NewRequest("POST", url, payload) req.SetBasicAuth("", "") req.Header.Add("Content-Type", "application/json") res, _ := http.DefaultClient.Do(req) defer res.Body.Close() body, _ := io.ReadAll(res.Body) fmt.Println(res) fmt.Println(string(body)) } ``` **`calling.stream.stop`** ```ruby calling.stream.stop require 'uri' require 'net/http' url = URI("https://{your_space_name}.signalwire.com/api/calling/calls") http = Net::HTTP.new(url.host, url.port) http.use_ssl = true request = Net::HTTP::Post.new(url) request.basic_auth("", "") request["Content-Type"] = 'application/json' request.body = "{\n \"command\": \"calling.stream.stop\",\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n \"params\": {\n \"control_id\": \"stream-control-1\"\n }\n}" response = http.request(request) puts response.read_body ``` **`calling.stream.stop`** ```java calling.stream.stop import com.mashape.unirest.http.HttpResponse; import com.mashape.unirest.http.Unirest; HttpResponse response = Unirest.post("https://{your_space_name}.signalwire.com/api/calling/calls") .basicAuth("", "") .header("Content-Type", "application/json") .body("{\n \"command\": \"calling.stream.stop\",\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n \"params\": {\n \"control_id\": \"stream-control-1\"\n }\n}") .asString(); ``` **`calling.stream.stop`** ```php calling.stream.stop request('POST', 'https://{your_space_name}.signalwire.com/api/calling/calls', [ 'body' => '{ "command": "calling.stream.stop", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "params": { "control_id": "stream-control-1" } }', 'headers' => [ 'Content-Type' => 'application/json', ], 'auth' => ['', ''], ]); echo $response->getBody(); ``` **`calling.stream.stop`** ```csharp calling.stream.stop using RestSharp; using RestSharp.Authenticators; var client = new RestClient("https://{your_space_name}.signalwire.com/api/calling/calls"); client.Authenticator = new HttpBasicAuthenticator("", ""); var request = new RestRequest(Method.POST); request.AddHeader("Content-Type", "application/json"); request.AddParameter("application/json", "{\n \"command\": \"calling.stream.stop\",\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n \"params\": {\n \"control_id\": \"stream-control-1\"\n }\n}", ParameterType.RequestBody); IRestResponse response = client.Execute(request); ``` **`calling.stream.stop`** ```swift calling.stream.stop import Foundation let credentials = Data(":".utf8).base64EncodedString() let headers = [ "Authorization": "Basic \(credentials)", "Content-Type": "application/json" ] let parameters = [ "command": "calling.stream.stop", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "params": ["control_id": "stream-control-1"] ] as [String : Any] let postData = JSONSerialization.data(withJSONObject: parameters, options: []) let request = NSMutableURLRequest(url: NSURL(string: "https://{your_space_name}.signalwire.com/api/calling/calls")! as URL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10.0) request.httpMethod = "POST" request.allHTTPHeaderFields = headers request.httpBody = postData as Data let session = URLSession.shared let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in if (error != nil) { print(error as Any) } else { let httpResponse = response as? HTTPURLResponse print(httpResponse) } }) dataTask.resume() ``` `calling.stream.stop` stops one-way streams only. A bidirectional stream ends when your server closes the WebSocket. ## Handle stream audio on your server The stream server prints what arrives; a real app decodes it. This section covers the messages in full, the stream settings that shape the audio, and two servers you can swap in for the stream server: one saves the audio and one processes it as it arrives. Both work with the calls above unchanged. If your server also serves your SWML, keep its SWML lines and replace only the `/stream` handler. ### Messages your server receives SignalWire sends each message as JSON text. Every message has an `event` field that names its type, so your server parses each message and branches on `event`. Every message after `connected` also carries a `sequenceNumber`, a string counter that starts at `"1"` and increments with each message on the connection. | `event` | When it arrives | Fields you use | | ----------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `connected` | Once, when the WebSocket opens | None; wait for `start` | | `start` | Once, before any audio | `start.streamSid`, `start.callSid`, `start.tracks`, `start.mediaFormat`, `start.customParameters` | | `media` | For each frame of audio on each track | `media.track`, `media.chunk`, `media.timestamp`, `media.payload` | | `dtmf` | When a key is pressed on the call | `dtmf.digit`, one of `0` to `9`, `*`, `#`, or `A` to `D`; `dtmf.duration` | | `mark` | Bidirectional streams only, in reply to a `mark` your server sent | `mark.name` | | `stop` | Once, when the stream stops or the call ends | None; finish your per-stream work | Set up your per-stream state on `start`: ```json { "event": "start", "sequenceNumber": "1", "start": { "streamSid": "7d56cc11-536d-4a45-b4fb-ed3d55be843b", "callSid": "76ac3c36-56da-4a3e-a0d6-b5f8df6da9ad", "tracks": ["inbound", "outbound"], "customParameters": {"session_id": "abc123"}, "mediaFormat": {"encoding": "audio/x-mulaw", "sampleRate": 8000, "channels": 2} } } ``` * `streamSid` identifies the stream. Each stream gets its own connection, so a server handling many calls tells them apart by it. Send it back on every message to a bidirectional stream. * `callSid` is the ID of the call being streamed. * `tracks` lists the tracks that will arrive: `inbound`, `outbound`, or both. * `mediaFormat` describes the audio. `encoding` names the codec and `sampleRate` is in hertz. `channels` is the number of tracks; each `media` message still carries a single track. * `customParameters` holds the custom parameters you set when you started the stream, if any. Each `media` message carries one frame of one track: ```json { "event": "media", "sequenceNumber": "42", "media": { "track": "inbound", "chunk": "41", "timestamp": "820", "payload": "" } } ``` * `track` is `inbound`, what the person on the call says, or `outbound`, what they hear. * `chunk` counts frames on that track, starting at `"1"`. * `timestamp` is in milliseconds and advances one frame per message. Both tracks share it, so use it rather than arrival order to align or mix them. * `payload` is the base64-encoded audio. ### Choose a stream codec for the call The stream has its own codec, separate from the codec the call uses. SignalWire converts the call's audio to the stream's `codec` before sending it to your server, and on a bidirectional stream converts the audio your server sends back into the call's codec. When you don't set `codec`, every stream uses `PCMU`, 8 kHz G.711 µ-law, whatever the call type. A stream can't carry more detail than the call does. Streaming a call that uses an 8 kHz codec at 16 kHz only resamples the same audio. Choose the stream codec from the call's own codec: | The call's codec | Audio the call carries | Stream `codec` to request | | ------------------------- | ---------------------- | --------------------------------------------------------------- | | `PCMU`, `PCMA`, or `G729` | 8 kHz narrowband | `PCMU`, the default | | `G722` or `AMR-WB` | 16 kHz wideband | `L16@16000h` | | `OPUS` | Up to 48 kHz | `L16@16000h`, or `L16@24000h` when your speech model accepts it | Where the call's codec comes from depends on the call type: * Calls to and from phone numbers use a codec SignalWire sets, and the `codecs` settings in SWML have no effect on them. Stream them with the default `PCMU`. * SIP calls use a codec from the list your SIP endpoint or [SIP Address][sip-addresses] allows. In SWML, `codecs` on [`answer`][swml-answer] or [`connect`][swml-connect] sets the codecs offered, for example `G722` when you want wideband audio to stream. * Browser SDK calls use WebRTC, and browsers support `OPUS`, so these calls can carry wideband audio. Request `L16` when your speech model expects linear PCM, at the sample rate the model wants, even on an 8 kHz call. Other codecs and packet-time modifiers are listed in the [`connect` reference][swml-connect]. SignalWire doesn't start a stream whose codec it doesn't accept. | Stream `codec` | `mediaFormat.encoding` | `mediaFormat.sampleRate` | | ---------------- | ---------------------- | ------------------------ | | `PCMU` (default) | `audio/x-mulaw` | 8000 | | `L16@16000h` | `audio/x-L16` | 16000 | | `L16@24000h` | `audio/x-L16` | 24000 | A decoded `payload` is raw audio with no file header. Each frame is 20 ms by default: 160 bytes of `PCMU`, or 640 bytes of `L16` at 16 kHz. `L16` audio is 16-bit signed samples in little-endian byte order. ### Stream options: track and custom parameters `track` selects which side of the conversation a one-way stream sends. `inbound_track`, the default, is what the person on the call says; `outbound_track` is what they hear; `both_tracks` sends both, with each `media` message labeled by track. A bidirectional stream sends only the `inbound` track. Two more settings connect a WebSocket to the rest of your application: * `custom_parameters` is an object you set when you start the stream. Its keys arrive as `start.customParameters`, so pass your own session or customer IDs here rather than in the URL. * `authorization_bearer_token` is sent as `Authorization: Bearer ` on the WebSocket handshake. Reject connections that don't carry the token you expect. ### Save call audio to a file This server writes each track to its own file as audio arrives, so memory use stays flat on a long call. It closes the files when the stream stops or the connection drops. Run it in place of the stream server, then make the call from your first run again. Add `track: both_tracks` to the stream to get a second file with what the caller hears. #### Python — FastAPI ```python # Install: python -m pip install fastapi "uvicorn[standard]" # Save as save_audio.py and run: python save_audio.py import base64 import json import uvicorn from fastapi import FastAPI, WebSocket app = FastAPI() @app.websocket("/stream") async def stream(websocket: WebSocket): await websocket.accept() files = {} try: async for message in websocket.iter_text(): event = json.loads(message) if event["event"] == "start": stream_sid = event["start"]["streamSid"] for track in event["start"]["tracks"]: files[track] = open(f"{stream_sid}-{track}.ulaw", "wb") print(f"Recording stream {stream_sid}") elif event["event"] == "media": audio = base64.b64decode(event["media"]["payload"]) files[event["media"]["track"]].write(audio) elif event["event"] == "stop": break finally: # Runs when the stream stops or the connection drops. for file in files.values(): file.close() print(f"Saved {file.name}") uvicorn.run(app, host="0.0.0.0", port=8080) ``` #### JavaScript — ws ```javascript // Install: npm install ws // Save as save-audio.mjs and run: node save-audio.mjs import { createWriteStream } from "node:fs"; import { WebSocketServer } from "ws"; const server = new WebSocketServer({ port: 8080, path: "/stream" }); console.log("Listening on port 8080"); server.on("connection", (socket) => { const files = new Map(); socket.on("message", (data) => { const event = JSON.parse(data.toString()); if (event.event === "start") { const { streamSid, tracks } = event.start; for (const track of tracks) { files.set(track, createWriteStream(`${streamSid}-${track}.ulaw`)); } console.log(`Recording stream ${streamSid}`); } else if (event.event === "media") { files.get(event.media.track).write(Buffer.from(event.media.payload, "base64")); } }); // Runs when the stream stops or the connection drops. socket.on("close", () => { for (const file of files.values()) { file.end(); console.log(`Saved ${file.path}`); } }); }); ``` When the call ends, the server prints `Saved` with each file's name. The files hold raw `PCMU` audio with no header. Convert one to WAV with [ffmpeg](https://ffmpeg.org/) to play it: ```bash ffmpeg -f mulaw -ar 8000 -ac 1 -i -inbound.ulaw inbound.wav ``` For an `L16` stream, use `-f s16le` and the stream's sample rate, such as `-ar 16000`. Audio from the moment before your server accepts the connection isn't streamed, so a file can start a fraction of a second into the call. ### Buffer audio for processing Speech-to-text services, voice agents, and analytics usually want audio in fixed-size pieces rather than one `media` message at a time. Buffer each track by bytes, and hand a window to your processing whenever the buffer holds enough audio. With `PCMU`, one byte is one sample, so a one-second window is `sampleRate` bytes. This server cuts one-second windows and measures each one's loudness. Replace `analyze` with your processing, such as a request to a speech-to-text API. #### Python — FastAPI ```python # Install: python -m pip install fastapi "uvicorn[standard]" # Save as process_audio.py and run: python process_audio.py import base64 import json import math import uvicorn from fastapi import FastAPI, WebSocket app = FastAPI() def mulaw_to_linear(value): # Decode one G.711 µ-law byte to a 16-bit sample. value = ~value & 0xFF exponent = (value >> 4) & 0x07 sample = ((((value & 0x0F) << 3) + 0x84) << exponent) - 0x84 return -sample if value & 0x80 else sample def analyze(track, window): samples = [mulaw_to_linear(value) for value in window] rms = math.sqrt(sum(sample * sample for sample in samples) / len(samples)) level = 20 * math.log10(max(rms, 1) / 32768) print(f"{track}: {level:.0f} dBFS") @app.websocket("/stream") async def stream(websocket: WebSocket): await websocket.accept() buffers = {} window_bytes = 8000 async for message in websocket.iter_text(): event = json.loads(message) if event["event"] == "start": window_bytes = event["start"]["mediaFormat"]["sampleRate"] buffers = {track: bytearray() for track in event["start"]["tracks"]} elif event["event"] == "media": track = event["media"]["track"] buffer = buffers[track] buffer += base64.b64decode(event["media"]["payload"]) if len(buffer) >= window_bytes: analyze(track, bytes(buffer[:window_bytes])) del buffer[:window_bytes] uvicorn.run(app, host="0.0.0.0", port=8080) ``` #### JavaScript — ws ```javascript // Install: npm install ws // Save as process-audio.mjs and run: node process-audio.mjs import { WebSocketServer } from "ws"; function mulawToLinear(byte) { // Decode one G.711 µ-law byte to a 16-bit sample. const value = ~byte & 0xff; const exponent = (value >> 4) & 0x07; const sample = ((((value & 0x0f) << 3) + 0x84) << exponent) - 0x84; return value & 0x80 ? -sample : sample; } function analyze(track, window) { let sum = 0; for (const byte of window) sum += mulawToLinear(byte) ** 2; const rms = Math.sqrt(sum / window.length); const level = 20 * Math.log10(Math.max(rms, 1) / 32768); console.log(`${track}: ${level.toFixed(0)} dBFS`); } const server = new WebSocketServer({ port: 8080, path: "/stream" }); console.log("Listening on port 8080"); server.on("connection", (socket) => { const buffers = new Map(); let windowBytes = 8000; socket.on("message", (data) => { const event = JSON.parse(data.toString()); if (event.event === "start") { windowBytes = event.start.mediaFormat.sampleRate; for (const track of event.start.tracks) buffers.set(track, Buffer.alloc(0)); } else if (event.event === "media") { const track = event.media.track; let buffer = Buffer.concat([buffers.get(track), Buffer.from(event.media.payload, "base64")]); if (buffer.length >= windowBytes) { analyze(track, buffer.subarray(0, windowBytes)); buffer = buffer.subarray(windowBytes); } buffers.set(track, buffer); } }); }); ``` Run it in place of the stream server, make the call from your first run again, and speak. The server prints a line per second for each track, in dBFS, and the number rises toward zero while that side talks. `analyze` runs inside the receive loop, which suits quick work like this. For slow work such as a network request, hand each window to a background task, so the server keeps reading audio while it waits. The decoder assumes the default `PCMU` codec. `L16` audio is already linear 16-bit PCM, so skip the decoding and read two bytes per sample; a one-second window then holds `sampleRate * 2` bytes. ## Send audio back on a bidirectional stream On a bidirectional stream, SignalWire plays the audio your server sends into the call. The stream's `realtime` setting decides what happens to audio your server sends faster than it plays: * `realtime: false` (the default): SignalWire queues the audio and plays it in order. The queue is bounded; once it fills, new audio and marks are dropped. * `realtime: true`: SignalWire keeps the queue short and drops new audio while it backs up, so playback stays close to live. In either mode, send audio in small chunks at about the rate it plays. Sending an entire file at once fills the queue, and a very large backlog ends the stream. ### Messages your server sends back Your server sends JSON messages to SignalWire on the same connection. Include the `streamSid` from the `start` message on each one, as the samples do. #### media Plays `media.payload`, base64-encoded raw audio in the stream's codec, into the call. SignalWire queues these messages and plays them in order. Send raw audio only: a payload that includes a file header, such as a WAV header, plays as noise. ```json { "event": "media", "streamSid": "", "media": { "payload": "" } } ``` #### mark Asks SignalWire to send a `mark` message with the same `mark.name` back once everything queued before it has played, or right away if nothing is queued. Use it to learn when a prompt has finished. ```json { "event": "mark", "streamSid": "", "mark": { "name": "prompt-done" } } ``` #### clear Stops playback and empties the queue, including key presses your server queued, so you can interrupt what the caller hears, for example when they start talking or press a key. Marks still waiting in the queue come back at once, so after a `clear`, treat returned marks as acknowledgments of cleared audio, not proof that the caller heard it. ```json { "event": "clear", "streamSid": "" } ``` #### dtmf Sends `dtmf.digit`, one of `0` to `9`, `*`, `#`, or `A` to `D`, into the call as a key press. Add `dtmf.duration` to set how long the key is held. ```json { "event": "dtmf", "streamSid": "", "dtmf": { "digit": "1" } } ``` ### Play your own audio into the call To play audio your server produces, such as a recorded prompt or text-to-speech output, convert it to the stream's codec, split it into frames, and send each frame as a `media` message. This server plays a prompt when the stream starts, lets the caller skip it with any key, and closes the connection once the prompt has finished or been skipped. The server sends 160-byte frames of 8 kHz µ-law audio about every 20 ms. After the final frame, it sends a `mark` and waits for the acknowledgment before closing. On a key press, it stops sending, sends `clear`, and closes. Receiving continues while audio plays. Convert your prompt to raw 8 kHz µ-law with no header first, for example with `ffmpeg -i prompt.mp3 -ar 8000 -ac 1 -f mulaw prompt.ulaw`, and save `prompt.ulaw` next to the server. #### Python — FastAPI ```python # Install: python -m pip install fastapi "uvicorn[standard]" # Save as play_audio.py next to prompt.ulaw and run: python play_audio.py import asyncio import base64 import json import uvicorn from fastapi import FastAPI, WebSocket app = FastAPI() FRAME_BYTES = 160 # 20 ms of 8 kHz µ-law audio with open("prompt.ulaw", "rb") as prompt_file: PROMPT = prompt_file.read() async def play(websocket, stream_sid, audio): for offset in range(0, len(audio), FRAME_BYTES): frame = audio[offset:offset + FRAME_BYTES] await websocket.send_text(json.dumps({ "event": "media", "streamSid": stream_sid, "media": {"payload": base64.b64encode(frame).decode()}, })) # Send one frame every 20 ms, the rate it plays. await asyncio.sleep(0.02) # SignalWire sends this mark back once everything before it has played. await websocket.send_text(json.dumps({ "event": "mark", "streamSid": stream_sid, "mark": {"name": "prompt-done"}, })) @app.websocket("/stream") async def stream(websocket: WebSocket): await websocket.accept() stream_sid = None player = None try: async for message in websocket.iter_text(): event = json.loads(message) if event["event"] == "start": stream_sid = event["start"]["streamSid"] player = asyncio.create_task(play(websocket, stream_sid, PROMPT)) elif event["event"] == "dtmf" and player: # Any key skips the prompt: stop sending, then drop what is queued. player.cancel() await websocket.send_text(json.dumps({"event": "clear", "streamSid": stream_sid})) print(f"Prompt skipped with {event['dtmf']['digit']}") break elif event["event"] == "mark" and event["mark"]["name"] == "prompt-done": print("Prompt finished") break finally: if player: player.cancel() await websocket.close() uvicorn.run(app, host="0.0.0.0", port=8080) ``` #### JavaScript — ws ```javascript // Install: npm install ws // Save as play-audio.mjs next to prompt.ulaw and run: node play-audio.mjs import { readFileSync } from "node:fs"; import { WebSocketServer } from "ws"; const FRAME_BYTES = 160; // 20 ms of 8 kHz µ-law audio const FRAME_MS = 20; const PROMPT = readFileSync("prompt.ulaw"); const server = new WebSocketServer({ port: 8080, path: "/stream" }); console.log("Listening on port 8080"); server.on("connection", (socket) => { let streamSid; let timer; function play(audio) { let offset = 0; // Send one frame every 20 ms, the rate it plays. timer = setInterval(() => { if (offset >= audio.length) { clearInterval(timer); // SignalWire sends this mark back once everything before it has played. socket.send(JSON.stringify({ event: "mark", streamSid, mark: { name: "prompt-done" } })); return; } const frame = audio.subarray(offset, offset + FRAME_BYTES); socket.send(JSON.stringify({ event: "media", streamSid, media: { payload: frame.toString("base64") } })); offset += FRAME_BYTES; }, FRAME_MS); } socket.on("message", (data) => { const event = JSON.parse(data.toString()); if (event.event === "start") { streamSid = event.start.streamSid; play(PROMPT); } else if (event.event === "dtmf") { // Any key skips the prompt: stop sending, then drop what is queued. clearInterval(timer); socket.send(JSON.stringify({ event: "clear", streamSid })); console.log(`Prompt skipped with ${event.dtmf.digit}`); socket.close(); } else if (event.event === "mark" && event.mark.name === "prompt-done") { console.log("Prompt finished"); socket.close(); } }); socket.on("close", () => clearInterval(timer)); }); ``` Run it in place of the echo server, then make your Talk back call again. You hear the announcement, then your prompt. Let it finish, or press a key to cut it short; either way the server prints the outcome and closes the connection, and the call hangs up. ## Troubleshoot call streaming Find the symptom you see: * **The call plays normally, but your server prints nothing.** SignalWire couldn't reach your stream URL, and the call carried on without a stream. Check that the URL begins with `wss://` and ends with `/stream`, and that the tunnel is running and points at port `8080`. A Relay program reports the same problem on a bidirectional stream as the `failed` connect state. * **You hear an error or silence when you call your number.** SignalWire couldn't fetch your SWML. Rerun the `curl` check with the exact URL in the SWML Script, and confirm that the server and the tunnel are still running. A `401` means the password in the URL doesn't match the server's. * **You hear your number's old behavior.** The number still points at another Resource. Open it under **Phone Numbers** and check **Inbound Call Settings**. * **The phone never rings, or the call doesn't connect.** Check that `` is a number in your Space or a verified caller ID. A trial project only calls and receives calls from verified numbers. * **You hear silence on a bidirectional stream.** Check that the call uses `connect`, or a `stream` device in Relay, rather than a one-way `stream`, which ignores anything your server sends. * **Your prompt plays as loud static.** The audio has a file header, or isn't in the stream's codec. Convert it to raw 8 kHz µ-law, as [Play your own audio into the call](#play-your-own-audio-into-the-call) shows. > Stream a live call to your own WebSocket server with SWML, Relay, or REST, then save or process the audio as it arrives, and play audio back into the call on a bidirectional stream.