Realtime API
Low-latency, bidirectional voice conversations via WebSockets.
The Realtime API enables fluid, natural voice conversations. It uses WebSockets to stream audio bi-directionally, achieving response times under 300ms.
It is powered by አሌፍ-1.2-realtime-audio, allowing for natural interruptions and back-channeling.
Stream speech with your chosen Voice 2 voice
For text-to-speech over HTTP or a persistent WebSocket, see Streaming text-to-speech. That guide covers voice selection, session tickets, saved clips, and minute billing. The page below covers microphone conversations.
Endpoint
Authentication
For browser and mobile clients, pass your API key as a query parameter:
wss://relay.addisassistant.com/ws?apiKey=YOUR_API_KEYIndustry Use Cases
Our Realtime model is already deployed across key African sectors. You can test these specific Agents in our Playground.
Telecom Assistant
Automated customer support for balance checks, package purchasing, and troubleshooting in Amharic.
Ride Hailing
Voice-first driver negotiation and location finding. "Where are you?" "I'm at Bole near the bank."
Farmer's Assistant
Agricultural advisory for weather updates, crop disease diagnosis, and market prices in local dialects.
Audio Format Requirements
Critical: Audio Encoding
The API expects 16-bit Signed Integer PCM (PCM16) audio.
Developer Warning: The standard browser AudioContext returns 32-bit Float data (-1.0 to 1.0) by default. If you send this directly, it will sound like static noise. You must convert Float32 to Int16 before sending it over the WebSocket.
| Property | Value |
|---|---|
| Encoding | PCM 16-bit (Little Endian) |
| Client Upload | audio/pcm;rate=16000 (Mono) |
| Server Audio Output | PCM16 Mono, 24,000Hz (each audio event carries its sample_rate) |
Integration Guide
Browser Implementation (JavaScript)
This example establishes a connection, captures microphone audio, converts it from Float32 to PCM16, and handles the audio stream.
const API_KEY = "sk_YOUR_KEY";
const WS_URL = `wss://relay.addisassistant.com/ws?apiKey=${encodeURIComponent(API_KEY)}`;
const INPUT_RATE = 16000;
const OUTPUT_RATE = 24000;
let socket;
let inputContext;
let outputContext;
let stream;
let source;
let processor;
let canStreamAudio = false;
let nextStartTime = 0;
const playing = new Set();
async function startRealtime() {
// Output context for AI playback
outputContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: OUTPUT_RATE,
});
await outputContext.resume();
nextStartTime = outputContext.currentTime;
// Input context for mic capture
inputContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: INPUT_RATE,
});
await inputContext.resume();
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
source = inputContext.createMediaStreamSource(stream);
processor = inputContext.createScriptProcessor(2048, 1, 1);
// Connect processor through a muted gain so callback keeps firing without mic echo
const mute = inputContext.createGain();
mute.gain.value = 0;
source.connect(processor);
processor.connect(mute);
mute.connect(inputContext.destination);
processor.onaudioprocess = (event) => {
if (!canStreamAudio || socket?.readyState !== WebSocket.OPEN) return;
// Browser gives Float32 (-1..1), convert to PCM16 and send as base64 JSON envelope
const float32 = event.inputBuffer.getChannelData(0);
const int16 = floatTo16BitPCM(float32);
socket.send(
JSON.stringify({
data: arrayBufferToBase64(int16.buffer),
mimeType: "audio/pcm;rate=16000",
}),
);
};
socket = new WebSocket(WS_URL);
socket.onopen = () => {
console.log("Connected. Waiting for the ready event...");
};
socket.onmessage = async (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case "ready":
canStreamAudio = true;
break;
case "audio":
await playPcm16Base64(message.data, message.sample_rate || OUTPUT_RATE);
break;
case "interrupted":
stopPlayback(); // the user spoke over the assistant: drop queued audio
break;
case "transcript":
if (message.finished) console.log(`${message.role}: ${message.text}`);
break;
case "turn_complete":
if (message.usage) console.log("Usage:", message.usage);
break;
case "warning":
case "session_ending":
console.warn(message);
break;
case "error":
console.error("Realtime error:", message.error);
break;
}
};
socket.onerror = (event) => {
console.error("WebSocket error:", event);
};
socket.onclose = (event) => {
console.log(`WebSocket closed code=${event.code} reason=${event.reason || "n/a"}`);
};
}
function stopRealtime() {
canStreamAudio = false;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
socket.close(1000, "client-stop");
}
if (processor) processor.disconnect();
if (source) source.disconnect();
if (stream) stream.getTracks().forEach((t) => t.stop());
if (inputContext) inputContext.close();
if (outputContext) outputContext.close();
}
function floatTo16BitPCM(float32Array) {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]));
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
return int16Array;
}
function arrayBufferToBase64(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToUint8Array(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
async function playPcm16Base64(base64Audio, sampleRate = 24000) {
const bytes = base64ToUint8Array(base64Audio);
const pcm16 = new Int16Array(bytes.buffer);
const float32 = new Float32Array(pcm16.length);
for (let i = 0; i < pcm16.length; i++) {
float32[i] = pcm16[i] / 32768;
}
const buffer = outputContext.createBuffer(1, float32.length, sampleRate);
buffer.copyToChannel(float32, 0);
const src = outputContext.createBufferSource();
src.buffer = buffer;
src.connect(outputContext.destination);
playing.add(src);
src.onended = () => playing.delete(src);
const startAt = Math.max(outputContext.currentTime, nextStartTime);
src.start(startAt);
nextStartTime = startAt + buffer.duration;
}
function stopPlayback() {
for (const src of playing) src.stop();
playing.clear();
nextStartTime = outputContext.currentTime;
}import asyncio
import base64
import json
import pyaudio
import websockets
# Audio Config
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
CHUNK = 1024 # ~64ms
async def realtime_chat():
api_key = "sk_YOUR_KEY"
uri = f"wss://relay.addisassistant.com/ws?apiKey={api_key}"
ready_event = asyncio.Event()
async with websockets.connect(uri) as websocket:
# 1. Setup Microphone
p = pyaudio.PyAudio()
stream = p.open(
format=FORMAT,
channels=CHANNELS,
rate=RATE,
input=True,
frames_per_buffer=CHUNK,
)
async def receive_messages():
async for raw in websocket:
message = json.loads(raw)
if message.get("type") != "audio":
print("<", message)
if message.get("type") == "ready":
ready_event.set()
elif message.get("type") == "error":
print("Realtime error:", message["error"])
break
print("Listening... (Press Ctrl+C to stop)")
try:
receive_task = asyncio.create_task(receive_messages())
await ready_event.wait()
while True:
# Read raw PCM16 bytes from mic and send as base64 JSON message
data = stream.read(CHUNK)
await websocket.send(
json.dumps(
{
"data": base64.b64encode(data).decode("utf-8"),
"mimeType": "audio/pcm;rate=16000",
}
)
)
except KeyboardInterrupt:
pass
finally:
receive_task.cancel()
stream.stop_stream()
stream.close()
p.terminate()
asyncio.run(realtime_chat())Protocol & Events
The WebSocket carries the Addis realtime protocol: JSON text frames in both directions. Every server event has a type field; ignore types you don't recognise, so your client keeps working as events are added.
Wait for ready
After connecting, wait for the ready event before streaming microphone frames.
{ "type": "ready", "protocol": "addis" }Client to Server Audio
Send JSON envelopes with base64 PCM16 chunks (16 kHz, mono).
{
"data": "BASE64_ENCODED_PCM16_CHUNK",
"mimeType": "audio/pcm;rate=16000"
}{"type": "audio", "data": "BASE64_ENCODED_PCM16_CHUNK"} is accepted too.
Server to Client Events
type | When | Fields |
|---|---|---|
ready | The session is ready for audio. | protocol |
audio | A chunk of the assistant's speech. | data (base64 PCM16 mono), sample_rate |
transcript | Text of what the user or the assistant said. | role (user or assistant), text, finished |
interrupted | The user spoke over the assistant. Drop any audio you have queued. | none |
turn_complete | The assistant finished its turn. Carries the turn's token usage, once per turn. | usage (may be absent) |
warning | A non-critical issue, such as a low wallet balance. | code, message |
session_ending | The session is about to end. | seconds_left |
error | A critical failure. The server closes the socket after it. | error.code, error.message, error.status |
tool_call | The assistant asks your client to run a tool. | id, name, arguments |
Audio
{
"type": "audio",
"data": "BASE64_ENCODED_PCM16_AUDIO",
"sample_rate": 24000
}Transcript
{ "type": "transcript", "role": "assistant", "text": "ሰላም! እንዴት ልርዳዎት?", "finished": true }Interrupted
{ "type": "interrupted" }Turn Complete & Usage
{
"type": "turn_complete",
"usage": { "input_tokens": 120, "output_tokens": 340, "total_tokens": 460 }
}usage holds the turn's billed token counts. It arrives only on turn_complete, once per turn; add up total_tokens across turns for the session total.
Warning and Session Ending
{
"type": "warning",
"code": "low_balance",
"message": "Your wallet balance is low. Please top up to avoid service interruption."
}{ "type": "session_ending", "seconds_left": 30 }Error
{
"type": "error",
"error": {
"code": "insufficient_balance",
"message": "Your wallet balance is too low to continue.",
"status": 402
}
}Connection Troubleshooting
- If the socket closes with
1006, verify:- You used the correct endpoint.
- The
apiKeyquery value is valid. - Client audio is sent as JSON (
data+mimeType), not raw binary.
Live Interactive Demo
Use the embedded tool below to test the Realtime API directly from this page.
Live Realtime Voice Demo
Integrated browser demo with start/stop, playback, and logs.
wss://relay.addisassistant.com/ws?apiKey=YOUR_API_KEYUse on localhost or https. For production, issue short-lived auth from your backend.
Standalone HTML (Local Testing)
Use this standalone file if you want to run the same flow outside the docs site.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Realtime Voice Test</title>
</head>
<body style="font-family: Arial, sans-serif; margin: 24px;">
<h2>Realtime Voice Test</h2>
<input id="apiKey" type="password" placeholder="sk_..." style="width: 340px; padding: 8px;" />
<button id="startBtn">Start</button>
<button id="stopBtn" disabled>Stop</button>
<pre id="log" style="margin-top: 16px; border: 1px solid #ddd; padding: 12px; min-height: 140px;"></pre>
<script>
const WS_URL = "wss://relay.addisassistant.com/ws";
const INPUT_RATE = 16000;
const OUTPUT_RATE = 24000;
let ws;
let inputCtx;
let outputCtx;
let stream;
let sourceNode;
let processorNode;
let muteNode;
let canStream = false;
let nextStartTime = 0;
const playing = new Set();
const logEl = document.getElementById("log");
const startBtn = document.getElementById("startBtn");
const stopBtn = document.getElementById("stopBtn");
const apiKeyEl = document.getElementById("apiKey");
function log(message) {
const t = new Date().toLocaleTimeString();
logEl.textContent += `[${t}] ${message}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
function f32ToPcm16(float32) {
const out = new Int16Array(float32.length);
for (let i = 0; i < float32.length; i++) {
const s = Math.max(-1, Math.min(1, float32[i]));
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
return out;
}
function abToBase64(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function base64ToAb(base64) {
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
function stopPlayback() {
for (const src of playing) src.stop();
playing.clear();
nextStartTime = outputCtx ? outputCtx.currentTime : 0;
}
async function playPcm16(arrayBuffer, sampleRate = OUTPUT_RATE) {
if (!outputCtx) return;
const pcm16 = new Int16Array(arrayBuffer);
const f32 = new Float32Array(pcm16.length);
for (let i = 0; i < pcm16.length; i++) f32[i] = pcm16[i] / 32768;
const buffer = outputCtx.createBuffer(1, f32.length, sampleRate);
buffer.copyToChannel(f32, 0);
const src = outputCtx.createBufferSource();
src.buffer = buffer;
src.connect(outputCtx.destination);
playing.add(src);
src.onended = () => playing.delete(src);
const startAt = Math.max(outputCtx.currentTime, nextStartTime);
src.start(startAt);
nextStartTime = startAt + buffer.duration;
}
async function stop() {
canStream = false;
if (ws && (ws.readyState === 0 || ws.readyState === 1)) ws.close(1000, "client-stop");
ws = null;
try { if (processorNode) processorNode.disconnect(); } catch {}
try { if (sourceNode) sourceNode.disconnect(); } catch {}
try { if (muteNode) muteNode.disconnect(); } catch {}
if (stream) stream.getTracks().forEach((t) => t.stop());
if (inputCtx) await inputCtx.close().catch(() => {});
if (outputCtx) await outputCtx.close().catch(() => {});
inputCtx = outputCtx = null;
stream = sourceNode = processorNode = muteNode = null;
nextStartTime = 0;
startBtn.disabled = false;
stopBtn.disabled = true;
log("Stopped.");
}
async function start() {
const apiKey = apiKeyEl.value.trim();
if (!apiKey) return log("Missing API key.");
startBtn.disabled = true;
stopBtn.disabled = false;
outputCtx = new AudioContext({ sampleRate: OUTPUT_RATE });
await outputCtx.resume();
nextStartTime = outputCtx.currentTime;
inputCtx = new AudioContext({ sampleRate: INPUT_RATE });
await inputCtx.resume();
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
sourceNode = inputCtx.createMediaStreamSource(stream);
processorNode = inputCtx.createScriptProcessor(2048, 1, 1);
muteNode = inputCtx.createGain();
muteNode.gain.value = 0;
processorNode.onaudioprocess = (event) => {
if (!canStream || !ws || ws.readyState !== 1) return;
const f32 = event.inputBuffer.getChannelData(0);
const pcm16 = f32ToPcm16(f32);
ws.send(JSON.stringify({ data: abToBase64(pcm16.buffer), mimeType: "audio/pcm;rate=16000" }));
};
sourceNode.connect(processorNode);
processorNode.connect(muteNode);
muteNode.connect(inputCtx.destination);
const url = new URL(WS_URL);
url.searchParams.set("apiKey", apiKey);
ws = new WebSocket(url.toString());
ws.onopen = () => log("Connected. Waiting for the ready event...");
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ready") {
canStream = true;
return log("Ready. Streaming live.");
}
if (msg.type === "audio" && msg.data) await playPcm16(base64ToAb(msg.data), msg.sample_rate || OUTPUT_RATE);
if (msg.type === "interrupted") stopPlayback();
if (msg.type === "transcript" && msg.finished) log(`${msg.role}: ${msg.text}`);
if (msg.type === "warning" || msg.type === "session_ending") log(JSON.stringify(msg));
if (msg.type === "error") log(`Server error: ${JSON.stringify(msg.error)}`);
};
ws.onclose = (e) => log(`Closed code=${e.code} reason=${e.reason || "n/a"}`);
ws.onerror = () => log("WebSocket error");
}
startBtn.onclick = () => void start();
stopBtn.onclick = () => void stop();
window.addEventListener("beforeunload", () => void stop());
</script>
</body>
</html>Capabilities Roadmap
We are rapidly expanding the Realtime engine.
VAD (Voice Activity Detection)
The model currently uses server-side VAD to determine when the user has stopped speaking.
Knowledge Base
Coming SoonSoon you will be able to attach PDFs or Text documents to the Realtime session. This will allow the voice assistant to answer questions specifically based on your uploaded data (RAG).
Last updated on