Addis AI

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

WSSwss://relay.addisassistant.com/ws

Authentication

For browser and mobile clients, pass your API key as a query parameter:

wss://relay.addisassistant.com/ws?apiKey=YOUR_API_KEY

Industry 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.

PropertyValue
EncodingPCM 16-bit (Little Endian)
Client Uploadaudio/pcm;rate=16000 (Mono)
Server Audio OutputPCM16 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

typeWhenFields
readyThe session is ready for audio.protocol
audioA chunk of the assistant's speech.data (base64 PCM16 mono), sample_rate
transcriptText of what the user or the assistant said.role (user or assistant), text, finished
interruptedThe user spoke over the assistant. Drop any audio you have queued.none
turn_completeThe assistant finished its turn. Carries the turn's token usage, once per turn.usage (may be absent)
warningA non-critical issue, such as a low wallet balance.code, message
session_endingThe session is about to end.seconds_left
errorA critical failure. The server closes the socket after it.error.code, error.message, error.status
tool_callThe 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 apiKey query 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.

Endpoint
wss://relay.addisassistant.com/ws?apiKey=YOUR_API_KEY
Idle
Ready to connect.
Events / Logs

Use 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 Soon

Soon 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).

Edit on GitHub

Last updated on

On this page