Created
June 14, 2026 20:40
-
-
Save chapimenge3/dc7ae492d35632394d2910efb14ffa5d to your computer and use it in GitHub Desktop.
Test implementation of addis ai realtime api https://platform.addisassistant.com/docs/capabilities/realtime-api
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Realtime Audio Test</title> | |
| <style> | |
| :root { | |
| --bg: #0f1117; | |
| --panel: #1a1d27; | |
| --border: #2a2f3d; | |
| --text: #e6e8ee; | |
| --muted: #8b91a3; | |
| --accent: #5b8cff; | |
| --green: #3ecf8e; | |
| --red: #ff5c5c; | |
| --amber: #ffb84d; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| display: flex; | |
| justify-content: center; | |
| padding: 32px 16px; | |
| } | |
| .wrap { width: 100%; max-width: 720px; } | |
| h1 { font-size: 20px; margin: 0 0 4px; } | |
| .sub { color: var(--muted); font-size: 13px; margin-bottom: 24px; } | |
| .card { | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin-bottom: 16px; | |
| } | |
| label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; } | |
| input[type="text"] { | |
| width: 100%; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| padding: 10px 12px; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| font-family: ui-monospace, monospace; | |
| } | |
| .controls { display: flex; gap: 10px; align-items: center; margin-top: 16px; } | |
| button { | |
| background: var(--accent); | |
| color: #fff; | |
| border: none; | |
| padding: 10px 18px; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: opacity .15s; | |
| } | |
| button:hover { opacity: .9; } | |
| button:disabled { opacity: .4; cursor: not-allowed; } | |
| button.stop { background: var(--red); } | |
| .status { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 13px; | |
| color: var(--muted); | |
| margin-left: auto; | |
| } | |
| .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); } | |
| .dot.connecting { background: var(--amber); animation: pulse 1s infinite; } | |
| .dot.live { background: var(--green); } | |
| .dot.error { background: var(--red); } | |
| @keyframes pulse { 50% { opacity: .3; } } | |
| .logwrap { margin-top: 4px; } | |
| .logheader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } | |
| #log { | |
| background: #0a0c11; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 12px; | |
| height: 280px; | |
| overflow-y: auto; | |
| font-family: ui-monospace, monospace; | |
| font-size: 12px; | |
| line-height: 1.5; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .log-line { margin: 0 0 2px; } | |
| .log-info { color: var(--text); } | |
| .log-sys { color: var(--accent); } | |
| .log-err { color: var(--red); } | |
| .log-ok { color: var(--green); } | |
| .log-time { color: var(--muted); } | |
| .clearbtn { | |
| background: transparent; | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| padding: 4px 10px; | |
| font-size: 11px; | |
| font-weight: 500; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <h1>Realtime Audio Test</h1> | |
| <div class="sub">Mic → WebSocket relay → AI audio playback. Open the browser console for full event objects.</div> | |
| <div class="card"> | |
| <label for="apikey">API Key</label> | |
| <input type="text" id="apikey" placeholder="Paste your API key here" autocomplete="off" spellcheck="false" /> | |
| <div class="controls"> | |
| <button id="startBtn">Start</button> | |
| <button id="stopBtn" class="stop" disabled>Stop</button> | |
| <span class="status"><span class="dot" id="dot"></span><span id="statusText">Idle</span></span> | |
| </div> | |
| </div> | |
| <div class="card logwrap"> | |
| <div class="logheader"> | |
| <label style="margin:0;">Event Log</label> | |
| <button class="clearbtn" id="clearBtn">Clear</button> | |
| </div> | |
| <div id="log"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // ===== Config ===== | |
| const RELAY_BASE = "wss://relay.addisassistant.com/ws"; | |
| const INPUT_RATE = 16000; | |
| const OUTPUT_RATE = 24000; | |
| // ===== State ===== | |
| let socket; | |
| let inputContext; | |
| let outputContext; | |
| let stream; | |
| let source; | |
| let processor; | |
| let canStreamAudio = false; | |
| let nextStartTime = 0; | |
| // ===== UI helpers ===== | |
| const $ = (id) => document.getElementById(id); | |
| const startBtn = $("startBtn"); | |
| const stopBtn = $("stopBtn"); | |
| const clearBtn = $("clearBtn"); | |
| const dot = $("dot"); | |
| const statusText = $("statusText"); | |
| function setStatus(state, text) { | |
| dot.className = "dot" + (state ? " " + state : ""); | |
| statusText.textContent = text; | |
| } | |
| function log(msg, type = "info") { | |
| const el = $("log"); | |
| const time = new Date().toLocaleTimeString(); | |
| const line = document.createElement("div"); | |
| line.className = "log-line log-" + type; | |
| line.innerHTML = '<span class="log-time">' + time + '</span> ' + | |
| String(msg).replace(/</g, "<"); | |
| el.appendChild(line); | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| clearBtn.onclick = () => ($("log").innerHTML = ""); | |
| startBtn.onclick = () => startRealtime(); | |
| stopBtn.onclick = () => stopRealtime(); | |
| // ===== Main ===== | |
| async function startRealtime() { | |
| const apiKey = $("apikey").value.trim(); | |
| if (!apiKey) { | |
| log("Enter an API key first.", "err"); | |
| return; | |
| } | |
| startBtn.disabled = true; | |
| setStatus("connecting", "Connecting…"); | |
| try { | |
| // 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 }); | |
| log("Mic access granted.", "ok"); | |
| 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", | |
| }), | |
| ); | |
| }; | |
| const wsUrl = RELAY_BASE + "?apiKey=" + encodeURIComponent(apiKey); | |
| socket = new WebSocket(wsUrl); | |
| socket.onopen = () => { | |
| log("Connected. Waiting for setupComplete…", "sys"); | |
| setStatus("connecting", "Handshaking…"); | |
| }; | |
| socket.onmessage = async (event) => { | |
| let message; | |
| try { | |
| message = JSON.parse(event.data); | |
| } catch { | |
| log("Non-JSON message received.", "err"); | |
| return; | |
| } | |
| console.log("Event:", message); | |
| if (message.setupComplete || (message.type === "status" && /ready/i.test(message.message || ""))) { | |
| canStreamAudio = true; | |
| log("Setup complete — streaming mic audio.", "ok"); | |
| setStatus("live", "Live"); | |
| return; | |
| } | |
| const b64 = message?.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data; | |
| if (typeof b64 === "string" && b64.length) { | |
| log("Received audio chunk (" + b64.length + " b64 chars).", "info"); | |
| await playPcm16Base64(b64, OUTPUT_RATE); | |
| } else { | |
| // Log other message types so you can inspect the protocol | |
| const keys = Object.keys(message).join(", "); | |
| log("Message: { " + keys + " }", "sys"); | |
| } | |
| if (message?.error) { | |
| log("Realtime error: " + JSON.stringify(message.error), "err"); | |
| console.error("Realtime error:", message.error); | |
| } | |
| }; | |
| socket.onerror = (event) => { | |
| log("WebSocket error (see console).", "err"); | |
| setStatus("error", "Error"); | |
| console.error("WebSocket error:", event); | |
| }; | |
| socket.onclose = (event) => { | |
| log("WebSocket closed. code=" + event.code + " reason=" + (event.reason || "n/a"), "sys"); | |
| console.log("WebSocket closed code=" + event.code + " reason=" + (event.reason || "n/a")); | |
| teardownAudio(); | |
| setStatus("", "Idle"); | |
| startBtn.disabled = false; | |
| stopBtn.disabled = true; | |
| }; | |
| stopBtn.disabled = false; | |
| } catch (err) { | |
| log("Failed to start: " + err.message, "err"); | |
| console.error(err); | |
| setStatus("error", "Error"); | |
| teardownAudio(); | |
| startBtn.disabled = false; | |
| } | |
| } | |
| function stopRealtime() { | |
| canStreamAudio = false; | |
| log("Stopping…", "sys"); | |
| if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { | |
| socket.close(1000, "client-stop"); | |
| } | |
| teardownAudio(); | |
| setStatus("", "Idle"); | |
| startBtn.disabled = false; | |
| stopBtn.disabled = true; | |
| } | |
| function teardownAudio() { | |
| try { if (processor) processor.disconnect(); } catch {} | |
| try { if (source) source.disconnect(); } catch {} | |
| try { if (stream) stream.getTracks().forEach((t) => t.stop()); } catch {} | |
| try { if (inputContext && inputContext.state !== "closed") inputContext.close(); } catch {} | |
| try { if (outputContext && outputContext.state !== "closed") outputContext.close(); } catch {} | |
| processor = source = stream = inputContext = outputContext = null; | |
| } | |
| // ===== Audio conversion ===== | |
| 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); | |
| const startAt = Math.max(outputContext.currentTime, nextStartTime); | |
| src.start(startAt); | |
| nextStartTime = startAt + buffer.duration; | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment