Custom Audio & Node.js
Bring your own audio: calls from a server, a phone line or a recording
With audio: false, AgentCall leaves the microphone and speakers alone, and you bring the audio. Use it to call your agent from Node.js: automated voice tests, a phone line, a kiosk with its own audio stack, or recording calls.
import { AgentCall } from "@voice213/agent-client";
const call = new AgentCall({ agentId: "k3x9m2p7q4wz", audio: false });
call.on("audio", (pcm24k) => speaker.write(pcm24k)); // the agent's voice
call.on("interrupted", () => speaker.flush()); // the caller cut in: drop what's queued
await call.start();
microphone.on("data", (pcm16k) => call.sendAudio(pcm16k)); // the caller's voiceThe audio format
| Direction | Format |
|---|---|
Caller → agent (sendAudio) | 16-bit signed little-endian PCM, mono, 16 kHz, as an Int16Array or ArrayBuffer |
Agent → caller (audio event) | 16-bit signed little-endian PCM, mono, 24 kHz, as an Int16Array |
- Send in small chunks, in real time. 20 to 40 ms per
sendAudio(320 to 640 samples), at the pace the audio was recorded. - Keep sending during silence. The agent knows the caller has finished a sentence from the silence after it. If you stop sending, it waits. While the call is muted (
setMuted(true)), keep callingsendAudio: the SDK replaces what you send with silence of the same length. - Play the agent's voice as it comes. It usually arrives faster than real time, so queue it. Empty your queue on
interrupted: the caller spoke over the agent, and the rest of its sentence must not play.
INPUT_SAMPLE_RATE (16000) and OUTPUT_SAMPLE_RATE (24000) are exported too.
Node.js
Node.js 22 and later have the fetch and WebSocket the SDK needs. On Node.js 20, pass the ws package:
import WebSocket from "ws";
const call = new AgentCall({ agentId: "k3x9m2p7q4wz", audio: false, webSocket: WebSocket });A server calls your agent the way a visitor does, through its public endpoint, so the same rules apply. Servers send no Origin header, so the agent's Allowed websites list must be empty. And each IP address can start 20 calls an hour. That's fine for tests and pilots. For production phone traffic, use WhatsApp calls, or contact us.
Test a voice flow from a recording
Play a recorded question to your agent, record its answer, and check the transcript. This makes a regression test you can run after every change to the instructions:
import { readFile, writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { AgentCall, resamplePcm16 } from "@voice213/agent-client";
/** A 16-bit mono WAV file's samples and rate. */
function readWav(file) {
let rate = 16000;
let data = null;
for (let at = 12; at + 8 <= file.length; ) {
const id = file.toString("ascii", at, at + 4);
const size = file.readUInt32LE(at + 4);
if (id === "fmt ") rate = file.readUInt32LE(at + 12);
if (id === "data") data = file.subarray(at + 8, at + 8 + size);
at += 8 + size + (size % 2);
}
return { rate, pcm: new Int16Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)) };
}
/** 16-bit mono PCM as a WAV file. */
function wavFile(pcm, rate) {
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + pcm.byteLength, 4);
header.write("WAVEfmt ", 8);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(1, 22); // mono
header.writeUInt32LE(rate, 24);
header.writeUInt32LE(rate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36);
header.writeUInt32LE(pcm.byteLength, 40);
return Buffer.concat([header, Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength)]);
}
const question = readWav(await readFile("question.wav"));
const pcm = resamplePcm16(question.pcm, question.rate, 16000);
const heard = [];
const call = new AgentCall({ agentId: process.env.VOICE213_AGENT_ID, audio: false });
call.on("audio", (chunk) => heard.push(chunk));
call.on("message", ({ role, text }) => console.log(`${role}: ${text}`));
/** Resolves with the agent's next completed sentence. */
const agentSays = () =>
new Promise((resolve) => {
const stop = call.on("message", (message) => {
if (message.role !== "agent") return;
stop();
resolve(message.text);
});
});
const greeting = agentSays();
await call.start();
await greeting; // its opening line (leave this out if your agent waits for the caller)
// The question, in real time, 40 ms at a time, then two seconds of silence to end the turn
const answer = agentSays();
const frame = 640;
for (let at = 0; at < pcm.length + 2 * 16000; at += frame) {
call.sendAudio(at < pcm.length ? pcm.subarray(at, at + frame) : new Int16Array(frame));
await sleep(40);
}
console.log("The agent answered:", await answer);
await sleep(3000); // let its voice finish arriving
const ended = await call.end();
const voice = new Int16Array(heard.reduce((n, c) => n + c.length, 0));
heard.reduce((at, c) => (voice.set(c, at), at + c.length), 0);
await writeFile("answer.wav", wavFile(voice, 24000));
console.log(ended, call.transcript.map((m) => `${m.role}: ${m.text}`));Run it with VOICE213_AGENT_ID=k3x9m2p7q4wz node ask.mjs. The call is a real conversation: it appears in your dashboard and counts against your call minutes.
To type instead of speak, skip the audio and use call.sendText("…"). The agent answers by voice, and you get the transcript and audio the same way.
A phone line (Twilio)
A phone network carries 8 kHz μ-law audio. The SDK converts it both ways: decodeMulaw and encodeMulaw for the codec, and createResampler for the rates. This bridge connects a Twilio call to your agent through Twilio's bidirectional Media Streams. The bridge's address is public, and every call it starts is billed to you, so the TwiML hands it a key of your own and the bridge refuses any stream without it:
<Response>
<Connect>
<Stream url="wss://bridge.example.com/twilio">
<Parameter name="bridgeKey" value="YOUR_BRIDGE_KEY" />
</Stream>
</Connect>
</Response>import crypto from "node:crypto";
import { WebSocketServer } from "ws";
import { AgentCall, createResampler, decodeMulaw, encodeMulaw } from "@voice213/agent-client";
// The same random value as in the TwiML (openssl rand -hex 24)
const BRIDGE_KEY = process.env.BRIDGE_KEY;
if (!BRIDGE_KEY) throw new Error("Set BRIDGE_KEY");
const sameKey = (given = "") =>
given.length === BRIDGE_KEY.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(BRIDGE_KEY));
const server = new WebSocketServer({ port: 8080, path: "/twilio", maxPayload: 64 * 1024 });
server.on("connection", (twilio) => {
let streamSid = null;
// One resampler per direction, kept for the whole call so chunks join seamlessly
const toAgent = createResampler(8000, 16000);
const toPhone = createResampler(24000, 8000);
const call = new AgentCall({ agentId: process.env.VOICE213_AGENT_ID, audio: false });
call.on("audio", (pcm24k) => {
const payload = Buffer.from(encodeMulaw(toPhone.process(pcm24k))).toString("base64");
twilio.send(JSON.stringify({ event: "media", streamSid, media: { payload } }));
});
// The caller spoke over the agent: drop the audio Twilio still holds
call.on("interrupted", () => twilio.send(JSON.stringify({ event: "clear", streamSid })));
call.on("ended", () => twilio.close());
call.on("error", (error) => {
console.error("Agent call failed:", error.code, error.message);
twilio.close();
});
twilio.on("message", (raw) => {
let message;
try {
message = JSON.parse(raw);
} catch {
return twilio.close();
}
if (message.event === "start") {
// No key, no call: anyone can reach this address
if (!sameKey(message.start.customParameters?.bridgeKey)) return twilio.close();
streamSid = message.start.streamSid;
call.start();
} else if (message.event === "media" && call.status === "connected") {
call.sendAudio(toAgent.process(decodeMulaw(Buffer.from(message.media.payload, "base64"))));
} else if (message.event === "stop") {
call.end();
}
});
twilio.on("close", () => call.end());
});Twilio sends 20 ms of audio per message, which is the pace the agent expects. Mind the limits in the warning above before you put real phone traffic on it.
Record calls in the browser
The audio event fires with browser audio too. It's a copy, and playback isn't affected. Collect the agent's side of a call:
const chunks = [];
call.on("audio", (pcm) => chunks.push(pcm));
call.on("ended", () => uploadRecording(chunks)); // 24 kHz 16-bit mono PCMTo record the caller too, use the MediaRecorder API on your own microphone stream. The SDK opens its own and doesn't share it.
Helpers
| Export | What it does |
|---|---|
createResampler(fromRate, toRate) | A streaming resampler: .process(Int16Array) for each chunk. When it lowers the rate, it filters out what the new rate can't carry. |
resamplePcm16(pcm, fromRate, toRate) | Resamples a whole recording at once |
encodeMulaw(pcm), decodeMulaw(bytes) | G.711 μ-law, the telephone codec |
floatToPcm16(float32), pcm16ToFloat(int16) | Between Web Audio's float samples and 16-bit PCM |
Was this page helpful?