Add Athena WebRTC bridge for OpenClaw Talk

This commit is contained in:
Mikei386
2026-09-16 14:10:39 +02:00
parent 276df0eae5
commit e86c905968
14 changed files with 1223 additions and 13 deletions
+23
View File
@@ -65,6 +65,29 @@ services:
retries: 12 retries: 12
start_period: 10s start_period: 10s
realtime-voice:
build: ./services/athena-realtime-voice
image: mike-ai/realtime-voice:local
container_name: mike-ai-realtime-voice
restart: unless-stopped
network_mode: "service:wireguard-gateway"
read_only: true
tmpfs:
- /tmp:size=32m,mode=1777
environment:
PORT: "8090"
ATHENA_API_BASE_URL: http://router:8081/v1
ATHENA_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
OPENCLAW_ORIGIN: https://oc.casaderoll.de
OPENCLAW_PUBLIC_KEY_URL: https://oc.casaderoll.de/plugins/athena-talk/realtime/public-key
depends_on:
wireguard-gateway:
condition: service_healthy
router:
condition: service_healthy
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
llama-fast: llama-fast:
<<: *llama-common <<: *llama-common
container_name: mike-ai-llama-fast container_name: mike-ai-llama-fast
+1
View File
@@ -24,6 +24,7 @@ nicht automatisch ein ungenutzter Rest.
| `mike-ai-music-ui` | kein Modell; `fspecii/ace-step-ui` | Community-Oberfläche für ACE-Step; bleibt als leichte UI verfügbar, während der GPU-Worker bedarfsgesteuert läuft. | | `mike-ai-music-ui` | kein Modell; `fspecii/ace-step-ui` | Community-Oberfläche für ACE-Step; bleibt als leichte UI verfügbar, während der GPU-Worker bedarfsgesteuert läuft. |
| `mike-ai-portainer` | kein Modell; Portainer CE | Optionale Docker-Verwaltungsoberfläche. | | `mike-ai-portainer` | kein Modell; Portainer CE | Optionale Docker-Verwaltungsoberfläche. |
| `mike-ai-profile-controller` | kein Modell | Startet und stoppt ausschließlich freigegebene Modellprofile und Spezialworker in einer sicheren Reihenfolge. | | `mike-ai-profile-controller` | kein Modell | Startet und stoppt ausschließlich freigegebene Modellprofile und Spezialworker in einer sicheren Reihenfolge. |
| `mike-ai-realtime-voice` | kein Modell | Experimentelle WebRTC-Brücke für OpenClaw Talk; verbindet über den privaten WireGuard-Pfad Athena Whisper, OpenClaw-Agent und Qwen3-TTS ohne Profilwechsel. |
| `mike-ai-qwen3-tts` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base`, Stimme Serena | Hochwertige deutsche Sprachausgabe auf der RTX 3060 im LLM-Betrieb. | | `mike-ai-qwen3-tts` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base`, Stimme Serena | Hochwertige deutsche Sprachausgabe auf der RTX 3060 im LLM-Betrieb. |
| `mike-ai-router` | kein eigenes Modell | Einzige OpenAI-kompatible Modelladresse; koordiniert Profile, Bildaufträge, Sprache und Betriebsarten. | | `mike-ai-router` | kein eigenes Modell | Einzige OpenAI-kompatible Modelladresse; koordiniert Profile, Bildaufträge, Sprache und Betriebsarten. |
| `mike-ai-stem-separator` | BS-RoFormer Viperx 1297, `htdemucs_ft`, `htdemucs_6s`, `MossFormer2_SE_48K` | Trennt Gesang, Instrumente oder Sprache/Hintergrundgeräusche im exklusiven Separationsmodus. | | `mike-ai-stem-separator` | BS-RoFormer Viperx 1297, `htdemucs_ft`, `htdemucs_6s`, `MossFormer2_SE_48K` | Trennt Gesang, Instrumente oder Sprache/Hintergrundgeräusche im exklusiven Separationsmodus. |
+36 -6
View File
@@ -1,7 +1,8 @@
# Athena Local Talk for OpenClaw # Athena Local Talk for OpenClaw
This private OpenClaw provider connects browser/Desktop Talk to the existing This private OpenClaw provider connects Gateway-relay Talk sessions to the
Athena speech stack: existing Athena speech stack. An experimental browser WebRTC path is available
through the separate `services/athena-realtime-voice` service:
1. local VAD collects a spoken utterance, 1. local VAD collects a spoken utterance,
2. Athena Whisper transcribes it, 2. Athena Whisper transcribes it,
@@ -9,8 +10,8 @@ Athena speech stack:
tools, tools,
4. Athena Qwen3-TTS returns PCM audio to the Talk client. 4. Athena Qwen3-TTS returns PCM audio to the Talk client.
Long replies are synthesized incrementally. The first short phrase starts The older `gateway-relay` path synthesizes long replies incrementally. The
playing as soon as it is ready while the next phrase is generated in parallel. experimental WebRTC path currently synthesizes each agent reply as one clip.
The provider intentionally uses half-duplex audio: microphone input is paused The provider intentionally uses half-duplex audio: microphone input is paused
while a response is being transcribed, generated, synthesized, or played. This while a response is being transcribed, generated, synthesized, or played. This
@@ -30,11 +31,12 @@ Recommended `talk.realtime` configuration:
"model": "athena-local", "model": "athena-local",
"speakerVoice": "alloy", "speakerVoice": "alloy",
"mode": "realtime", "mode": "realtime",
"transport": "gateway-relay", "transport": "webrtc",
"brain": "agent-consult", "brain": "agent-consult",
"providers": { "providers": {
"athena-talk": { "athena-talk": {
"modelProvider": "llama-cpp", "modelProvider": "llama-cpp",
"realtimeUpstreamUrl": "http://192.168.1.212:8090/v1/realtime/calls",
"language": "de", "language": "de",
"vadThreshold": 0.018, "vadThreshold": 0.018,
"silenceDurationMs": 750, "silenceDurationMs": 750,
@@ -59,4 +61,32 @@ openclaw plugins inspect athena-talk --runtime --json
Restart the gateway once if the installation does not trigger an automatic Restart the gateway once if the installation does not trigger an automatic
reload. The managed plugin copy is stored in OpenClaw's persistent data reload. The managed plugin copy is stored in OpenClaw's persistent data
directory, so normal image updates do not remove it. Hermes remains unchanged; directory, so normal image updates do not remove it. Hermes remains unchanged;
both clients reuse the same OpenAI-compatible Athena speech endpoints. the provider reuses the same OpenAI-compatible Athena speech endpoints.
## Browser and Mac app status (OpenClaw 2026.9.4)
The original 1.0.0 provider implements `gateway-relay` only. A direct Gateway call to
`talk.session.create` with `mode=realtime`, `transport=gateway-relay`, and
`brain=agent-consult` creates an Athena Talk session successfully. The Control
UI and Mac app first call `talk.client.create`, which rejects that transport
with `talk.client.create is client-owned; use talk.session.create for
gateway-relay`. Their fallback to `talk.session.create` is not observed in the
affected installation, so the UI still reports a misleading authentication
error. A successfully created test session is tied to its Gateway connection
and disappears when that connection closes.
The 2026.9.4 browser `provider-websocket` client accepts only the built-in
`google-live-bidi` protocol and validates the Google WebSocket hostname.
Version 1.1.0 adds a separate `webrtc` browser session using the UI's
OpenAI-style WebRTC protocol and the Athena realtime voice bridge. The plugin
proxies the SDP offer through the existing OpenClaw HTTPS origin. On
2026-09-16, the installed plugin created a browser session successfully; a
synthetic spoken request traversed the real OpenClaw offer route, private
WireGuard media connection, Athena Whisper, and Qwen3-TTS and returned audio.
The installed browser code also recognizes the bridge's
`openclaw_agent_consult` call. A real UI microphone-to-agent-to-speaker call
has **not** yet been observed, so this remains experimental. The Mac setting
“Echtzeitweiterleitung über Gateway verwenden” must be off for the browser-owned
WebRTC path. `gateway-relay` remains available as a reversible fallback. Never
select `provider-websocket` for this service. See
`services/athena-realtime-voice/README.md` for setup and limits.
+125 -2
View File
@@ -1,6 +1,80 @@
import { randomUUID } from "node:crypto"; import { generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 }; const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 };
const BROWSER_OFFER_PATH = "/plugins/athena-talk/realtime/calls";
const BROWSER_KEY_PATH = "/plugins/athena-talk/realtime/public-key";
const MAX_OFFER_BYTES = 64 * 1024;
const browserKeys = generateKeyPairSync("ed25519");
const publicKeyPem = browserKeys.publicKey.export({ format: "pem", type: "spki" }).toString();
function base64url(value) {
return Buffer.from(value).toString("base64url");
}
function createBrowserToken() {
const expiresAt = Date.now() + 60_000;
const payload = base64url(JSON.stringify({ exp: Math.floor(expiresAt / 1000), jti: randomUUID() }));
const signature = sign(null, Buffer.from(payload), browserKeys.privateKey).toString("base64url");
return { token: `${payload}.${signature}`, expiresAt };
}
function validBrowserToken(value) {
const [payload, signature, extra] = value.split(".");
if (!payload || !signature || extra !== undefined)
return false;
try {
if (!verify(null, Buffer.from(payload), browserKeys.publicKey, Buffer.from(signature, "base64url"))) {
return false;
}
const claims = record(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
return typeof claims.jti === "string" && Number.isInteger(claims.exp) &&
claims.exp > Math.floor(Date.now() / 1000) &&
claims.exp <= Math.floor(Date.now() / 1000) + 65;
}
catch {
return false;
}
}
async function handleBrowserOffer(req, res, upstream) {
if (req.method !== "POST") {
res.writeHead(405).end("Method not allowed");
return true;
}
const token = req.headers.authorization?.replace(/^Bearer /i, "") || "";
if (!validBrowserToken(token)) {
res.writeHead(401).end("Invalid realtime session token");
return true;
}
if (!upstream.startsWith("http://") && !upstream.startsWith("https://")) {
res.writeHead(503).end("Athena realtime upstream is not configured");
return true;
}
const chunks = [];
let total = 0;
for await (const part of req) {
const chunk = Buffer.from(part);
total += chunk.length;
if (total > MAX_OFFER_BYTES) {
res.writeHead(413).end("WebRTC offer too large");
return true;
}
chunks.push(chunk);
}
try {
const response = await fetch(upstream, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/sdp" },
body: Uint8Array.from(Buffer.concat(chunks)),
signal: AbortSignal.timeout(30_000),
});
const answer = Buffer.from(await response.arrayBuffer());
if (answer.length > MAX_OFFER_BYTES)
throw new Error("WebRTC answer too large");
res.writeHead(response.status, { "Content-Type": response.headers.get("content-type") || "text/plain" });
res.end(answer);
}
catch {
res.writeHead(502).end("Athena realtime bridge unavailable");
}
return true;
}
function record(value) { function record(value) {
return value && typeof value === "object" && !Array.isArray(value) return value && typeof value === "object" && !Array.isArray(value)
? value ? value
@@ -31,6 +105,7 @@ function resolveConfig(req) {
silenceDurationMs: Number(raw.silenceDurationMs ?? 750), silenceDurationMs: Number(raw.silenceDurationMs ?? 750),
prefixPaddingMs: Number(raw.prefixPaddingMs ?? 300), prefixPaddingMs: Number(raw.prefixPaddingMs ?? 300),
maxSpeechSeconds: Number(raw.maxSpeechSeconds ?? 45), maxSpeechSeconds: Number(raw.maxSpeechSeconds ?? 45),
realtimeUpstreamUrl: String(raw.realtimeUpstreamUrl || ""),
}; };
} }
function wavFromPcm16(pcm, sampleRate = 24000) { function wavFromPcm16(pcm, sampleRate = 24000) {
@@ -337,6 +412,31 @@ export default definePluginEntry({
name: "Athena Local Talk", name: "Athena Local Talk",
description: "Private voice loop using Athena STT and TTS with the normal OpenClaw agent.", description: "Private voice loop using Athena STT and TTS with the normal OpenClaw agent.",
register(api) { register(api) {
api.registerHttpRoute({
path: BROWSER_KEY_PATH,
auth: "plugin",
match: "exact",
handler: (req, res) => {
if (req.method !== "GET") {
res.writeHead(405).end("Method not allowed");
}
else {
res.writeHead(200, { "Content-Type": "application/x-pem-file", "Cache-Control": "no-store" });
res.end(publicKeyPem);
}
return true;
},
});
api.registerHttpRoute({
path: BROWSER_OFFER_PATH,
auth: "plugin",
match: "exact",
handler: (req, res) => {
const raw = record(record(record(api.runtime.config.current()).talk).realtime);
const provider = record(record(raw.providers)["athena-talk"]);
return handleBrowserOffer(req, res, String(provider.realtimeUpstreamUrl || ""));
},
});
api.registerRealtimeVoiceProvider({ api.registerRealtimeVoiceProvider({
id: "athena-talk", id: "athena-talk",
label: "Athena Local Talk", label: "Athena Local Talk",
@@ -345,9 +445,10 @@ export default definePluginEntry({
voices: ["alloy"], voices: ["alloy"],
autoSelectOrder: 1, autoSelectOrder: 1,
capabilities: { capabilities: {
transports: ["gateway-relay"], transports: ["gateway-relay", "webrtc"],
inputAudioFormats: [AUDIO_FORMAT], inputAudioFormats: [AUDIO_FORMAT],
outputAudioFormats: [AUDIO_FORMAT], outputAudioFormats: [AUDIO_FORMAT],
supportsBrowserSession: true,
supportsBargeIn: false, supportsBargeIn: false,
handlesInputAudioBargeIn: false, handlesInputAudioBargeIn: false,
supportsToolCalls: true, supportsToolCalls: true,
@@ -359,6 +460,28 @@ export default definePluginEntry({
return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl); return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl);
}, },
createBridge: (req) => new AthenaTalkBridge(req, resolveConfig(req)), createBridge: (req) => new AthenaTalkBridge(req, resolveConfig(req)),
async createBrowserSession(req) {
const config = resolveConfig(req);
if (!config.realtimeUpstreamUrl) {
throw new Error("athena-talk realtimeUpstreamUrl is required for browser Talk");
}
const { token, expiresAt } = createBrowserToken();
return {
provider: "athena-talk",
transport: "webrtc",
clientSecret: token,
offerUrl: BROWSER_OFFER_PATH,
audio: {
inputEncoding: "pcm16",
inputSampleRateHz: 24000,
outputEncoding: "pcm16",
outputSampleRateHz: 24000,
},
model: req.model || "athena-local",
voice: req.voice || config.voice,
expiresAt,
};
},
}); });
}, },
}); });
+129 -2
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto"; import { generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 } as const; const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 } as const;
@@ -13,8 +14,86 @@ type ProviderConfig = {
silenceDurationMs?: number; silenceDurationMs?: number;
prefixPaddingMs?: number; prefixPaddingMs?: number;
maxSpeechSeconds?: number; maxSpeechSeconds?: number;
realtimeUpstreamUrl?: string;
}; };
const BROWSER_OFFER_PATH = "/plugins/athena-talk/realtime/calls";
const BROWSER_KEY_PATH = "/plugins/athena-talk/realtime/public-key";
const MAX_OFFER_BYTES = 64 * 1024;
const browserKeys = generateKeyPairSync("ed25519");
const publicKeyPem = browserKeys.publicKey.export({ format: "pem", type: "spki" }).toString();
function base64url(value: string): string {
return Buffer.from(value).toString("base64url");
}
function createBrowserToken(): { token: string; expiresAt: number } {
const expiresAt = Date.now() + 60_000;
const payload = base64url(JSON.stringify({ exp: Math.floor(expiresAt / 1000), jti: randomUUID() }));
const signature = sign(null, Buffer.from(payload), browserKeys.privateKey).toString("base64url");
return { token: `${payload}.${signature}`, expiresAt };
}
function validBrowserToken(value: string): boolean {
const [payload, signature, extra] = value.split(".");
if (!payload || !signature || extra !== undefined) return false;
try {
if (!verify(null, Buffer.from(payload), browserKeys.publicKey, Buffer.from(signature, "base64url"))) {
return false;
}
const claims = record(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
return typeof claims.jti === "string" && Number.isInteger(claims.exp) &&
claims.exp > Math.floor(Date.now() / 1000) &&
claims.exp <= Math.floor(Date.now() / 1000) + 65;
} catch {
return false;
}
}
async function handleBrowserOffer(
req: IncomingMessage, res: ServerResponse, upstream: string,
): Promise<boolean> {
if (req.method !== "POST") {
res.writeHead(405).end("Method not allowed");
return true;
}
const token = req.headers.authorization?.replace(/^Bearer /i, "") || "";
if (!validBrowserToken(token)) {
res.writeHead(401).end("Invalid realtime session token");
return true;
}
if (!upstream.startsWith("http://") && !upstream.startsWith("https://")) {
res.writeHead(503).end("Athena realtime upstream is not configured");
return true;
}
const chunks: Buffer[] = [];
let total = 0;
for await (const part of req) {
const chunk = Buffer.from(part);
total += chunk.length;
if (total > MAX_OFFER_BYTES) {
res.writeHead(413).end("WebRTC offer too large");
return true;
}
chunks.push(chunk);
}
try {
const response = await fetch(upstream, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/sdp" },
body: Uint8Array.from(Buffer.concat(chunks)),
signal: AbortSignal.timeout(30_000),
});
const answer = Buffer.from(await response.arrayBuffer());
if (answer.length > MAX_OFFER_BYTES) throw new Error("WebRTC answer too large");
res.writeHead(response.status, { "Content-Type": response.headers.get("content-type") || "text/plain" });
res.end(answer);
} catch {
res.writeHead(502).end("Athena realtime bridge unavailable");
}
return true;
}
function record(value: unknown): Record<string, any> { function record(value: unknown): Record<string, any> {
return value && typeof value === "object" && !Array.isArray(value) return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, any> ? value as Record<string, any>
@@ -45,6 +124,7 @@ function resolveConfig(req: any): Required<ProviderConfig> {
silenceDurationMs: Number(raw.silenceDurationMs ?? 750), silenceDurationMs: Number(raw.silenceDurationMs ?? 750),
prefixPaddingMs: Number(raw.prefixPaddingMs ?? 300), prefixPaddingMs: Number(raw.prefixPaddingMs ?? 300),
maxSpeechSeconds: Number(raw.maxSpeechSeconds ?? 45), maxSpeechSeconds: Number(raw.maxSpeechSeconds ?? 45),
realtimeUpstreamUrl: String(raw.realtimeUpstreamUrl || ""),
}; };
} }
@@ -338,6 +418,30 @@ export default definePluginEntry({
name: "Athena Local Talk", name: "Athena Local Talk",
description: "Private voice loop using Athena STT and TTS with the normal OpenClaw agent.", description: "Private voice loop using Athena STT and TTS with the normal OpenClaw agent.",
register(api) { register(api) {
api.registerHttpRoute({
path: BROWSER_KEY_PATH,
auth: "plugin",
match: "exact",
handler: (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "GET") {
res.writeHead(405).end("Method not allowed");
} else {
res.writeHead(200, { "Content-Type": "application/x-pem-file", "Cache-Control": "no-store" });
res.end(publicKeyPem);
}
return true;
},
});
api.registerHttpRoute({
path: BROWSER_OFFER_PATH,
auth: "plugin",
match: "exact",
handler: (req: IncomingMessage, res: ServerResponse) => {
const raw = record(record(record(api.runtime.config.current()).talk).realtime);
const provider = record(record(raw.providers)["athena-talk"]);
return handleBrowserOffer(req, res, String(provider.realtimeUpstreamUrl || ""));
},
});
api.registerRealtimeVoiceProvider({ api.registerRealtimeVoiceProvider({
id: "athena-talk", id: "athena-talk",
label: "Athena Local Talk", label: "Athena Local Talk",
@@ -346,9 +450,10 @@ export default definePluginEntry({
voices: ["alloy"], voices: ["alloy"],
autoSelectOrder: 1, autoSelectOrder: 1,
capabilities: { capabilities: {
transports: ["gateway-relay"], transports: ["gateway-relay", "webrtc"],
inputAudioFormats: [AUDIO_FORMAT], inputAudioFormats: [AUDIO_FORMAT],
outputAudioFormats: [AUDIO_FORMAT], outputAudioFormats: [AUDIO_FORMAT],
supportsBrowserSession: true,
supportsBargeIn: false, supportsBargeIn: false,
handlesInputAudioBargeIn: false, handlesInputAudioBargeIn: false,
supportsToolCalls: true, supportsToolCalls: true,
@@ -360,6 +465,28 @@ export default definePluginEntry({
return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl); return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl);
}, },
createBridge: (req: any) => new AthenaTalkBridge(req, resolveConfig(req)), createBridge: (req: any) => new AthenaTalkBridge(req, resolveConfig(req)),
async createBrowserSession(req: any) {
const config = resolveConfig(req);
if (!config.realtimeUpstreamUrl) {
throw new Error("athena-talk realtimeUpstreamUrl is required for browser Talk");
}
const { token, expiresAt } = createBrowserToken();
return {
provider: "athena-talk",
transport: "webrtc",
clientSecret: token,
offerUrl: BROWSER_OFFER_PATH,
audio: {
inputEncoding: "pcm16",
inputSampleRateHz: 24000,
outputEncoding: "pcm16",
outputSampleRateHz: 24000,
},
model: req.model || "athena-local",
voice: req.voice || config.voice,
expiresAt,
};
},
} as any); } as any);
}, },
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.0.0", "version": "1.1.0",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"openclaw": "2026.9.4", "openclaw": "2026.9.4",
@@ -1,6 +1,6 @@
{ {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"description": "Local OpenClaw Talk provider backed by Athena Whisper and Qwen3-TTS", "description": "Local OpenClaw Talk provider backed by Athena Whisper and Qwen3-TTS",
"type": "module", "type": "module",
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { test } from "node:test";
import plugin from "./dist/index.js";
async function listen(server) {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return `http://127.0.0.1:${server.address().port}`;
}
test("browser session signs a short-lived token and proxies the SDP offer", async () => {
let upstreamCalls = 0;
const upstream = createServer(async (req, res) => {
upstreamCalls += 1;
assert.equal(req.method, "POST");
assert.match(req.headers.authorization, /^Bearer [^.]+\.[^.]+$/);
assert.equal(await new Response(req).text(), "offer-sdp");
res.writeHead(200, { "Content-Type": "application/sdp" }).end("answer-sdp");
});
const upstreamBase = await listen(upstream);
let provider;
const routes = [];
plugin.register({
registerRealtimeVoiceProvider: (value) => { provider = value; },
registerHttpRoute: (value) => { routes.push(value); },
runtime: { config: { current: () => ({ talk: { realtime: { providers: {
"athena-talk": { realtimeUpstreamUrl: `${upstreamBase}/v1/realtime/calls` },
} } } }) } },
});
const route = routes.find((value) => value.path.endsWith("/calls"));
const keyRoute = routes.find((value) => value.path.endsWith("/public-key"));
assert.equal(route.path, "/plugins/athena-talk/realtime/calls");
const browser = await provider.createBrowserSession({
providerConfig: { realtimeUpstreamUrl: `${upstreamBase}/v1/realtime/calls` },
model: "athena-local",
});
assert.equal(browser.transport, "webrtc");
assert.ok(browser.expiresAt > Date.now());
const proxy = createServer((req, res) => {
if (req.url === keyRoute.path) return keyRoute.handler(req, res);
return route.handler(req, res);
});
const proxyBase = await listen(proxy);
try {
const response = await fetch(`${proxyBase}${browser.offerUrl}`, {
method: "POST",
headers: { Authorization: `Bearer ${browser.clientSecret}`, "Content-Type": "application/sdp" },
body: "offer-sdp",
});
assert.equal(response.status, 200);
assert.equal(await response.text(), "answer-sdp");
assert.equal(upstreamCalls, 1);
const key = await fetch(`${proxyBase}${keyRoute.path}`);
assert.equal(key.status, 200);
assert.match(await key.text(), /BEGIN PUBLIC KEY/);
const invalid = await fetch(`${proxyBase}${browser.offerUrl}`, {
method: "POST",
headers: { Authorization: `Bearer ${browser.clientSecret}x` },
body: "offer-sdp",
});
assert.equal(invalid.status, 401);
assert.equal(upstreamCalls, 1);
} finally {
proxy.closeAllConnections();
upstream.closeAllConnections();
await Promise.all([
new Promise((resolve) => proxy.close(resolve)),
new Promise((resolve) => upstream.close(resolve)),
]);
}
});
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PORT=8090
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && useradd --uid 10001 --create-home athenavoice
COPY server.py .
USER athenavoice
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3)" || exit 1
CMD ["python", "server.py"]
+74
View File
@@ -0,0 +1,74 @@
# Athena realtime voice bridge (experimental)
This independent service lets OpenClaw's existing browser Talk UI use Athena
Whisper, OpenClaw's agent, and Athena Qwen3-TTS through the browser's supported
OpenAI-style WebRTC transport. It does not modify OpenClaw or switch an Athena
profile. The existing `gateway-relay` path remains available.
Flow: OpenClaw's `athena-talk` plugin signs a single-use 60-second browser token;
the browser posts an SDP offer to this service; microphone audio is sent over
WebRTC; Athena transcribes it; the service asks OpenClaw to run
`openclaw_agent_consult`; Athena speaks the returned text over the same WebRTC
connection. This is a half-duplex prototype. Barge-in and remote-network TURN
support are not implemented.
Required service environment:
| Name | Purpose |
| --- | --- |
| `OPENCLAW_PUBLIC_KEY_URL` | Existing OpenClaw HTTPS origin plus `/plugins/athena-talk/realtime/public-key`. The service fetches the plugin's public verification key. |
| `ATHENA_API_BASE_URL` | Router API base, `http://router:8081/v1` in the Compose deployment. |
| `ATHENA_API_KEY` | Router key, if required. |
| `OPENCLAW_ORIGIN` | Exact HTTPS origin of the UI, e.g. `https://oc.casaderoll.de`. |
| `PORT` | HTTP listen port; default 8090. The Compose service shares the existing WireGuard gateway network namespace. |
The plugin provides an HTTPS offer route on the existing OpenClaw origin and
forwards SDP to this service. Configure
`talk.realtime.providers.athena-talk.realtimeUpstreamUrl` with Athena's internal
HTTP URL ending in `/v1/realtime/calls`. Set the service's
`OPENCLAW_PUBLIC_KEY_URL` to the OpenClaw HTTPS public-key route above. No new
secret is needed in OpenClaw: its plugin keeps a private signing key in memory,
and the service receives only the public key. Select
`talk.realtime.transport: "webrtc"` only after the service is reachable. Keep
`gateway-relay` as a rollback option.
The WebRTC media connection needs a route from the client device to Athena's
ICE candidate addresses. The service runs directly inside Athena's existing
private WireGuard network namespace, so home/VPN clients can reach it at
`192.168.1.212:8090`; the OpenClaw HTTPS proxy covers signaling only. Clients
outside the private network need TURN support, which is not implemented.
The deployed service is `mike-ai-realtime-voice` in `compose.yaml`. It uses no
GPU and does not switch Athena's active model profile. Deploy it with the
stack's normal environment:
```sh
cd /opt/mike-ai/stack
docker compose --env-file /etc/mike-ai/stack.env up -d --no-deps --build realtime-voice
```
OpenClaw 2026.9.4 uses the `athena-talk` plugin version 1.1.0 with
`talk.realtime.transport` set to `webrtc` and
`talk.realtime.providers.athena-talk.realtimeUpstreamUrl` set to
`http://192.168.1.212:8090/v1/realtime/calls`. On the Mac, turn off
“Echtzeitweiterleitung über Gateway verwenden”, which applies only to the
older `gateway-relay` mode. A Gateway restart after plugin installation was
needed to register the two new HTTPS routes. The plugin signs short-lived
session tokens with an in-memory Ed25519 key; the service fetches only its
public key. A Gateway restart rotates the key automatically.
Local isolated test (fake STT/TTS, synthetic microphone and speaker audio):
```sh
python3.11 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python -m unittest -v test_smoke.py
```
The synthetic microphone test passed over the actual OpenClaw HTTPS offer
route and WireGuard media path with production Whisper and Qwen3-TTS on
2026-09-16. It supplied a fake agent result; it does not prove that the Mac
or browser UI completes a real agent-consult turn. Keep this integration
experimental until that UI round trip has been observed.
For isolated tests, `ATHENA_TALK_REALTIME_SECRET` can replace
`OPENCLAW_PUBLIC_KEY_URL`; do not use that test mode in the deployed setup.
@@ -0,0 +1,126 @@
"""Manual Mac→Athena WebRTC check using a generated test WAV, no microphone."""
import asyncio
import base64
import hashlib
import hmac
import io
import json
import os
import sys
import time
import uuid
import wave
from fractions import Fraction
import numpy as np
from aiohttp import ClientSession
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
from av import AudioFrame
from server import FRAME_SAMPLES, SAMPLE_RATE
class RecordedMicrophone(MediaStreamTrack):
kind = "audio"
def __init__(self, path):
super().__init__()
with wave.open(path, "rb") as wav:
assert wav.getframerate() == SAMPLE_RATE and wav.getnchannels() == 1
assert wav.getsampwidth() == 2
self.audio = wav.readframes(wav.getnframes())
self.offset = 0
self.pts = 0
self.start = time.monotonic()
async def recv(self):
await asyncio.sleep(max(0, self.start + self.pts / SAMPLE_RATE - time.monotonic()))
chunk = self.audio[self.offset : self.offset + FRAME_SAMPLES * 2]
self.offset += len(chunk)
samples = np.frombuffer(chunk.ljust(FRAME_SAMPLES * 2, b"\0"), dtype="<i2")
frame = AudioFrame.from_ndarray(samples.reshape(1, FRAME_SAMPLES), format="s16", layout="mono")
frame.sample_rate = SAMPLE_RATE
frame.pts = self.pts
frame.time_base = Fraction(1, SAMPLE_RATE)
self.pts += FRAME_SAMPLES
return frame
async def main(url, wav_path):
browser_token = os.environ.get("BROWSER_SESSION_TOKEN")
if not browser_token:
secret = os.environ["ATHENA_TALK_REALTIME_SECRET"]
payload = base64.urlsafe_b64encode(json.dumps({
"exp": int(time.time()) + 60, "jti": str(uuid.uuid4()),
}).encode()).rstrip(b"=").decode()
signature = base64.urlsafe_b64encode(hmac.new(
secret.encode(), payload.encode(), hashlib.sha256,
).digest()).rstrip(b"=").decode()
browser_token = f"{payload}.{signature}"
pc = RTCPeerConnection()
pc.addTrack(RecordedMicrophone(wav_path))
channel = pc.createDataChannel("oai-events")
transcribed = asyncio.Event()
response_done = asyncio.Event()
audio_received = asyncio.Event()
transcript = ""
seen = []
@pc.on("track")
def on_track(track):
if track.kind == "audio":
async def consume():
for _ in range(9000):
frame = await track.recv()
if np.max(np.abs(frame.to_ndarray())) > 100:
audio_received.set()
return
asyncio.create_task(consume())
@channel.on("message")
def on_message(raw):
nonlocal transcript
event = json.loads(raw)
seen.append(event.get("type"))
if event.get("type") == "conversation.item.input_audio_transcription.completed":
transcript = event.get("transcript", "")
transcribed.set()
elif event.get("type") == "response.done":
output = event.get("response", {}).get("output", [])
if output and output[0].get("type") == "function_call":
channel.send(json.dumps({"type": "conversation.item.create", "item": {
"type": "function_call_output", "call_id": output[0]["call_id"],
"output": json.dumps({"result": "Hallo zurück!"}),
}}))
channel.send(json.dumps({"type": "response.create"}))
elif output and output[0].get("type") == "message":
response_done.set()
elif event.get("type") == "error":
print("bridge error:", event.get("error", {}).get("message"))
try:
await pc.setLocalDescription(await pc.createOffer())
async with ClientSession() as http:
async with http.post(url, data=pc.localDescription.sdp,
headers={"Authorization": f"Bearer {browser_token}",
"Content-Type": "application/sdp"}) as result:
if result.status != 200:
raise RuntimeError(f"SDP offer failed: HTTP {result.status}")
answer = await result.text()
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer, type="answer"))
try:
await asyncio.wait_for(transcribed.wait(), timeout=90)
await asyncio.wait_for(audio_received.wait(), timeout=90)
await asyncio.wait_for(response_done.wait(), timeout=90)
except asyncio.TimeoutError as exc:
raise RuntimeError(f"Remote voice timeout: state={pc.connectionState}, "
f"transcript={transcript!r}, events={seen}") from exc
print(json.dumps({"webRTC": pc.connectionState, "transcript": transcript,
"spokenResponse": True}, ensure_ascii=False))
finally:
await pc.close()
if __name__ == "__main__":
asyncio.run(main(sys.argv[1], sys.argv[2]))
@@ -0,0 +1,5 @@
aiohttp==3.14.3
aiortc==1.15.0
av==17.1.0
cryptography==50.0.1
numpy==2.4.6
+441
View File
@@ -0,0 +1,441 @@
"""OpenAI Realtime WebRTC subset backed by Athena STT/TTS and OpenClaw tools.
The browser's OpenAI-compatible data channel delegates reasoning to OpenClaw's
``openclaw_agent_consult`` tool. This server never calls the LLM directly.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import io
import json
import os
import time
import uuid
import wave
from fractions import Fraction
import numpy as np
from aiohttp import ClientSession, FormData, web
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
from av import AudioFrame, AudioResampler
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.serialization import load_pem_public_key
SAMPLE_RATE = 24000
FRAME_SAMPLES = 480
MAX_OFFER_BYTES = 64 * 1024
SECRET = web.AppKey("secret", str)
ORIGIN = web.AppKey("origin", str)
USED_TOKENS = web.AppKey("used_tokens", dict)
PEERS = web.AppKey("peers", set)
HTTP = web.AppKey("http", ClientSession)
PUBLIC_KEY_URL = web.AppKey("public_key_url", str)
def decode_payload(payload: str, timestamp: int) -> dict:
try:
body = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
if not isinstance(body, dict) or not isinstance(body.get("jti"), str):
raise ValueError("invalid payload")
if not timestamp < int(body["exp"]) <= timestamp + 65:
raise ValueError("expired token")
return body
except (TypeError, KeyError, ValueError, UnicodeError, json.JSONDecodeError) as exc:
raise web.HTTPUnauthorized(text="Invalid realtime session token") from exc
def decode_token(token: str, secret: str, now: int | None = None) -> dict:
"""Validate the short-lived, signed browser session without exposing a router key."""
try:
payload, signature = token.split(".", 1)
expected = base64.urlsafe_b64encode(
hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest()
).rstrip(b"=").decode()
if not hmac.compare_digest(signature, expected):
raise ValueError("invalid signature")
timestamp = int(time.time()) if now is None else now
return decode_payload(payload, timestamp)
except (TypeError, KeyError, ValueError, UnicodeError, json.JSONDecodeError) as exc:
raise web.HTTPUnauthorized(text="Invalid realtime session token") from exc
async def decode_public_key_token(token: str, http: ClientSession, url: str) -> dict:
try:
payload, signature = token.split(".", 1)
if "." in signature:
raise ValueError("invalid token")
async with http.get(url, timeout=5) as response:
response.raise_for_status()
key_pem = await response.read()
if len(key_pem) > 4096:
raise ValueError("public key too large")
public_key = load_pem_public_key(key_pem)
public_key.verify(
base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4)),
payload.encode(),
)
return decode_payload(payload, int(time.time()))
except (ValueError, InvalidSignature, TypeError) as exc:
raise web.HTTPUnauthorized(text="Invalid realtime session token") from exc
except web.HTTPException:
raise
except Exception as exc:
raise web.HTTPServiceUnavailable(text="OpenClaw public key unavailable") from exc
def pcm_rms(data: bytes) -> float:
samples = np.frombuffer(data[: len(data) // 2 * 2], dtype="<i2")
if not samples.size:
return 0.0
normalized = samples.astype(np.float32) / 32768.0
return float(np.sqrt(np.mean(normalized * normalized)))
def make_wav(data: bytes) -> bytes:
output = io.BytesIO()
with wave.open(output, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(SAMPLE_RATE)
wav.writeframes(data)
return output.getvalue()
def read_wav(data: bytes) -> bytes:
with wave.open(io.BytesIO(data), "rb") as wav:
if wav.getsampwidth() != 2:
raise ValueError("Athena TTS must return 16-bit WAV")
channels, rate = wav.getnchannels(), wav.getframerate()
samples = np.frombuffer(wav.readframes(wav.getnframes()), dtype="<i2")
if channels > 1:
samples = samples.reshape(-1, channels)[:, 0]
if rate != SAMPLE_RATE:
count = max(1, round(samples.size * SAMPLE_RATE / rate))
samples = np.interp(
np.arange(count) * rate / SAMPLE_RATE,
np.arange(samples.size),
samples,
).astype("<i2")
return samples.astype("<i2").tobytes()
class SpeechTrack(MediaStreamTrack):
kind = "audio"
def __init__(self):
super().__init__()
self._queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=2000)
self._pending = bytearray()
self._pts = 0
self._start = time.monotonic()
def enqueue(self, pcm: bytes) -> None:
for offset in range(0, len(pcm), FRAME_SAMPLES * 2):
try:
self._queue.put_nowait(pcm[offset : offset + FRAME_SAMPLES * 2])
except asyncio.QueueFull:
break
async def recv(self) -> AudioFrame:
await asyncio.sleep(max(0, self._start + self._pts / SAMPLE_RATE - time.monotonic()))
if not self._pending:
try:
self._pending.extend(self._queue.get_nowait())
except asyncio.QueueEmpty:
pass
chunk = bytes(self._pending[: FRAME_SAMPLES * 2])
del self._pending[: FRAME_SAMPLES * 2]
chunk = chunk.ljust(FRAME_SAMPLES * 2, b"\x00")
frame = AudioFrame.from_ndarray(
np.frombuffer(chunk, dtype="<i2").reshape(1, FRAME_SAMPLES),
format="s16", layout="mono",
)
frame.sample_rate = SAMPLE_RATE
frame.pts = self._pts
frame.time_base = Fraction(1, SAMPLE_RATE)
self._pts += FRAME_SAMPLES
return frame
class RealtimeSession:
def __init__(self, pc: RTCPeerConnection, http: ClientSession, track: SpeechTrack):
self.pc, self.http, self.track = pc, http, track
self.channel = None
self.busy = False
self.speaking = False
self.speech = bytearray()
self.silence_since: float | None = None
self.call_id: str | None = None
self.pending_reply: str | None = None
self.turn_task: asyncio.Task | None = None
self.vad_threshold = float(os.environ.get("VAD_THRESHOLD", "0.018"))
self.silence_ms = int(os.environ.get("SILENCE_MS", "750"))
def emit(self, event: dict) -> None:
if self.channel and self.channel.readyState == "open":
self.channel.send(json.dumps(event, ensure_ascii=False))
def on_message(self, message: str | bytes) -> None:
if not isinstance(message, str) or len(message) > 262144:
return
try:
event = json.loads(message)
except json.JSONDecodeError:
return
kind = event.get("type")
if kind == "session.update":
self.emit({"type": "session.updated", "session": {"id": "athena-local"}})
elif kind == "conversation.item.create":
item = event.get("item") or {}
if item.get("type") == "function_call_output" and item.get("call_id") == self.call_id:
try:
answer = json.loads(item.get("output") or "{}")
self.pending_reply = str(answer.get("result") or answer.get("error") or "")
except (ValueError, TypeError):
self.pending_reply = "Die Antwort konnte nicht gelesen werden."
elif item.get("type") == "message" and item.get("role") == "user":
text = " ".join(str(part.get("text") or "") for part in item.get("content", []))
if text.strip() and not self.busy:
self.turn_task = asyncio.create_task(self.consult(text.strip()))
elif kind == "response.create" and self.pending_reply is not None:
text, self.pending_reply = self.pending_reply, None
self.turn_task = asyncio.create_task(self.speak(text))
elif kind == "response.cancel":
if self.turn_task:
self.turn_task.cancel()
async def consume_audio(self, remote: MediaStreamTrack) -> None:
resampler = AudioResampler(format="s16", layout="mono", rate=SAMPLE_RATE)
try:
while True:
frame = await remote.recv()
for converted in resampler.resample(frame):
pcm = bytes(converted.planes[0])[: converted.samples * 2]
await self.on_audio(pcm)
except asyncio.CancelledError:
raise
except Exception:
return
async def on_audio(self, pcm: bytes) -> None:
if self.busy or not pcm:
return
voiced = pcm_rms(pcm) >= self.vad_threshold
now = time.monotonic()
if voiced and not self.speaking:
self.speaking = True
self.speech.clear()
self.silence_since = None
self.emit({"type": "input_audio_buffer.speech_started"})
if not self.speaking:
return
self.speech.extend(pcm)
if voiced:
self.silence_since = None
elif self.silence_since is None:
self.silence_since = now
elapsed_silence = (now - self.silence_since) * 1000 if self.silence_since else 0
if elapsed_silence >= self.silence_ms or len(self.speech) >= SAMPLE_RATE * 2 * 45:
self.speaking = False
self.emit({"type": "input_audio_buffer.speech_stopped"})
audio = bytes(self.speech)
self.speech.clear()
if len(audio) >= SAMPLE_RATE * 2 // 4:
self.busy = True
self.turn_task = asyncio.create_task(self.transcribe_and_consult(audio))
async def transcribe_and_consult(self, pcm: bytes) -> None:
try:
form = FormData()
form.add_field("file", make_wav(pcm), filename="talk.wav", content_type="audio/wav")
form.add_field("model", "whisper-1")
form.add_field("language", os.environ.get("STT_LANGUAGE", "de"))
async with self.http.post(
os.environ["ATHENA_API_BASE_URL"].rstrip("/") + "/audio/transcriptions",
headers=self.auth_headers(), data=form, timeout=90,
) as response:
response.raise_for_status()
text = str((await response.json()).get("text") or "").strip()
if not text:
self.busy = False
return
self.emit({"type": "conversation.item.input_audio_transcription.completed",
"item_id": f"item_{uuid.uuid4().hex}", "transcript": text})
await self.consult(text)
except asyncio.CancelledError:
self.busy = False
raise
except Exception:
self.busy = False
self.emit({"type": "error", "error": {"message": "Athena STT failed"}})
async def consult(self, text: str) -> None:
self.busy = True
self.call_id = f"call_{uuid.uuid4().hex}"
response_id = f"resp_{uuid.uuid4().hex}"
self.emit({"type": "response.created", "response": {"id": response_id}})
self.emit({"type": "response.done", "response": {
"id": response_id, "status": "completed", "output": [{
"id": f"item_{uuid.uuid4().hex}", "type": "function_call",
"status": "completed", "call_id": self.call_id,
"name": "openclaw_agent_consult",
"arguments": json.dumps({"prompt": text}, ensure_ascii=False),
}],
}})
async def speak(self, text: str) -> None:
if not text.strip():
self.busy = False
return
response_id = f"resp_{uuid.uuid4().hex}"
try:
self.emit({"type": "response.created", "response": {"id": response_id}})
async with self.http.post(
os.environ["ATHENA_API_BASE_URL"].rstrip("/") + "/audio/speech",
headers={"Content-Type": "application/json", **self.auth_headers()},
json={"input": text, "voice": os.environ.get("TTS_VOICE", "alloy"),
"response_format": "wav"}, timeout=120,
) as response:
response.raise_for_status()
pcm = read_wav(await response.read())
self.track.enqueue(pcm)
self.emit({"type": "response.output_audio_transcript.done", "transcript": text})
# Keep half-duplex active until the queued audio has actually played.
await asyncio.sleep(len(pcm) / (SAMPLE_RATE * 2) + 0.2)
self.emit({"type": "response.done", "response": {
"id": response_id, "status": "completed", "output": [
{"id": f"item_{uuid.uuid4().hex}", "type": "message", "role": "assistant", "status": "completed"}
],
}})
except asyncio.CancelledError:
self.emit({"type": "response.cancelled", "response": {"id": response_id}})
raise
except Exception:
self.emit({"type": "error", "error": {"message": "Athena TTS failed"}})
finally:
self.busy = False
@staticmethod
def auth_headers() -> dict[str, str]:
key = os.environ.get("ATHENA_API_KEY", "")
return {"Authorization": f"Bearer {key}"} if key else {}
async def offer(request: web.Request) -> web.Response:
if request.headers.get("Origin") not in (None, request.app[ORIGIN]):
raise web.HTTPForbidden(text="Origin not allowed")
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
if request.app[PUBLIC_KEY_URL]:
claims = await decode_public_key_token(token, request.app[HTTP], request.app[PUBLIC_KEY_URL])
else:
claims = decode_token(token, request.app[SECRET])
used = request.app[USED_TOKENS]
now = time.time()
for key, expiry in list(used.items()):
if expiry < now:
del used[key]
if claims["jti"] in used:
raise web.HTTPUnauthorized(text="Realtime token already used")
if len(request.app[PEERS]) >= int(os.environ.get("MAX_SESSIONS", "4")):
raise web.HTTPServiceUnavailable(text="Too many realtime sessions")
used[claims["jti"]] = claims["exp"]
if request.content_length is not None and request.content_length > MAX_OFFER_BYTES:
raise web.HTTPRequestEntityTooLarge(max_size=MAX_OFFER_BYTES, actual_size=request.content_length)
sdp = await request.text()
if len(sdp.encode()) > MAX_OFFER_BYTES:
raise web.HTTPRequestEntityTooLarge(max_size=MAX_OFFER_BYTES, actual_size=len(sdp))
pc = RTCPeerConnection()
speech = SpeechTrack()
pc.addTrack(speech)
session = RealtimeSession(pc, request.app[HTTP], speech)
request.app[PEERS].add(pc)
@pc.on("datachannel")
def on_channel(channel):
session.channel = channel
@channel.on("open")
def on_open():
session.emit({"type": "session.created", "session": {"id": "athena-local"}})
@channel.on("message")
def on_message(message):
session.on_message(message)
@pc.on("track")
def on_track(track):
if track.kind == "audio":
asyncio.create_task(session.consume_audio(track))
@pc.on("connectionstatechange")
async def on_state_change():
if pc.connectionState in {"closed", "failed"}:
if session.turn_task:
session.turn_task.cancel()
await pc.close()
request.app[PEERS].discard(pc)
try:
await pc.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="offer"))
await pc.setLocalDescription(await pc.createAnswer())
return web.Response(text=pc.localDescription.sdp, content_type="application/sdp",
headers={"Access-Control-Allow-Origin": request.app[ORIGIN]})
except Exception:
await pc.close()
request.app[PEERS].discard(pc)
raise web.HTTPBadRequest(text="Invalid WebRTC offer")
async def preflight(request: web.Request) -> web.Response:
if request.headers.get("Origin") != request.app[ORIGIN]:
raise web.HTTPForbidden(text="Origin not allowed")
return web.Response(headers={
"Access-Control-Allow-Origin": request.app[ORIGIN],
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "600",
})
async def lifecycle(app: web.Application):
app[HTTP] = ClientSession()
yield
await asyncio.gather(*(pc.close() for pc in app[PEERS]))
await app[HTTP].close()
async def health(_: web.Request) -> web.Response:
return web.json_response({"ok": True})
def create_app() -> web.Application:
secret = os.environ.get("ATHENA_TALK_REALTIME_SECRET", "")
public_key_url = os.environ.get("OPENCLAW_PUBLIC_KEY_URL", "")
if public_key_url and not public_key_url.startswith("https://"):
raise RuntimeError("OPENCLAW_PUBLIC_KEY_URL must use HTTPS")
if not public_key_url and len(secret) < 32:
raise RuntimeError("Set OPENCLAW_PUBLIC_KEY_URL or a 32+ character secret")
if not os.environ.get("ATHENA_API_BASE_URL"):
raise RuntimeError("ATHENA_API_BASE_URL is required")
origin = os.environ.get("OPENCLAW_ORIGIN", "")
if not origin.startswith("https://"):
raise RuntimeError("OPENCLAW_ORIGIN must be an HTTPS origin")
app = web.Application(client_max_size=MAX_OFFER_BYTES)
app[SECRET] = secret
app[PUBLIC_KEY_URL] = public_key_url
app[ORIGIN] = origin
app[USED_TOKENS] = {}
app[PEERS] = set()
app.cleanup_ctx.append(lifecycle)
app.router.add_route("OPTIONS", "/v1/realtime/calls", preflight)
app.router.add_post("/v1/realtime/calls", offer)
app.router.add_get("/health", health)
return app
if __name__ == "__main__":
web.run_app(create_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "8090")))
@@ -0,0 +1,175 @@
"""Local end-to-end WebRTC smoke test with fake Athena STT and TTS endpoints."""
import asyncio
import base64
import hashlib
import hmac
import json
import os
import time
import unittest
import uuid
from fractions import Fraction
import numpy as np
from aiohttp import ClientSession, web
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
from av import AudioFrame
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from server import FRAME_SAMPLES, SAMPLE_RATE, create_app, decode_public_key_token, make_wav
class Microphone(MediaStreamTrack):
kind = "audio"
def __init__(self):
super().__init__()
self.count = 0
self.start = time.monotonic()
async def recv(self):
await asyncio.sleep(max(0, self.start + self.count * .02 - time.monotonic()))
# 0.6 s speech followed by silence to trigger server VAD.
if 4 <= self.count < 44:
index = np.arange(FRAME_SAMPLES) + self.count * FRAME_SAMPLES
samples = (8000 * np.sin(2 * np.pi * 440 * index / SAMPLE_RATE)).astype("<i2")
else:
samples = np.zeros(FRAME_SAMPLES, dtype="<i2")
samples = samples.reshape(1, FRAME_SAMPLES)
frame = AudioFrame.from_ndarray(samples, format="s16", layout="mono")
frame.sample_rate = SAMPLE_RATE
frame.pts = self.count * FRAME_SAMPLES
frame.time_base = Fraction(1, SAMPLE_RATE)
self.count += 1
return frame
async def serve(app):
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
return runner, f"http://127.0.0.1:{port}"
class VoiceSmokeTest(unittest.IsolatedAsyncioTestCase):
async def test_public_key_session_token(self):
private = Ed25519PrivateKey.generate()
pem = private.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
key_server = web.Application()
async def key_handler(_):
return web.Response(body=pem)
key_server.router.add_get("/key", key_handler)
runner, base = await serve(key_server)
payload = base64.urlsafe_b64encode(json.dumps({
"exp": int(time.time()) + 60, "jti": str(uuid.uuid4()),
}).encode()).rstrip(b"=").decode()
signature = base64.urlsafe_b64encode(private.sign(payload.encode())).rstrip(b"=").decode()
try:
async with ClientSession() as http:
claims = await decode_public_key_token(f"{payload}.{signature}", http, base + "/key")
self.assertIn("jti", claims)
with self.assertRaises(web.HTTPUnauthorized):
await decode_public_key_token(f"{payload}.{signature}x", http, base + "/key")
finally:
await runner.cleanup()
async def test_browser_session_and_audio_round_trip(self):
async def stt(request):
data = await request.post()
self.assertIn("file", data)
return web.json_response({"text": "Hallo Athena"})
async def tts(request):
body = await request.json()
self.assertEqual(body["input"], "Hallo zurück")
return web.Response(body=make_wav((6000).to_bytes(2, "little", signed=True) * SAMPLE_RATE),
content_type="audio/wav")
fake = web.Application()
fake.router.add_post("/audio/transcriptions", stt)
fake.router.add_post("/audio/speech", tts)
fake_runner, fake_url = await serve(fake)
os.environ.update({
"ATHENA_TALK_REALTIME_SECRET": "test-secret-" * 4,
"ATHENA_API_BASE_URL": fake_url,
"OPENCLAW_ORIGIN": "https://oc.example.test",
"SILENCE_MS": "200",
})
voice_runner, voice_url = await serve(create_app())
pc = RTCPeerConnection()
events = []
done = asyncio.Event()
audio_received = asyncio.Event()
channel = pc.createDataChannel("oai-events")
pc.addTrack(Microphone())
@pc.on("track")
def on_track(track):
if track.kind == "audio":
async def consume():
for _ in range(150):
frame = await track.recv()
if np.max(np.abs(frame.to_ndarray())) > 100:
audio_received.set()
return
asyncio.create_task(consume())
@channel.on("open")
def on_open():
channel.send(json.dumps({"type": "session.update", "session": {}}))
@channel.on("message")
def on_message(raw):
event = json.loads(raw)
events.append(event)
if event.get("type") == "response.done":
output = event.get("response", {}).get("output", [])
if output and output[0].get("type") == "function_call":
call = output[0]
channel.send(json.dumps({"type": "conversation.item.create", "item": {
"type": "function_call_output", "call_id": call["call_id"],
"output": json.dumps({"result": "Hallo zurück"}),
}}))
channel.send(json.dumps({"type": "response.create"}))
elif output and output[0].get("type") == "message":
done.set()
try:
await pc.setLocalDescription(await pc.createOffer())
payload = base64.urlsafe_b64encode(json.dumps({
"exp": int(time.time()) + 60, "jti": str(uuid.uuid4()),
}).encode()).rstrip(b"=").decode()
signature = base64.urlsafe_b64encode(hmac.new(
os.environ["ATHENA_TALK_REALTIME_SECRET"].encode(),
payload.encode(), hashlib.sha256,
).digest()).rstrip(b"=").decode()
async with ClientSession() as http:
async with http.post(voice_url + "/v1/realtime/calls",
data=pc.localDescription.sdp,
headers={"Authorization": f"Bearer {payload}.{signature}",
"Content-Type": "application/sdp"}) as response:
self.assertEqual(response.status, 200, await response.text())
answer = await response.text()
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer, type="answer"))
try:
await asyncio.wait_for(done.wait(), timeout=20)
except asyncio.TimeoutError:
self.fail(f"No completed voice response; events: {[e.get('type') for e in events]}")
await asyncio.wait_for(audio_received.wait(), timeout=5)
self.assertTrue(any(event.get("type") ==
"conversation.item.input_audio_transcription.completed"
for event in events))
self.assertTrue(any(event.get("type") ==
"response.output_audio_transcript.done" for event in events))
finally:
await pc.close()
await voice_runner.cleanup()
await fake_runner.cleanup()
if __name__ == "__main__":
unittest.main()