diff --git a/compose.yaml b/compose.yaml index b291b88..1832427 100644 --- a/compose.yaml +++ b/compose.yaml @@ -65,6 +65,29 @@ services: retries: 12 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-common container_name: mike-ai-llama-fast diff --git a/docs/CONTAINER_INVENTORY.md b/docs/CONTAINER_INVENTORY.md index 79ed809..23b3e0d 100644 --- a/docs/CONTAINER_INVENTORY.md +++ b/docs/CONTAINER_INVENTORY.md @@ -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-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-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-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. | diff --git a/integrations/openclaw-athena-talk/README.md b/integrations/openclaw-athena-talk/README.md index a7957e7..7985e8b 100644 --- a/integrations/openclaw-athena-talk/README.md +++ b/integrations/openclaw-athena-talk/README.md @@ -1,7 +1,8 @@ # Athena Local Talk for OpenClaw -This private OpenClaw provider connects browser/Desktop Talk to the existing -Athena speech stack: +This private OpenClaw provider connects Gateway-relay Talk sessions to the +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, 2. Athena Whisper transcribes it, @@ -9,8 +10,8 @@ Athena speech stack: tools, 4. Athena Qwen3-TTS returns PCM audio to the Talk client. -Long replies are synthesized incrementally. The first short phrase starts -playing as soon as it is ready while the next phrase is generated in parallel. +The older `gateway-relay` path synthesizes long replies incrementally. The +experimental WebRTC path currently synthesizes each agent reply as one clip. The provider intentionally uses half-duplex audio: microphone input is paused while a response is being transcribed, generated, synthesized, or played. This @@ -30,11 +31,12 @@ Recommended `talk.realtime` configuration: "model": "athena-local", "speakerVoice": "alloy", "mode": "realtime", - "transport": "gateway-relay", + "transport": "webrtc", "brain": "agent-consult", "providers": { "athena-talk": { "modelProvider": "llama-cpp", + "realtimeUpstreamUrl": "http://192.168.1.212:8090/v1/realtime/calls", "language": "de", "vadThreshold": 0.018, "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 reload. The managed plugin copy is stored in OpenClaw's persistent data 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. diff --git a/integrations/openclaw-athena-talk/dist/index.js b/integrations/openclaw-athena-talk/dist/index.js index f4b5d3a..53aea33 100644 --- a/integrations/openclaw-athena-talk/dist/index.js +++ b/integrations/openclaw-athena-talk/dist/index.js @@ -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"; 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) { return value && typeof value === "object" && !Array.isArray(value) ? value @@ -31,6 +105,7 @@ function resolveConfig(req) { silenceDurationMs: Number(raw.silenceDurationMs ?? 750), prefixPaddingMs: Number(raw.prefixPaddingMs ?? 300), maxSpeechSeconds: Number(raw.maxSpeechSeconds ?? 45), + realtimeUpstreamUrl: String(raw.realtimeUpstreamUrl || ""), }; } function wavFromPcm16(pcm, sampleRate = 24000) { @@ -337,6 +412,31 @@ export default definePluginEntry({ name: "Athena Local Talk", description: "Private voice loop using Athena STT and TTS with the normal OpenClaw agent.", 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({ id: "athena-talk", label: "Athena Local Talk", @@ -345,9 +445,10 @@ export default definePluginEntry({ voices: ["alloy"], autoSelectOrder: 1, capabilities: { - transports: ["gateway-relay"], + transports: ["gateway-relay", "webrtc"], inputAudioFormats: [AUDIO_FORMAT], outputAudioFormats: [AUDIO_FORMAT], + supportsBrowserSession: true, supportsBargeIn: false, handlesInputAudioBargeIn: false, supportsToolCalls: true, @@ -359,6 +460,28 @@ export default definePluginEntry({ return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl); }, 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, + }; + }, }); }, }); diff --git a/integrations/openclaw-athena-talk/index.ts b/integrations/openclaw-athena-talk/index.ts index 2276d2d..8338771 100644 --- a/integrations/openclaw-athena-talk/index.ts +++ b/integrations/openclaw-athena-talk/index.ts @@ -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"; const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 } as const; @@ -13,8 +14,86 @@ type ProviderConfig = { silenceDurationMs?: number; prefixPaddingMs?: 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 { + 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 { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -45,6 +124,7 @@ function resolveConfig(req: any): Required { silenceDurationMs: Number(raw.silenceDurationMs ?? 750), prefixPaddingMs: Number(raw.prefixPaddingMs ?? 300), maxSpeechSeconds: Number(raw.maxSpeechSeconds ?? 45), + realtimeUpstreamUrl: String(raw.realtimeUpstreamUrl || ""), }; } @@ -338,6 +418,30 @@ export default definePluginEntry({ name: "Athena Local Talk", description: "Private voice loop using Athena STT and TTS with the normal OpenClaw agent.", 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({ id: "athena-talk", label: "Athena Local Talk", @@ -346,9 +450,10 @@ export default definePluginEntry({ voices: ["alloy"], autoSelectOrder: 1, capabilities: { - transports: ["gateway-relay"], + transports: ["gateway-relay", "webrtc"], inputAudioFormats: [AUDIO_FORMAT], outputAudioFormats: [AUDIO_FORMAT], + supportsBrowserSession: true, supportsBargeIn: false, handlesInputAudioBargeIn: false, supportsToolCalls: true, @@ -360,6 +465,28 @@ export default definePluginEntry({ return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl); }, 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); }, }); diff --git a/integrations/openclaw-athena-talk/package-lock.json b/integrations/openclaw-athena-talk/package-lock.json index dad547e..4a17a69 100644 --- a/integrations/openclaw-athena-talk/package-lock.json +++ b/integrations/openclaw-athena-talk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@casaderoll/openclaw-athena-talk", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@casaderoll/openclaw-athena-talk", - "version": "1.0.0", + "version": "1.1.0", "devDependencies": { "@types/node": "^24.0.0", "openclaw": "2026.9.4", diff --git a/integrations/openclaw-athena-talk/package.json b/integrations/openclaw-athena-talk/package.json index d821cb2..df9f660 100644 --- a/integrations/openclaw-athena-talk/package.json +++ b/integrations/openclaw-athena-talk/package.json @@ -1,6 +1,6 @@ { "name": "@casaderoll/openclaw-athena-talk", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "Local OpenClaw Talk provider backed by Athena Whisper and Qwen3-TTS", "type": "module", diff --git a/integrations/openclaw-athena-talk/test-browser.mjs b/integrations/openclaw-athena-talk/test-browser.mjs new file mode 100644 index 0000000..7c304a8 --- /dev/null +++ b/integrations/openclaw-athena-talk/test-browser.mjs @@ -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)), + ]); + } +}); diff --git a/services/athena-realtime-voice/Dockerfile b/services/athena-realtime-voice/Dockerfile new file mode 100644 index 0000000..35eb8a2 --- /dev/null +++ b/services/athena-realtime-voice/Dockerfile @@ -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"] diff --git a/services/athena-realtime-voice/README.md b/services/athena-realtime-voice/README.md new file mode 100644 index 0000000..5fffbd6 --- /dev/null +++ b/services/athena-realtime-voice/README.md @@ -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. diff --git a/services/athena-realtime-voice/manual_remote_smoke.py b/services/athena-realtime-voice/manual_remote_smoke.py new file mode 100644 index 0000000..e8bfc2a --- /dev/null +++ b/services/athena-realtime-voice/manual_remote_smoke.py @@ -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=" 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])) diff --git a/services/athena-realtime-voice/requirements.txt b/services/athena-realtime-voice/requirements.txt new file mode 100644 index 0000000..1b74b77 --- /dev/null +++ b/services/athena-realtime-voice/requirements.txt @@ -0,0 +1,5 @@ +aiohttp==3.14.3 +aiortc==1.15.0 +av==17.1.0 +cryptography==50.0.1 +numpy==2.4.6 diff --git a/services/athena-realtime-voice/server.py b/services/athena-realtime-voice/server.py new file mode 100644 index 0000000..e67ceb6 --- /dev/null +++ b/services/athena-realtime-voice/server.py @@ -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=" 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=" 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(" 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=" 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"))) diff --git a/services/athena-realtime-voice/test_smoke.py b/services/athena-realtime-voice/test_smoke.py new file mode 100644 index 0000000..80ea9ce --- /dev/null +++ b/services/athena-realtime-voice/test_smoke.py @@ -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(" 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()