diff --git a/README.md b/README.md index 6226483..9c33c14 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,20 @@ Provider and model allowlists are tracked explicitly. Secrets, payment credentia - [Migration manifest](docs/migration/manifest.md) - [Full execution plan](docs/migration/execution-plan.md) +## Local realtime voice + +The optional voice stack adds a browser voice surface, short-lived WebSocket ticket gateway, and a local Hugging Face speech pipeline while preserving the AI Gateway as the single LLM policy boundary. + +```text +Browser -> apps/zvoice -> services/voice-gateway -> services/voice-agent + -> services/ai-gateway + -> Ollama / llama.cpp / vLLM +``` + +- [Voice architecture](docs/architecture/voice-agent.md) +- [Voice operations runbook](docs/operations/voice-agent.md) +- [Voice Compose overlay](compose.voice.yml) + ## Security No secrets, payment credentials, wallet keys, MPC shares, Cloudflare tokens, or provider API keys may be committed. See [SECURITY.md](SECURITY.md). diff --git a/apps/zvoice/Dockerfile b/apps/zvoice/Dockerfile new file mode 100644 index 0000000..3b4afc4 --- /dev/null +++ b/apps/zvoice/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22.16.0-alpine3.22 + +ARG OCI_REVISION=unknown +ARG OCI_SOURCE=https://github.com/cvsz/z-platform +ARG OCI_CREATED=unknown +ARG OCI_VERSION=dev + +LABEL org.opencontainers.image.revision="${OCI_REVISION}" \ + org.opencontainers.image.source="${OCI_SOURCE}" \ + org.opencontainers.image.created="${OCI_CREATED}" \ + org.opencontainers.image.version="${OCI_VERSION}" + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3022 + +WORKDIR /app +COPY --chown=node:node server.mjs ./server.mjs +COPY --chown=node:node public ./public + +USER node +EXPOSE 3022 +CMD ["node", "server.mjs"] diff --git a/apps/zvoice/README.md b/apps/zvoice/README.md new file mode 100644 index 0000000..fc9a4e7 --- /dev/null +++ b/apps/zvoice/README.md @@ -0,0 +1,7 @@ +# ZVoice + +`apps/zvoice` is the browser voice surface for the Z Platform realtime voice-agent stack. + +It requests short-lived signed WebSocket tickets from `services/voice-gateway`, captures microphone audio with an AudioWorklet, sends 16 kHz PCM16 events using the OpenAI Realtime protocol, plays streaming response audio, and stops queued playback when the user interrupts. + +The app never exposes `Z_PLATFORM_SERVICE_TOKEN` or provider keys to the browser. In production, place it behind Cloudflare Access or another approved identity boundary and set `ZVOICE_ALLOW_ANONYMOUS=false`. diff --git a/apps/zvoice/public/app.js b/apps/zvoice/public/app.js new file mode 100644 index 0000000..2aa9a7b --- /dev/null +++ b/apps/zvoice/public/app.js @@ -0,0 +1,288 @@ +const startButton = document.querySelector("#start"); +const muteButton = document.querySelector("#mute"); +const stopButton = document.querySelector("#stop"); +const cancelButton = document.querySelector("#cancel"); +const clearButton = document.querySelector("#clear"); +const stateBadge = document.querySelector("#voice-state"); +const emptyState = document.querySelector("#empty"); +const voiceStatus = document.querySelector("#status"); +const voiceTranscript = document.querySelector("#transcript"); +const systemPrompt = document.querySelector("#instructions"); +const modelField = document.querySelector("#model"); + +// The upstream realtime pipeline accepts native mono PCM16 at 16 kHz. +const PIPELINE_SAMPLE_RATE = 16000; +let socket = null; +let audioContext = null; +let mediaStream = null; +let captureNode = null; +let muted = false; +let sessionConfigured = false; +let playhead = 0; +let activeSources = new Set(); + +function setVoiceStatus(message, tone = "idle") { + voiceStatus.textContent = message; + voiceStatus.dataset.tone = tone; + stateBadge.textContent = message; + stateBadge.dataset.tone = tone; +} + +function appendTranscript(role, text) { + if (!text) return; + const item = document.createElement("li"); + const label = document.createElement("strong"); + label.textContent = role === "assistant" ? "AI: " : "You: "; + item.append(label, document.createTextNode(text)); + item.dataset.role = role; + voiceTranscript.append(item); + emptyState.hidden = true; + voiceTranscript.scrollTop = voiceTranscript.scrollHeight; +} + +function bytesToBase64(bytes) { + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +} + +function base64ToBytes(value) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function resampleToPcm16(input, inputRate, outputRate) { + if (!input.length) return new Uint8Array(); + const ratio = inputRate / outputRate; + const outputLength = Math.max(1, Math.floor(input.length / ratio)); + const output = new ArrayBuffer(outputLength * 2); + const view = new DataView(output); + + for (let index = 0; index < outputLength; index += 1) { + const position = index * ratio; + const leftIndex = Math.floor(position); + const rightIndex = Math.min(input.length - 1, leftIndex + 1); + const fraction = position - leftIndex; + const sample = input[leftIndex] * (1 - fraction) + input[rightIndex] * fraction; + const clamped = Math.max(-1, Math.min(1, sample)); + view.setInt16(index * 2, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true); + } + return new Uint8Array(output); +} + +function pcm16ToFloat32(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const samples = new Float32Array(Math.floor(bytes.byteLength / 2)); + for (let index = 0; index < samples.length; index += 1) { + const value = view.getInt16(index * 2, true); + samples[index] = value < 0 ? value / 0x8000 : value / 0x7fff; + } + return samples; +} + +function stopPlayback() { + for (const source of activeSources) { + try { + source.stop(); + } catch { + // The source may already have ended. + } + } + activeSources = new Set(); + playhead = audioContext?.currentTime || 0; +} + +function queueAudio(base64Audio) { + if (!audioContext || !base64Audio) return; + const samples = pcm16ToFloat32(base64ToBytes(base64Audio)); + if (!samples.length) return; + const buffer = audioContext.createBuffer(1, samples.length, PIPELINE_SAMPLE_RATE); + buffer.copyToChannel(samples, 0); + + const source = audioContext.createBufferSource(); + source.buffer = buffer; + source.connect(audioContext.destination); + const startAt = Math.max(audioContext.currentTime + 0.02, playhead); + source.start(startAt); + playhead = startAt + buffer.duration; + activeSources.add(source); + source.addEventListener("ended", () => activeSources.delete(source), { once: true }); +} + +function sendSessionUpdate(instructions) { + socket.send(JSON.stringify({ + type: "session.update", + session: { + type: "realtime", + instructions, + }, + })); + sessionConfigured = true; +} + +function handleRealtimeEvent(event) { + let payload; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + + switch (payload.type) { + case "session.created": + sendSessionUpdate(systemPrompt?.value || "You are a concise, helpful voice assistant."); + setVoiceStatus("Connected — speak naturally", "ready"); + break; + case "input_audio_buffer.speech_started": + stopPlayback(); + setVoiceStatus("Listening…", "busy"); + break; + case "input_audio_buffer.speech_stopped": + setVoiceStatus("Thinking…", "busy"); + break; + case "conversation.item.input_audio_transcription.completed": + appendTranscript("user", payload.transcript || payload.text || ""); + break; + case "response.audio.delta": + case "response.output_audio.delta": + queueAudio(payload.delta); + setVoiceStatus("Speaking…", "busy"); + break; + case "response.audio_transcript.done": + case "response.output_audio_transcript.done": + appendTranscript("assistant", payload.transcript || payload.text || ""); + break; + case "response.done": + setVoiceStatus("Connected — speak naturally", "ready"); + break; + case "error": + setVoiceStatus(payload.error?.message || "Voice session error", "error"); + break; + default: + break; + } +} + +async function requestVoiceSession() { + const response = await fetch("/api/voice/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instructions: systemPrompt?.value || "You are a concise, helpful voice assistant.", + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error?.message || payload.error || "Unable to start voice session"); + } + return payload; +} + +async function startCapture() { + audioContext = new AudioContext({ latencyHint: "interactive" }); + await audioContext.audioWorklet.addModule("/voice-worklet.js"); + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + channelCount: 1, + }, + }); + + const source = audioContext.createMediaStreamSource(mediaStream); + captureNode = new AudioWorkletNode(audioContext, "z-platform-voice-capture"); + captureNode.port.onmessage = ({ data }) => { + if (muted || !sessionConfigured || socket?.readyState !== WebSocket.OPEN) return; + const bytes = resampleToPcm16(data, audioContext.sampleRate, PIPELINE_SAMPLE_RATE); + if (!bytes.length) return; + socket.send(JSON.stringify({ + type: "input_audio_buffer.append", + audio: bytesToBase64(bytes), + })); + }; + source.connect(captureNode); + const silent = audioContext.createGain(); + silent.gain.value = 0; + captureNode.connect(silent).connect(audioContext.destination); +} + +async function startVoice() { + startButton.disabled = true; + sessionConfigured = false; + setVoiceStatus("Requesting secure voice ticket…", "busy"); + try { + const session = await requestVoiceSession(); + if (modelField) modelField.value = session.model || modelField.value; + await startCapture(); + + socket = new WebSocket(session.websocket_url, [`zticket.${session.ticket}`]); + socket.addEventListener("message", handleRealtimeEvent); + socket.addEventListener("open", () => { + muteButton.disabled = false; + cancelButton.disabled = false; + stopButton.disabled = false; + setVoiceStatus("Configuring realtime session…", "busy"); + }); + socket.addEventListener("close", () => { + setVoiceStatus("Disconnected", "idle"); + void stopVoice(); + }, { once: true }); + socket.addEventListener("error", () => { + setVoiceStatus("Unable to connect to the voice gateway", "error"); + }); + } catch (error) { + setVoiceStatus(error instanceof Error ? error.message : "Unable to start voice", "error"); + await stopVoice(); + } +} + +async function stopVoice() { + sessionConfigured = false; + if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "client_stop"); + socket = null; + stopPlayback(); + captureNode?.disconnect(); + captureNode = null; + for (const track of mediaStream?.getTracks() || []) track.stop(); + mediaStream = null; + if (audioContext && audioContext.state !== "closed") await audioContext.close(); + audioContext = null; + muted = false; + startButton.disabled = false; + muteButton.disabled = true; + muteButton.textContent = "Mute"; + cancelButton.disabled = true; + stopButton.disabled = true; +} + +startButton?.addEventListener("click", () => void startVoice()); +stopButton?.addEventListener("click", () => void stopVoice()); +muteButton?.addEventListener("click", () => { + muted = !muted; + muteButton.textContent = muted ? "Unmute" : "Mute"; + setVoiceStatus(muted ? "Microphone muted" : "Connected — speak naturally", muted ? "idle" : "ready"); +}); + +cancelButton?.addEventListener("click", () => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "response.cancel" })); + stopPlayback(); + setVoiceStatus("Response cancelled", "ready"); + } +}); + +clearButton?.addEventListener("click", () => { + voiceTranscript.replaceChildren(); + emptyState.hidden = false; +}); + +window.addEventListener("beforeunload", () => { + if (socket && socket.readyState < WebSocket.CLOSING) socket.close(); + for (const track of mediaStream?.getTracks() || []) track.stop(); +}); diff --git a/apps/zvoice/public/index.html b/apps/zvoice/public/index.html new file mode 100644 index 0000000..226b1ed --- /dev/null +++ b/apps/zvoice/public/index.html @@ -0,0 +1,65 @@ + + + + + + + ZVoice — Local Realtime Voice Agent + + + +
+
+
+

Z Platform · Local Voice

+

Realtime voice agent

+

Local VAD, speech recognition and speech synthesis, with LLM traffic governed by the Z Platform AI Gateway.

+
+
Audio is streamed to your configured local runtime. Browser credentials never include provider or platform service keys.
+
+ +
+
+
+

Voice session

+

Use headphones for the best interruption and echo-cancellation behavior.

+
+ Disconnected +
+ +
+ + +
+ +
+ + + + +
+ +

Ready to request microphone access.

+
+ +
+
+
+

Live transcript

+

Transcripts remain in this page and are cleared when it reloads.

+
+ +
+
    +
    Start a session and speak naturally.
    +
    +
    + + + diff --git a/apps/zvoice/public/styles.css b/apps/zvoice/public/styles.css new file mode 100644 index 0000000..7dc793b --- /dev/null +++ b/apps/zvoice/public/styles.css @@ -0,0 +1,223 @@ +:root { + color-scheme: dark; + --bg: #07111f; + --surface: rgba(15, 29, 48, 0.82); + --surface-2: #10233b; + --border: rgba(143, 177, 221, 0.22); + --text: #edf5ff; + --muted: #9db0c9; + --accent: #50d4c6; + --accent-strong: #8cebe1; + --danger: #ff7272; + --shadow: 0 26px 90px rgba(0, 0, 0, 0.38); +} + +* { box-sizing: border-box; } + +body { + min-height: 100vh; + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 15% 5%, rgba(31, 200, 183, 0.18), transparent 33%), + radial-gradient(circle at 86% 18%, rgba(79, 119, 255, 0.20), transparent 31%), + linear-gradient(160deg, #06101d, #0b1a2e 55%, #07111f); +} + +button, input, textarea { font: inherit; } + +.shell { + width: min(980px, calc(100vw - 28px)); + margin: 0 auto; + padding: 40px 0 60px; +} + +.hero { + display: grid; + grid-template-columns: 1.4fr 0.8fr; + gap: 24px; + align-items: end; + margin-bottom: 22px; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent); + font-size: 0.78rem; + font-weight: 750; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +h1, h2, p { margin-top: 0; } + +h1 { + margin-bottom: 12px; + font-size: clamp(2.1rem, 5vw, 4rem); + line-height: 0.98; + letter-spacing: -0.05em; +} + +.lede { + max-width: 64ch; + margin-bottom: 0; + color: var(--muted); + line-height: 1.55; +} + +.privacy { + padding: 16px; + border: 1px solid var(--border); + border-radius: 16px; + background: rgba(8, 20, 35, 0.74); + color: var(--muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.panel { + margin-top: 16px; + padding: 22px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--surface); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.panel__header { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; + margin-bottom: 18px; +} + +.panel__header h2 { + margin-bottom: 5px; + font-size: 1.05rem; +} + +.panel__header p { + margin-bottom: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.badge { + flex: none; + padding: 7px 11px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + background: var(--surface-2); + font-size: 0.82rem; +} + +.badge[data-tone="ready"] { color: var(--accent-strong); } +.badge[data-tone="busy"] { color: #ffd166; } +.badge[data-tone="error"] { color: var(--danger); } + +.grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 14px; +} + +.grid label { + display: grid; + gap: 7px; + color: var(--muted); + font-size: 0.87rem; + font-weight: 650; +} + +input, textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 13px; + background: rgba(5, 16, 29, 0.72); + color: var(--text); + outline: none; +} + +input:focus, textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(80, 212, 198, 0.12); +} + +textarea { resize: vertical; line-height: 1.5; } + +.controls { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 16px; +} + +button { + min-height: 42px; + padding: 0 16px; + border: 1px solid transparent; + border-radius: 12px; + background: var(--accent); + color: #03211f; + font-weight: 750; + cursor: pointer; +} + +button:hover:not(:disabled) { filter: brightness(1.08); } +button:disabled { cursor: not-allowed; opacity: 0.45; } +button.danger { background: var(--danger); color: #2b0707; } +button.secondary { background: transparent; color: var(--text); border-color: var(--border); } + +.status { + margin: 14px 0 0; + padding: 12px 13px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(5, 16, 29, 0.48); + color: var(--muted); +} + +.transcript-panel { min-height: 260px; } + +.transcript { + list-style: none; + display: grid; + gap: 10px; + max-height: 340px; + overflow: auto; + margin: 0; + padding: 0; +} + +.transcript li { + padding: 12px 13px; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(5, 16, 29, 0.62); + line-height: 1.5; +} + +.transcript li[data-role="assistant"] { border-left: 3px solid var(--accent); } +.transcript li[data-role="user"] { border-left: 3px solid #7398ff; } + +.empty { + display: grid; + place-items: center; + min-height: 150px; + color: var(--muted); +} + +.empty[hidden] { display: none; } + +@media (max-width: 720px) { + .shell { padding-top: 24px; } + .hero { grid-template-columns: 1fr; } + .panel { padding: 18px; } + .panel__header { align-items: stretch; flex-direction: column; } + .badge { align-self: flex-start; } +} diff --git a/apps/zvoice/public/voice-worklet.js b/apps/zvoice/public/voice-worklet.js new file mode 100644 index 0000000..becb0fc --- /dev/null +++ b/apps/zvoice/public/voice-worklet.js @@ -0,0 +1,28 @@ +class ZPlatformVoiceCaptureProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.buffer = new Float32Array(2048); + this.offset = 0; + } + + process(inputs) { + const channel = inputs[0]?.[0]; + if (!channel) return true; + + let sourceOffset = 0; + while (sourceOffset < channel.length) { + const count = Math.min(channel.length - sourceOffset, this.buffer.length - this.offset); + this.buffer.set(channel.subarray(sourceOffset, sourceOffset + count), this.offset); + this.offset += count; + sourceOffset += count; + if (this.offset === this.buffer.length) { + this.port.postMessage(this.buffer, [this.buffer.buffer]); + this.buffer = new Float32Array(2048); + this.offset = 0; + } + } + return true; + } +} + +registerProcessor("z-platform-voice-capture", ZPlatformVoiceCaptureProcessor); diff --git a/apps/zvoice/server.mjs b/apps/zvoice/server.mjs new file mode 100644 index 0000000..595ac41 --- /dev/null +++ b/apps/zvoice/server.mjs @@ -0,0 +1,184 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +const staticAssets = { + "/": { file: "index.html", type: "text/html; charset=utf-8" }, + "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" }, + "/voice-worklet.js": { file: "voice-worklet.js", type: "text/javascript; charset=utf-8" }, + "/styles.css": { file: "styles.css", type: "text/css; charset=utf-8" }, +}; + +const SECURITY_HEADERS = { + "Cache-Control": "no-store", + "Content-Security-Policy": [ + "default-src 'self'", + "connect-src 'self' ws: wss:", + "media-src 'self' blob:", + "script-src 'self'", + "style-src 'self'", + "img-src 'self' data:", + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + ].join("; "), + "Cross-Origin-Opener-Policy": "same-origin", + "Permissions-Policy": "camera=(), geolocation=(), microphone=(self)", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", +}; + +function send(response, status, body, type = "application/json; charset=utf-8", headers = {}) { + response.writeHead(status, { + "Content-Type": type, + "Content-Length": Buffer.byteLength(body), + ...SECURITY_HEADERS, + ...headers, + }); + response.end(body); +} + +async function json(request) { + let size = 0; + const chunks = []; + for await (const chunk of request) { + size += chunk.length; + if (size > 32 * 1024) throw new Error("Request body is too large"); + chunks.push(chunk); + } + if (!chunks.length) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function cleanText(value, fallback, maxLength) { + if (value == null || value === "") return fallback; + if (typeof value !== "string") throw new Error("Expected text value"); + const text = value.trim(); + if (!text || text.length > maxLength) throw new Error("Invalid text value"); + return text; +} + +function identity(request, env) { + const tenantId = String(request.headers["x-tenant-id"] || "").trim(); + const subjectId = String( + request.headers["x-subject-id"] || request.headers["cf-access-authenticated-user-email"] || "", + ).trim(); + const allowAnonymous = String(env.ZVOICE_ALLOW_ANONYMOUS || "false").toLowerCase() === "true"; + if (tenantId && subjectId) return { tenantId, subjectId }; + if (allowAnonymous) { + return { + tenantId: tenantId || "anonymous", + subjectId: subjectId || "anonymous", + }; + } + throw new Error("Authenticated tenant and subject are required"); +} + +export function healthSnapshot(env = process.env) { + return { + status: "ok", + service: "zvoice", + voice_gateway_configured: Boolean( + env.Z_PLATFORM_VOICE_GATEWAY_URL && env.Z_PLATFORM_SERVICE_TOKEN, + ), + anonymous_access: String(env.ZVOICE_ALLOW_ANONYMOUS || "false").toLowerCase() === "true", + }; +} + +export async function createVoiceSession( + body, + request, + env = process.env, + fetchImpl = fetch, +) { + const gatewayUrl = env.Z_PLATFORM_VOICE_GATEWAY_URL?.replace(/\/$/, ""); + const serviceToken = env.Z_PLATFORM_SERVICE_TOKEN; + if (!gatewayUrl || !serviceToken) throw new Error("Voice gateway is not configured"); + + const { tenantId, subjectId } = identity(request, env); + const instructions = cleanText( + body.instructions, + "You are a concise, helpful voice assistant. Reply in the user's language.", + 8000, + ); + const model = cleanText(env.VOICE_LLM_MODEL, "default", 256); + + const result = await fetchImpl(`${gatewayUrl}/v1/voice/tickets`, { + method: "POST", + headers: { + Authorization: `Bearer ${serviceToken}`, + "Content-Type": "application/json", + "X-Tenant-Id": tenantId, + "X-Subject-Id": subjectId, + "X-Request-Id": request.headers["x-request-id"] || randomUUID(), + }, + body: JSON.stringify({ model }), + signal: AbortSignal.timeout(5000), + }); + + const payload = await result.json().catch(() => ({})); + if (!result.ok) { + throw new Error(payload?.error?.message || "Voice gateway rejected the session request"); + } + + return { + ...payload, + model, + instructions, + }; +} + +export function createZVoiceRequestHandler({ env = process.env, fetchImpl = fetch } = {}) { + return async (request, response) => { + try { + const url = new URL(request.url || "/", "http://zvoice.local"); + if (request.method === "GET" && url.pathname === "/health/live") { + return send(response, 200, JSON.stringify({ status: "ok", service: "zvoice" })); + } + if (request.method === "GET" && url.pathname === "/health") { + return send(response, 200, JSON.stringify(healthSnapshot(env))); + } + if (request.method === "POST" && url.pathname === "/api/voice/session") { + const body = await json(request); + const session = await createVoiceSession(body, request, env, fetchImpl); + return send(response, 201, JSON.stringify(session)); + } + + const asset = staticAssets[url.pathname]; + if (request.method === "GET" && asset) { + const content = await readFile(new URL(`./public/${asset.file}`, import.meta.url)); + return send(response, 200, content, asset.type, { + "Cache-Control": asset.file === "index.html" ? "no-store" : "public, max-age=300", + }); + } + return send(response, 404, JSON.stringify({ error: "Not found" })); + } catch (error) { + return send( + response, + 400, + JSON.stringify({ error: error instanceof Error ? error.message : "Request failed" }), + ); + } + }; +} + +export function createZVoiceServer(options = {}) { + return createServer(createZVoiceRequestHandler(options)); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const host = process.env.HOST || "127.0.0.1"; + const port = Number(process.env.PORT || 3022); + createZVoiceServer().listen(port, host, () => { + process.stdout.write(`${JSON.stringify({ + timestamp: new Date().toISOString(), + level: "info", + service: "zvoice", + event: "listening", + host, + port, + })}\n`); + }); +} diff --git a/apps/zvoice/test/server.test.mjs b/apps/zvoice/test/server.test.mjs new file mode 100644 index 0000000..8935337 --- /dev/null +++ b/apps/zvoice/test/server.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createVoiceSession, healthSnapshot } from "../server.mjs"; + +test("health snapshot does not disclose secrets", () => { + const result = healthSnapshot({ + Z_PLATFORM_VOICE_GATEWAY_URL: "http://voice-gateway:8450", + Z_PLATFORM_SERVICE_TOKEN: "secret", + ZVOICE_ALLOW_ANONYMOUS: "true", + }); + assert.equal(result.voice_gateway_configured, true); + assert.equal(JSON.stringify(result).includes("secret"), false); +}); + +test("session request proxies identity and returns browser-safe data", async () => { + let captured; + const fetchImpl = async (url, options) => { + captured = { url, options }; + return new Response(JSON.stringify({ + ticket: "signed-ticket", + websocket_url: "ws://localhost:8450/v1/realtime", + expires_at: "2030-01-01T00:00:00.000Z", + ticket_transport: "sec-websocket-protocol", + }), { status: 201, headers: { "Content-Type": "application/json" } }); + }; + + const result = await createVoiceSession( + { model: "qwen3:8b", instructions: "Be helpful" }, + { headers: { "x-tenant-id": "tenant-1", "x-subject-id": "user-1" } }, + { + Z_PLATFORM_VOICE_GATEWAY_URL: "http://voice-gateway:8450", + Z_PLATFORM_SERVICE_TOKEN: "service-token", + ZVOICE_ALLOW_ANONYMOUS: "false", + }, + fetchImpl, + ); + + assert.equal(result.ticket, "signed-ticket"); + assert.equal(result.instructions, "Be helpful"); + assert.equal(captured.options.headers.Authorization, "Bearer service-token"); + assert.equal(captured.options.headers["X-Tenant-Id"], "tenant-1"); + assert.equal(captured.options.headers["X-Subject-Id"], "user-1"); +}); diff --git a/compose.voice.yml b/compose.voice.yml new file mode 100644 index 0000000..3fdcd1d --- /dev/null +++ b/compose.voice.yml @@ -0,0 +1,193 @@ +services: + ai-gateway: + environment: + UPSTREAM_BASE_URL: ${UPSTREAM_BASE_URL:-http://ollama:11434/v1} + UPSTREAM_PROVIDER: ${UPSTREAM_PROVIDER:-ollama} + AI_DEFAULT_PROVIDER: ${AI_DEFAULT_PROVIDER:-ollama} + AI_PROVIDER_KEYS_JSON: ${AI_PROVIDER_KEYS_JSON:?set AI_PROVIDER_KEYS_JSON in .env} + AI_MODELS_JSON: ${AI_MODELS_JSON:-["qwen3:8b"]} + AI_MODEL: ${AI_MODEL:-qwen3:8b} + + voice-agent: + build: + context: ./services/voice-agent + dockerfile: Dockerfile + args: + SPEECH_TO_SPEECH_VERSION: ${SPEECH_TO_SPEECH_VERSION:-0.2.11} + QWENTTS_CPP_VERSION: ${QWENTTS_CPP_VERSION:-0.3.1+cpu} + QWENTTS_WHEEL_INDEX: ${QWENTTS_WHEEL_INDEX:-https://huggingface.co/datasets/andito/qwentts-cpp-python-wheels/tree/main/whl/cpu} + restart: unless-stopped + init: true + networks: + - z-platform-internal + security_opt: + - no-new-privileges:true + environment: + VOICE_AGENT_PORT: 8765 + VOICE_LLM_MODEL: ${VOICE_LLM_MODEL:-qwen3:8b} + VOICE_LLM_BASE_URL: http://ai-gateway:8400/v1 + VOICE_LLM_API_KEY: ${Z_PLATFORM_SERVICE_TOKEN:?set Z_PLATFORM_SERVICE_TOKEN in .env} + VOICE_STT_BACKEND: ${VOICE_STT_BACKEND:-faster-whisper} + VOICE_STT_MODEL: ${VOICE_STT_MODEL:-small} + VOICE_STT_DEVICE: ${VOICE_STT_DEVICE:-auto} + VOICE_STT_COMPUTE_TYPE: ${VOICE_STT_COMPUTE_TYPE:-auto} + VOICE_STT_LANGUAGE: ${VOICE_STT_LANGUAGE:-th} + VOICE_TTS_BACKEND: ${VOICE_TTS_BACKEND:-qwen3} + VOICE_TTS_MODEL: ${VOICE_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice} + VOICE_TTS_SPEAKER: ${VOICE_TTS_SPEAKER:-Aiden} + VOICE_TTS_LANGUAGE: ${VOICE_TTS_LANGUAGE:-auto} + VOICE_TTS_DEVICE: ${VOICE_TTS_DEVICE:-cpu} + VOICE_TTS_BACKEND_ENGINE: ${VOICE_TTS_BACKEND_ENGINE:-ggml} + VOICE_TTS_QUANTIZATION: ${VOICE_TTS_QUANTIZATION:-Q4_K_M} + VOICE_NUM_PIPELINES: ${VOICE_NUM_PIPELINES:-1} + VOICE_VAD_THRESHOLD: ${VOICE_VAD_THRESHOLD:-0.6} + VOICE_MIN_SPEECH_MS: ${VOICE_MIN_SPEECH_MS:-250} + VOICE_MIN_SILENCE_MS: ${VOICE_MIN_SILENCE_MS:-500} + VOICE_QWEN3_NON_STREAMING: ${VOICE_QWEN3_NON_STREAMING:-True} + VOICE_LOG_LEVEL: ${VOICE_LOG_LEVEL:-info} + HF_TOKEN: ${HF_TOKEN:-} + volumes: + - voice_hf_cache:/models + depends_on: + ai-gateway: + condition: service_healthy + + voice-gateway: + build: + context: ./services/voice-gateway + dockerfile: Dockerfile + restart: unless-stopped + init: true + networks: + - z-platform-internal + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + environment: + HOST: 0.0.0.0 + PORT: 8450 + Z_PLATFORM_SERVICE_TOKEN: ${Z_PLATFORM_SERVICE_TOKEN:?set Z_PLATFORM_SERVICE_TOKEN in .env} + VOICE_TICKET_SECRET: ${VOICE_TICKET_SECRET:?set VOICE_TICKET_SECRET in .env} + VOICE_AGENT_URL: http://voice-agent:8765 + VOICE_PUBLIC_WS_URL: ${VOICE_PUBLIC_WS_URL:-ws://127.0.0.1:8450/v1/realtime} + VOICE_TICKET_TTL_SECONDS: ${VOICE_TICKET_TTL_SECONDS:-60} + VOICE_MAX_SESSIONS: ${VOICE_MAX_SESSIONS:-2} + VOICE_MAX_SESSIONS_PER_IP: ${VOICE_MAX_SESSIONS_PER_IP:-1} + VOICE_ALLOW_ANONYMOUS: ${VOICE_ALLOW_ANONYMOUS:-true} + ports: + - "127.0.0.1:${VOICE_GATEWAY_PORT:-8450}:8450" + depends_on: + voice-agent: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8450/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 5s + + zvoice: + build: + context: ./apps/zvoice + dockerfile: Dockerfile + restart: unless-stopped + init: true + networks: + - z-platform-internal + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + environment: + HOST: 0.0.0.0 + PORT: 3022 + Z_PLATFORM_SERVICE_TOKEN: ${Z_PLATFORM_SERVICE_TOKEN:?set Z_PLATFORM_SERVICE_TOKEN in .env} + Z_PLATFORM_VOICE_GATEWAY_URL: http://voice-gateway:8450 + VOICE_LLM_MODEL: ${VOICE_LLM_MODEL:-qwen3:8b} + ZVOICE_ALLOW_ANONYMOUS: ${VOICE_ALLOW_ANONYMOUS:-true} + ports: + - "127.0.0.1:${ZVOICE_PORT:-3022}:3022" + depends_on: + voice-gateway: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3022/health/live').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 5s + + ollama: + profiles: ["voice-ollama"] + image: ${OLLAMA_IMAGE:-ollama/ollama:0.32.3} + restart: unless-stopped + networks: + - z-platform-internal + volumes: + - ollama_data:/root/.ollama + ports: + - "127.0.0.1:${OLLAMA_PORT:-11434}:11434" + + llama-cpp: + profiles: ["voice-llamacpp"] + image: ${LLAMA_CPP_IMAGE:-ghcr.io/ggml-org/llama.cpp:server-b9404} + restart: unless-stopped + networks: + - z-platform-internal + command: + - --host + - 0.0.0.0 + - --port + - "8080" + - --model + - /models/${LLAMA_CPP_MODEL_FILE:-model.gguf} + - --alias + - ${VOICE_LLM_MODEL:-local-voice-model} + - --ctx-size + - ${LLAMA_CPP_CONTEXT_SIZE:-8192} + - --parallel + - ${LLAMA_CPP_PARALLEL:-2} + volumes: + - ${LLAMA_CPP_MODELS_DIR:-./models/llama.cpp}:/models:ro + ports: + - "127.0.0.1:${LLAMA_CPP_PORT:-8080}:8080" + + vllm: + profiles: ["voice-vllm"] + image: ${VLLM_IMAGE:-vllm/vllm-openai:v0.26.0} + restart: unless-stopped + networks: + - z-platform-internal + ipc: host + command: + - --model + - ${VLLM_MODEL:-Qwen/Qwen3-4B-Instruct-2507} + - --served-model-name + - ${VOICE_LLM_MODEL:-local-voice-model} + - --api-key + - ${VLLM_API_KEY:-local} + - --generation-config + - vllm + - --max-model-len + - ${VLLM_MAX_MODEL_LEN:-8192} + environment: + HUGGING_FACE_HUB_TOKEN: ${HF_TOKEN:-} + volumes: + - vllm_cache:/root/.cache/huggingface + ports: + - "127.0.0.1:${VLLM_PORT:-8000}:8000" + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: ${VLLM_GPU_COUNT:-1} + capabilities: [gpu] + +volumes: + voice_hf_cache: + ollama_data: + vllm_cache: diff --git a/configs/voice-agent.env.example b/configs/voice-agent.env.example new file mode 100644 index 0000000..75f097f --- /dev/null +++ b/configs/voice-agent.env.example @@ -0,0 +1,72 @@ +# Generate both values; never commit the resulting .env file. +# openssl rand -hex 32 +Z_PLATFORM_SERVICE_TOKEN= +VOICE_TICKET_SECRET= + +# Public browser endpoint. Use wss:// behind Cloudflare/Caddy in production. +VOICE_PUBLIC_WS_URL=ws://127.0.0.1:8450/v1/realtime +VOICE_GATEWAY_PORT=8450 +VOICE_TICKET_TTL_SECONDS=60 +VOICE_MAX_SESSIONS=2 +VOICE_MAX_SESSIONS_PER_IP=1 +VOICE_ALLOW_ANONYMOUS=true + +# Speech pipeline +SPEECH_TO_SPEECH_VERSION=0.2.11 +VOICE_STT_BACKEND=faster-whisper +VOICE_STT_MODEL=small +VOICE_STT_DEVICE=auto +VOICE_STT_COMPUTE_TYPE=auto +VOICE_STT_LANGUAGE=th +VOICE_TTS_BACKEND=qwen3 +VOICE_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice +VOICE_TTS_SPEAKER=Aiden +VOICE_TTS_LANGUAGE=auto +VOICE_TTS_DEVICE=cpu +VOICE_TTS_BACKEND_ENGINE=ggml +VOICE_TTS_QUANTIZATION=Q4_K_M +VOICE_NUM_PIPELINES=1 +VOICE_VAD_THRESHOLD=0.6 +VOICE_MIN_SPEECH_MS=250 +VOICE_MIN_SILENCE_MS=500 +VOICE_QWEN3_NON_STREAMING=True + +# Select exactly one local LLM upstream and keep AI Gateway as the policy boundary. +# +# Ollama: +# docker compose -f compose.yml -f compose.voice.yml --profile voice-ollama up -d +# docker compose exec ollama ollama pull qwen3:8b +UPSTREAM_PROVIDER=ollama +UPSTREAM_BASE_URL=http://ollama:11434/v1 +AI_DEFAULT_PROVIDER=ollama +AI_PROVIDER_KEYS_JSON={"ollama":["ollama"]} +AI_MODELS_JSON=["qwen3:8b"] +AI_MODEL=qwen3:8b +VOICE_LLM_MODEL=qwen3:8b +# +# llama.cpp: +# UPSTREAM_PROVIDER=llama-cpp +# UPSTREAM_BASE_URL=http://llama-cpp:8080/v1 +# AI_DEFAULT_PROVIDER=llama-cpp +# AI_PROVIDER_KEYS_JSON={"llama-cpp":["local"]} +# AI_MODELS_JSON=["local-voice-model"] +# AI_MODEL=local-voice-model +# VOICE_LLM_MODEL=local-voice-model +# LLAMA_CPP_MODELS_DIR=./models/llama.cpp +# LLAMA_CPP_MODEL_FILE=model.gguf +# +# vLLM: +# UPSTREAM_PROVIDER=vllm +# UPSTREAM_BASE_URL=http://vllm:8000/v1 +# AI_DEFAULT_PROVIDER=vllm +# VLLM_API_KEY=local +# AI_PROVIDER_KEYS_JSON={"vllm":["local"]} +# AI_MODELS_JSON=["local-voice-model"] +# AI_MODEL=local-voice-model +# VOICE_LLM_MODEL=local-voice-model +# VLLM_MODEL=Qwen/Qwen3-4B-Instruct-2507 + +# Pin runtime images. Review and update them deliberately. +OLLAMA_IMAGE=ollama/ollama:0.32.3 +LLAMA_CPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-b9404 +VLLM_IMAGE=vllm/vllm-openai:v0.26.0 diff --git a/docs/architecture/voice-agent.md b/docs/architecture/voice-agent.md new file mode 100644 index 0000000..a3f8a36 --- /dev/null +++ b/docs/architecture/voice-agent.md @@ -0,0 +1,95 @@ +# Local Realtime Voice Agent Architecture + +## Status + +Initial production-oriented vertical slice. External traffic remains disabled by default; published ports bind to loopback. + +## Goals + +- Real-time speech conversation with interruption handling. +- Local VAD, STT, and TTS, with a local or hosted OpenAI-compatible LLM. +- Keep model/provider credentials and platform service tokens out of the browser. +- Preserve `apps -> services -> packages/contracts` dependency direction. +- Route all LLM traffic through `services/ai-gateway`. +- Support Ollama, llama.cpp, and vLLM without coupling the speech pipeline to one runtime. + +## Components + +```text +Browser / ZVoice + │ POST /api/voice/session + ▼ +apps/zvoice (server-side) + │ service-token authenticated ticket request + ▼ +services/voice-gateway :8450 + │ signed one-time ticket + WebSocket tunnel + ▼ +services/voice-agent :8765 (internal only) + │ VAD -> STT -> LLM -> TTS + │ │ + │ ▼ + │ services/ai-gateway :8400 + │ │ + │ ├─ Ollama :11434/v1 + │ ├─ llama.cpp :8080/v1 + │ ├─ vLLM :8000/v1 + │ └─ hosted OpenAI-compatible provider + ▼ +PCM audio events returned to the browser +``` + +## Security boundary + +1. `zvoice` holds `Z_PLATFORM_SERVICE_TOKEN` server-side. +2. The browser requests a voice session from `zvoice`; it never receives the service token. +3. `voice-gateway` issues a signed ticket with a 10–300 second lifetime. +4. The browser sends the ticket through `Sec-WebSocket-Protocol` as `zticket.`. +5. The gateway validates the HMAC, expiry, tenant, subject, and nonce, then consumes the nonce. +6. The speech runtime has no published host port and only accepts traffic from the internal Compose network. +7. The speech runtime calls `ai-gateway`; provider credentials remain in gateway/secret storage. + +The first slice keeps consumed ticket nonces in memory and therefore runs `voice-gateway` as a single replica. Before horizontal scaling, move nonce consumption and active-session admission to Redis using an atomic `SET NX EX` operation. + +## Realtime protocol + +The speech runtime exposes the OpenAI Realtime-compatible `/v1/realtime` WebSocket endpoint. The ZVoice client sends 16 kHz mono PCM16 audio with `input_audio_buffer.append` and handles: + +- `input_audio_buffer.speech_started` +- `input_audio_buffer.speech_stopped` +- live/final transcription events +- `response.output_audio.delta` +- `response.output_audio_transcript.done` +- `response.done` +- `error` + +New user speech stops queued playback immediately, enabling barge-in behavior. + +## Model/runtime profiles + +| Profile | Best fit | AI Gateway upstream | +|---|---|---| +| `voice-ollama` | Simple local setup, CPU/GPU, easy model management | `http://ollama:11434/v1` | +| `voice-llamacpp` | GGUF models, CPU/Metal/CUDA/Vulkan, tight memory control | `http://llama-cpp:8080/v1` | +| `voice-vllm` | NVIDIA GPU throughput, continuous batching, multi-session workloads | `http://vllm:8000/v1` | + +Only one LLM profile should be selected per local deployment unless a separate provider router is configured. + +## Capacity model + +- `VOICE_NUM_PIPELINES` controls concurrent Hugging Face realtime pipelines. Each pipeline loads its own conversation handlers and can materially increase VRAM/RAM. +- `VOICE_MAX_SESSIONS` is enforced at `voice-gateway`. +- Start with one pipeline/session on CPU and two only after measuring memory and latency. +- The browser sends approximately 32 KB/s of uncompressed 16 kHz mono PCM16 before WebSocket framing. + +## Target SLOs + +These are engineering targets, not guarantees: + +- Ticket issuance p95: under 100 ms on the local network. +- VAD end-of-turn decision: 300–600 ms after speech ends. +- First partial transcript: under 700 ms where the STT backend supports streaming. +- First synthesized audio: under 1.5 seconds after end-of-turn on a suitable GPU. +- Session setup success: at least 99.5% in a healthy single-node deployment. + +Record actual values before enabling external production traffic. diff --git a/docs/operations/voice-agent.md b/docs/operations/voice-agent.md new file mode 100644 index 0000000..cad6df0 --- /dev/null +++ b/docs/operations/voice-agent.md @@ -0,0 +1,95 @@ +# Voice Agent Operations Runbook + +## Prerequisites + +- Docker Engine and Docker Compose. +- A microphone-capable browser served from `localhost` or HTTPS. +- Sufficient disk for model caches. +- NVIDIA Container Toolkit only for the vLLM profile or a CUDA-enabled custom voice-agent image. + +## Configure + +```bash +cp configs/voice-agent.env.example .env.voice +openssl rand -hex 32 +``` + +Set the generated values as `Z_PLATFORM_SERVICE_TOKEN` and `VOICE_TICKET_SECRET` in the environment file. Keep `.env.voice` outside version control. + +## Ollama path + +```bash +docker compose --env-file .env.voice -f compose.yml -f compose.voice.yml --profile voice-ollama up -d --build + +docker compose --env-file .env.voice -f compose.yml -f compose.voice.yml exec ollama ollama pull qwen3:8b +``` + +## llama.cpp path + +Place a GGUF model at `./models/llama.cpp/model.gguf`, select the llama.cpp block in `.env.voice`, then run: + +```bash +docker compose --env-file .env.voice -f compose.yml -f compose.voice.yml --profile voice-llamacpp up -d --build +``` + +## vLLM path + +Select the vLLM block in `.env.voice`, verify the NVIDIA runtime, then run: + +```bash +docker compose --env-file .env.voice -f compose.yml -f compose.voice.yml --profile voice-vllm up -d --build +``` + +## Validate + +```bash +set -a +source .env.voice +set +a +bash scripts/voice-agent-smoke.sh +``` + +Open `http://127.0.0.1:3022`, allow microphone access, and use the Voice panel. + +Validate all of the following: + +- microphone permission and capture; +- final transcription quality for the target language; +- response audio playback; +- barge-in while the assistant is speaking; +- ticket expiry and replay rejection; +- model/provider failure behavior; +- browser reconnect behavior; +- CPU, RAM, VRAM, and disk-cache growth. + +## Observability + +- `GET /health` on port 8450 reports active and maximum sessions. +- `GET /metrics` exposes Prometheus text metrics. +- Structured gateway logs include ticket/session lifecycle events but never ticket values or audio. +- Add the voice gateway metrics endpoint to the platform Prometheus scrape configuration before staging. + +## Failure modes + +| Symptom | Likely cause | Action | +|---|---|---| +| Voice ticket returns 401 | Service token mismatch | Verify the same `Z_PLATFORM_SERVICE_TOKEN` reaches ZVoice and voice-gateway | +| WebSocket returns 401 | Ticket expired, replayed, or malformed | Request a new session; inspect gateway rejection logs | +| Voice agent unhealthy for several minutes | Initial model download or insufficient RAM/disk | Inspect `voice-agent` logs and model-cache volume | +| No transcription | Wrong STT model/language or no microphone frames | Confirm browser permission and test a smaller Whisper model | +| LLM 503 from AI Gateway | Provider key pool not seeded | Verify `AI_PROVIDER_KEYS_JSON`, `AI_DEFAULT_PROVIDER`, and upstream URL | +| High response latency | CPU-only STT/TTS/LLM or oversized model | Move one stage to GPU, reduce model size, and benchmark stages separately | +| Audio overlaps after interruption | Client did not stop queued sources | Confirm `speech_started` events reach the browser and clear playback queue | + +## Production gate + +Do not expose the port publicly until: + +- Cloudflare/OIDC identity supplies stable tenant and subject headers; +- `VOICE_ALLOW_ANONYMOUS=false`; +- TLS terminates at a reviewed reverse proxy; +- ticket replay state is moved to Redis for multi-replica operation; +- rate limits and concurrency limits match hardware capacity; +- privacy notice, retention policy, and consent flow cover microphone/audio processing; +- load, interruption, recovery, and data-boundary tests pass; +- a human release owner signs off. diff --git a/package.json b/package.json index 6f5e259..0ff44e6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "packageManager": "pnpm@11.4.0", "scripts": { "build": "pnpm -r --if-present build", - "test": "pnpm -r --workspace-concurrency=1 --if-present test && node --test scripts/test/*.test.mjs", + "test": "pnpm -r --workspace-concurrency=1 --if-present test && node --test scripts/test/*.test.mjs apps/zvoice/test/*.test.mjs services/voice-gateway/test/*.test.mjs", "lint": "pnpm -r --if-present lint", "typecheck": "pnpm -r --if-present typecheck", "keys:validate": "node scripts/validate-api-keys.mjs", diff --git a/scripts/voice-agent-smoke.sh b/scripts/voice-agent-smoke.sh new file mode 100644 index 0000000..ad25b89 --- /dev/null +++ b/scripts/voice-agent-smoke.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +voice_gateway_url="${VOICE_GATEWAY_HTTP_URL:-http://127.0.0.1:8450}" +zvoice_url="${ZVOICE_URL:-http://127.0.0.1:3022}" + +curl --fail --silent --show-error "${voice_gateway_url}/health" | python3 -m json.tool +curl --fail --silent --show-error "${zvoice_url}/health/live" | python3 -m json.tool + +ticket_json="$( + curl --fail --silent --show-error \ + -X POST "${voice_gateway_url}/v1/voice/tickets" \ + -H "Authorization: Bearer ${Z_PLATFORM_SERVICE_TOKEN:?Z_PLATFORM_SERVICE_TOKEN is required}" \ + -H "X-Tenant-Id: smoke-test" \ + -H "X-Subject-Id: smoke-test" \ + -H "Content-Type: application/json" \ + -d '{}' +)" + +python3 - "${ticket_json}" <<'PY' +import json +import sys + +payload = json.loads(sys.argv[1]) +assert payload["ticket"] +assert payload["websocket_url"].endswith("/v1/realtime") +assert payload["ticket_transport"] == "sec-websocket-protocol" +print("voice ticket contract: ok") +PY + +echo "HTTP and ticket smoke checks passed." +echo "Run the browser test at ${zvoice_url} to validate microphone capture, interruption, transcription, and audio playback." diff --git a/services/voice-agent/Dockerfile b/services/voice-agent/Dockerfile new file mode 100644 index 0000000..697351f --- /dev/null +++ b/services/voice-agent/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.12-slim-bookworm + +ARG SPEECH_TO_SPEECH_VERSION=0.2.11 +ARG QWENTTS_CPP_VERSION=0.3.1+cpu +ARG QWENTTS_WHEEL_INDEX=https://huggingface.co/datasets/andito/qwentts-cpp-python-wheels/tree/main/whl/cpu + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + HF_HOME=/models/huggingface \ + TRANSFORMERS_CACHE=/models/huggingface/transformers \ + XDG_CACHE_HOME=/models/cache + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg libgomp1 libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel \ + && python -m pip install --no-cache-dir \ + "qwentts-cpp-python==${QWENTTS_CPP_VERSION}" \ + --find-links "${QWENTTS_WHEEL_INDEX}" \ + && python -m pip install --no-cache-dir \ + "speech-to-speech[faster-whisper]==${SPEECH_TO_SPEECH_VERSION}" + +RUN groupadd --system zplatform \ + && useradd --system --gid zplatform --home-dir /app --create-home zplatform \ + && mkdir -p /models/huggingface /models/cache \ + && chown -R zplatform:zplatform /app /models + +WORKDIR /app +COPY entrypoint.sh healthcheck.py /app/ +RUN chmod 0555 /app/entrypoint.sh /app/healthcheck.py + +USER zplatform + +EXPOSE 8765 +HEALTHCHECK --interval=15s --timeout=5s --start-period=120s --retries=12 CMD ["python", "/app/healthcheck.py"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/services/voice-agent/entrypoint.sh b/services/voice-agent/entrypoint.sh new file mode 100644 index 0000000..4386523 --- /dev/null +++ b/services/voice-agent/entrypoint.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -eu + +: "${VOICE_LLM_MODEL:?VOICE_LLM_MODEL is required}" +: "${VOICE_LLM_BASE_URL:?VOICE_LLM_BASE_URL is required}" +: "${VOICE_LLM_API_KEY:?VOICE_LLM_API_KEY is required}" + +VOICE_AGENT_PORT="${VOICE_AGENT_PORT:-8765}" +VOICE_STT_BACKEND="${VOICE_STT_BACKEND:-faster-whisper}" +VOICE_STT_MODEL="${VOICE_STT_MODEL:-small}" +VOICE_STT_DEVICE="${VOICE_STT_DEVICE:-auto}" +VOICE_STT_COMPUTE_TYPE="${VOICE_STT_COMPUTE_TYPE:-auto}" +VOICE_STT_LANGUAGE="${VOICE_STT_LANGUAGE:-th}" +VOICE_TTS_BACKEND="${VOICE_TTS_BACKEND:-qwen3}" +VOICE_TTS_MODEL="${VOICE_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}" +VOICE_TTS_SPEAKER="${VOICE_TTS_SPEAKER:-Aiden}" +VOICE_TTS_LANGUAGE="${VOICE_TTS_LANGUAGE:-auto}" +VOICE_TTS_DEVICE="${VOICE_TTS_DEVICE:-cpu}" +VOICE_TTS_BACKEND_ENGINE="${VOICE_TTS_BACKEND_ENGINE:-ggml}" +VOICE_TTS_QUANTIZATION="${VOICE_TTS_QUANTIZATION:-Q4_K_M}" +VOICE_NUM_PIPELINES="${VOICE_NUM_PIPELINES:-1}" +VOICE_VAD_THRESHOLD="${VOICE_VAD_THRESHOLD:-0.6}" +VOICE_MIN_SPEECH_MS="${VOICE_MIN_SPEECH_MS:-250}" +VOICE_MIN_SILENCE_MS="${VOICE_MIN_SILENCE_MS:-500}" +VOICE_CHAT_SIZE="${VOICE_CHAT_SIZE:-30}" +VOICE_LOG_LEVEL="${VOICE_LOG_LEVEL:-info}" +VOICE_REASONING_EFFORT="${VOICE_REASONING_EFFORT:-none}" +VOICE_QWEN3_NON_STREAMING="${VOICE_QWEN3_NON_STREAMING:-True}" + +set -- speech-to-speech \ + --mode realtime \ + --ws_host 0.0.0.0 \ + --ws_port "${VOICE_AGENT_PORT}" \ + --stt "${VOICE_STT_BACKEND}" \ + --llm_backend chat-completions \ + --tts "${VOICE_TTS_BACKEND}" \ + --model_name "${VOICE_LLM_MODEL}" \ + --responses_api_base_url "${VOICE_LLM_BASE_URL}" \ + --responses_api_api_key "${VOICE_LLM_API_KEY}" \ + --responses_api_reasoning_effort "${VOICE_REASONING_EFFORT}" \ + --chat_size "${VOICE_CHAT_SIZE}" \ + --thresh "${VOICE_VAD_THRESHOLD}" \ + --min_speech_ms "${VOICE_MIN_SPEECH_MS}" \ + --min_silence_ms "${VOICE_MIN_SILENCE_MS}" \ + --num_pipelines "${VOICE_NUM_PIPELINES}" \ + --log_level "${VOICE_LOG_LEVEL}" + +if [ "${VOICE_STT_BACKEND}" = "faster-whisper" ]; then + set -- "$@" \ + --faster_whisper_stt_model_name "${VOICE_STT_MODEL}" \ + --faster_whisper_stt_device "${VOICE_STT_DEVICE}" \ + --faster_whisper_stt_compute_type "${VOICE_STT_COMPUTE_TYPE}" \ + --faster_whisper_stt_gen_language "${VOICE_STT_LANGUAGE}" +elif [ "${VOICE_STT_BACKEND}" = "whisper" ]; then + set -- "$@" --whisper_stt_model_name "${VOICE_STT_MODEL}" +fi + +if [ "${VOICE_TTS_BACKEND}" = "qwen3" ]; then + set -- "$@" \ + --qwen3_tts_model_name "${VOICE_TTS_MODEL}" \ + --qwen3_tts_speaker "${VOICE_TTS_SPEAKER}" \ + --qwen3_tts_language "${VOICE_TTS_LANGUAGE}" \ + --qwen3_tts_device "${VOICE_TTS_DEVICE}" \ + --qwen3_tts_backend "${VOICE_TTS_BACKEND_ENGINE}" \ + --qwen3_tts_ggml_quantization "${VOICE_TTS_QUANTIZATION}" \ + --qwen3_tts_non_streaming_mode "${VOICE_QWEN3_NON_STREAMING}" +fi + +exec "$@" diff --git a/services/voice-agent/healthcheck.py b/services/voice-agent/healthcheck.py new file mode 100644 index 0000000..0695b23 --- /dev/null +++ b/services/voice-agent/healthcheck.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +import os +import socket +import sys + +host = os.getenv("VOICE_AGENT_HEALTH_HOST", "127.0.0.1") +port = int(os.getenv("VOICE_AGENT_PORT", "8765")) + +try: + with socket.create_connection((host, port), timeout=3): + pass +except OSError: + sys.exit(1) diff --git a/services/voice-gateway/Dockerfile b/services/voice-gateway/Dockerfile new file mode 100644 index 0000000..87d31ad --- /dev/null +++ b/services/voice-gateway/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22.16.0-alpine3.22 + +ARG OCI_REVISION=unknown +ARG OCI_SOURCE=https://github.com/cvsz/z-platform +ARG OCI_CREATED=unknown +ARG OCI_VERSION=dev + +LABEL org.opencontainers.image.revision="${OCI_REVISION}" \ + org.opencontainers.image.source="${OCI_SOURCE}" \ + org.opencontainers.image.created="${OCI_CREATED}" \ + org.opencontainers.image.version="${OCI_VERSION}" + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=8450 + +WORKDIR /app +COPY --chown=node:node index.mjs ./index.mjs + +USER node +EXPOSE 8450 +CMD ["node", "index.mjs"] diff --git a/services/voice-gateway/index.mjs b/services/voice-gateway/index.mjs new file mode 100644 index 0000000..c926862 --- /dev/null +++ b/services/voice-gateway/index.mjs @@ -0,0 +1,395 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { connect as connectTcp } from "node:net"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_TICKET_TTL_SECONDS = 60; +const MAX_BODY_BYTES = 32 * 1024; + +function log(level, event, fields = {}) { + process.stdout.write(`${JSON.stringify({ + timestamp: new Date().toISOString(), + level, + service: "voice-gateway", + event, + ...fields, + })}\n`); +} + +function sendJson(response, status, payload, headers = {}) { + const body = JSON.stringify(payload); + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "Cache-Control": "no-store", + ...headers, + }); + response.end(body); +} + +async function readJson(request) { + let size = 0; + const chunks = []; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_BODY_BYTES) throw new Error("Request body is too large"); + chunks.push(chunk); + } + if (!chunks.length) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function base64UrlEncode(value) { + return Buffer.from(value).toString("base64url"); +} + +function base64UrlDecode(value) { + return Buffer.from(value, "base64url"); +} + +function boundedInteger(value, fallback, minimum, maximum) { + const number = Number(value); + if (!Number.isInteger(number) || number < minimum || number > maximum) return fallback; + return number; +} + +export function parseBearer(header) { + if (typeof header !== "string") return null; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match?.[1] || null; +} + +export function createTicketCodec(secret, { now = () => Date.now(), usedNonces = new Map() } = {}) { + if (typeof secret !== "string" || secret.length < 32) { + throw new Error("VOICE_TICKET_SECRET must be at least 32 characters"); + } + + function purgeExpiredNonces(currentSeconds) { + for (const [nonce, expiresAt] of usedNonces) { + if (expiresAt <= currentSeconds) usedNonces.delete(nonce); + } + } + + function sign(encodedPayload) { + return createHmac("sha256", secret).update(encodedPayload).digest(); + } + + function issue({ tenantId, subjectId, ttlSeconds = DEFAULT_TICKET_TTL_SECONDS }) { + const issuedAt = Math.floor(now() / 1000); + const ttl = boundedInteger(ttlSeconds, DEFAULT_TICKET_TTL_SECONDS, 10, 300); + const claims = { + v: 1, + iat: issuedAt, + nbf: issuedAt - 5, + exp: issuedAt + ttl, + nonce: randomBytes(18).toString("base64url"), + tenant_id: tenantId, + subject_id: subjectId, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const signature = base64UrlEncode(sign(encodedPayload)); + return { + ticket: `${encodedPayload}.${signature}`, + claims, + }; + } + + function verify(ticket, { consume = true } = {}) { + if (typeof ticket !== "string" || ticket.length > 4096) { + throw new Error("Invalid voice ticket"); + } + const parts = ticket.split("."); + if (parts.length !== 2) throw new Error("Invalid voice ticket"); + + const [encodedPayload, encodedSignature] = parts; + const expected = sign(encodedPayload); + const actual = base64UrlDecode(encodedSignature); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + throw new Error("Invalid voice ticket signature"); + } + + let claims; + try { + claims = JSON.parse(base64UrlDecode(encodedPayload).toString("utf8")); + } catch { + throw new Error("Invalid voice ticket payload"); + } + + const currentSeconds = Math.floor(now() / 1000); + purgeExpiredNonces(currentSeconds); + if (claims?.v !== 1 || !claims.nonce || !claims.tenant_id || !claims.subject_id) { + throw new Error("Incomplete voice ticket"); + } + if (claims.nbf > currentSeconds || claims.exp <= currentSeconds) { + throw new Error("Expired voice ticket"); + } + if (usedNonces.has(claims.nonce)) { + throw new Error("Voice ticket was already used"); + } + if (consume) usedNonces.set(claims.nonce, claims.exp); + return claims; + } + + return { issue, verify, usedNonces }; +} + +function clientIp(request) { + const forwarded = request.headers["x-forwarded-for"]; + if (typeof forwarded === "string" && forwarded.trim()) return forwarded.split(",")[0].trim(); + return request.socket.remoteAddress || "unknown"; +} + +function requestIdentity(request, allowAnonymous) { + const tenantId = String(request.headers["x-tenant-id"] || "").trim(); + const subjectId = String( + request.headers["x-subject-id"] || request.headers["x-user-id"] || "", + ).trim(); + + if (tenantId && subjectId) return { tenantId, subjectId }; + if (allowAnonymous) { + return { + tenantId: tenantId || "anonymous", + subjectId: subjectId || "anonymous", + }; + } + throw new Error("Tenant and subject headers are required"); +} + +function extractTicket(request) { + const protocols = String(request.headers["sec-websocket-protocol"] || "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const ticketProtocol = protocols.find((value) => value.startsWith("zticket.")); + if (ticketProtocol) { + return { + ticket: ticketProtocol.slice("zticket.".length), + forwardedProtocols: protocols.filter((value) => !value.startsWith("zticket.")), + }; + } + + const url = new URL(request.url || "/", "http://voice-gateway.local"); + return { + ticket: url.searchParams.get("ticket"), + forwardedProtocols: protocols, + }; +} + +function cleanUpstreamPath(rawUrl) { + const url = new URL(rawUrl || "/v1/realtime", "http://voice-gateway.local"); + url.searchParams.delete("ticket"); + const query = url.searchParams.toString(); + return `${url.pathname}${query ? `?${query}` : ""}`; +} + +function serializeUpgradeRequest(request, target, claims, forwardedProtocols) { + const headers = { ...request.headers }; + delete headers["proxy-connection"]; + delete headers["content-length"]; + delete headers["sec-websocket-protocol"]; + headers.host = target.host; + headers.connection = "Upgrade"; + headers.upgrade = "websocket"; + headers["x-z-platform-tenant"] = claims.tenant_id; + headers["x-z-platform-subject"] = claims.subject_id; + headers["x-z-platform-voice-session"] = claims.nonce; + if (forwardedProtocols.length) { + headers["sec-websocket-protocol"] = forwardedProtocols.join(", "); + } + + const lines = [`GET ${cleanUpstreamPath(request.url)} HTTP/1.1`]; + for (const [name, value] of Object.entries(headers)) { + if (value == null) continue; + if (Array.isArray(value)) { + for (const item of value) lines.push(`${name}: ${item}`); + } else { + lines.push(`${name}: ${value}`); + } + } + return `${lines.join("\r\n")}\r\n\r\n`; +} + +function closeSocket(socket, status, message) { + if (!socket.destroyed) { + socket.end( + `HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`, + ); + } +} + +export function createVoiceGateway({ env = process.env, now = () => Date.now() } = {}) { + const serviceToken = env.Z_PLATFORM_SERVICE_TOKEN; + if (!serviceToken) throw new Error("Z_PLATFORM_SERVICE_TOKEN is required"); + + const codec = createTicketCodec(env.VOICE_TICKET_SECRET || "", { now }); + const upstream = new URL(env.VOICE_AGENT_URL || "http://voice-agent:8765"); + const publicWebSocketUrl = env.VOICE_PUBLIC_WS_URL || "ws://127.0.0.1:8450/v1/realtime"; + const allowAnonymous = String(env.VOICE_ALLOW_ANONYMOUS || "false").toLowerCase() === "true"; + const ticketTtlSeconds = boundedInteger(env.VOICE_TICKET_TTL_SECONDS, 60, 10, 300); + const maxSessions = boundedInteger(env.VOICE_MAX_SESSIONS, 4, 1, 1024); + const maxSessionsPerIp = boundedInteger(env.VOICE_MAX_SESSIONS_PER_IP, 2, 1, 128); + + let activeSessions = 0; + const sessionsByIp = new Map(); + const metrics = { + ticketsIssued: 0, + ticketFailures: 0, + websocketAccepted: 0, + websocketRejected: 0, + upstreamFailures: 0, + }; + + const requestHandler = async (request, response) => { + try { + if (request.method === "GET" && request.url === "/health") { + return sendJson(response, 200, { + status: "ok", + service: "voice-gateway", + active_sessions: activeSessions, + max_sessions: maxSessions, + }); + } + + if (request.method === "GET" && request.url === "/metrics") { + const body = [ + "# TYPE z_platform_voice_active_sessions gauge", + `z_platform_voice_active_sessions ${activeSessions}`, + "# TYPE z_platform_voice_tickets_issued_total counter", + `z_platform_voice_tickets_issued_total ${metrics.ticketsIssued}`, + "# TYPE z_platform_voice_ticket_failures_total counter", + `z_platform_voice_ticket_failures_total ${metrics.ticketFailures}`, + "# TYPE z_platform_voice_websocket_accepted_total counter", + `z_platform_voice_websocket_accepted_total ${metrics.websocketAccepted}`, + "# TYPE z_platform_voice_websocket_rejected_total counter", + `z_platform_voice_websocket_rejected_total ${metrics.websocketRejected}`, + "# TYPE z_platform_voice_upstream_failures_total counter", + `z_platform_voice_upstream_failures_total ${metrics.upstreamFailures}`, + "", + ].join("\n"); + response.writeHead(200, { + "Content-Type": "text/plain; version=0.0.4; charset=utf-8", + "Cache-Control": "no-store", + }); + return response.end(body); + } + + if (request.method === "POST" && request.url === "/v1/voice/tickets") { + if (parseBearer(request.headers.authorization) !== serviceToken) { + metrics.ticketFailures += 1; + return sendJson(response, 401, { error: { code: "UNAUTHORIZED", message: "Invalid service token" } }); + } + + const identity = requestIdentity(request, allowAnonymous); + await readJson(request); // Parse and bound the body for forward-compatible metadata. + const { ticket, claims } = codec.issue({ + tenantId: identity.tenantId, + subjectId: identity.subjectId, + ttlSeconds: ticketTtlSeconds, + }); + metrics.ticketsIssued += 1; + log("info", "ticket_issued", { + tenant_id: identity.tenantId, + expires_at: claims.exp, + }); + return sendJson(response, 201, { + ticket, + expires_at: new Date(claims.exp * 1000).toISOString(), + websocket_url: publicWebSocketUrl, + ticket_transport: "sec-websocket-protocol", + }); + } + + return sendJson(response, 404, { error: { code: "NOT_FOUND", message: "Route not found" } }); + } catch (error) { + metrics.ticketFailures += 1; + log("warn", "request_rejected", { message: error instanceof Error ? error.message : "Request failed" }); + return sendJson(response, 400, { + error: { code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Request failed" }, + }); + } + }; + + const server = createServer(requestHandler); + + server.on("upgrade", (request, socket, head) => { + const ip = clientIp(request); + try { + if (!request.url?.startsWith("/v1/realtime")) { + metrics.websocketRejected += 1; + return closeSocket(socket, "404 Not Found", "Not found"); + } + if (activeSessions >= maxSessions || (sessionsByIp.get(ip) || 0) >= maxSessionsPerIp) { + metrics.websocketRejected += 1; + return closeSocket(socket, "429 Too Many Requests", "Voice session limit reached"); + } + + const { ticket, forwardedProtocols } = extractTicket(request); + const claims = codec.verify(ticket); + activeSessions += 1; + sessionsByIp.set(ip, (sessionsByIp.get(ip) || 0) + 1); + metrics.websocketAccepted += 1; + + let released = false; + const release = () => { + if (released) return; + released = true; + activeSessions = Math.max(0, activeSessions - 1); + const remaining = Math.max(0, (sessionsByIp.get(ip) || 1) - 1); + if (remaining) sessionsByIp.set(ip, remaining); + else sessionsByIp.delete(ip); + log("info", "session_closed", { + tenant_id: claims.tenant_id, + active_sessions: activeSessions, + }); + }; + + const upstreamSocket = connectTcp( + { + host: upstream.hostname, + port: Number(upstream.port || 80), + }, + () => { + upstreamSocket.write(serializeUpgradeRequest(request, upstream, claims, forwardedProtocols)); + if (head?.length) upstreamSocket.write(head); + socket.pipe(upstreamSocket); + upstreamSocket.pipe(socket); + log("info", "session_opened", { + tenant_id: claims.tenant_id, + active_sessions: activeSessions, + }); + }, + ); + + socket.setTimeout(0); + upstreamSocket.setTimeout(0); + socket.on("close", release); + socket.on("error", release); + upstreamSocket.on("close", release); + upstreamSocket.on("error", (error) => { + metrics.upstreamFailures += 1; + log("error", "upstream_socket_error", { message: error.message }); + if (!socket.destroyed) socket.destroy(); + release(); + }); + } catch (error) { + metrics.websocketRejected += 1; + log("warn", "websocket_rejected", { ip, message: error instanceof Error ? error.message : "Rejected" }); + closeSocket(socket, "401 Unauthorized", "Invalid or expired voice ticket"); + } + }); + + return { server, codec, metrics }; +} + +export function startVoiceGateway(options = {}) { + const env = options.env || process.env; + const { server } = createVoiceGateway(options); + const host = env.HOST || "0.0.0.0"; + const port = Number(env.PORT || 8450); + server.listen(port, host, () => log("info", "listening", { host, port })); + return server; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + startVoiceGateway(); +} diff --git a/services/voice-gateway/test/tickets.test.mjs b/services/voice-gateway/test/tickets.test.mjs new file mode 100644 index 0000000..905fc24 --- /dev/null +++ b/services/voice-gateway/test/tickets.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTicketCodec, createVoiceGateway, parseBearer } from "../index.mjs"; + +const TEST_ENV = { + Z_PLATFORM_SERVICE_TOKEN: "service-token", + VOICE_TICKET_SECRET: "a".repeat(64), + VOICE_AGENT_URL: "http://127.0.0.1:65534", + VOICE_PUBLIC_WS_URL: "ws://127.0.0.1:8450/v1/realtime", + VOICE_ALLOW_ANONYMOUS: "false", +}; + +test("parseBearer accepts a bearer token", () => { + assert.equal(parseBearer("Bearer secret"), "secret"); + assert.equal(parseBearer("basic secret"), null); +}); + +test("ticket is signed, expires, and is single-use", () => { + let now = 1_700_000_000_000; + const codec = createTicketCodec("a".repeat(64), { now: () => now }); + const issued = codec.issue({ tenantId: "tenant-1", subjectId: "user-1", ttlSeconds: 60 }); + + const claims = codec.verify(issued.ticket); + assert.equal(claims.tenant_id, "tenant-1"); + assert.equal(claims.subject_id, "user-1"); + assert.throws(() => codec.verify(issued.ticket), /already used/); + + const second = codec.issue({ tenantId: "tenant-1", subjectId: "user-1", ttlSeconds: 10 }); + now += 11_000; + assert.throws(() => codec.verify(second.ticket), /Expired/); +}); + +test("tampered tickets are rejected", () => { + const codec = createTicketCodec("b".repeat(64)); + const issued = codec.issue({ tenantId: "tenant-1", subjectId: "user-1" }); + assert.throws(() => codec.verify(`${issued.ticket}x`), /signature/); +}); + +test("ticket endpoint requires service authentication and returns browser-safe fields", async (t) => { + const { server, codec } = createVoiceGateway({ env: TEST_ENV }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const address = server.address(); + const endpoint = `http://127.0.0.1:${address.port}/v1/voice/tickets`; + + const unauthorized = await fetch(endpoint, { method: "POST", body: "{}" }); + assert.equal(unauthorized.status, 401); + + const authorized = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: "Bearer service-token", + "Content-Type": "application/json", + "X-Tenant-Id": "tenant-1", + "X-Subject-Id": "user-1", + }, + body: "{}", + }); + assert.equal(authorized.status, 201); + const payload = await authorized.json(); + assert.equal(payload.websocket_url, TEST_ENV.VOICE_PUBLIC_WS_URL); + assert.equal(payload.ticket_transport, "sec-websocket-protocol"); + assert.equal(JSON.stringify(payload).includes(TEST_ENV.Z_PLATFORM_SERVICE_TOKEN), false); + + const claims = codec.verify(payload.ticket, { consume: false }); + assert.equal(claims.tenant_id, "tenant-1"); + assert.equal(claims.subject_id, "user-1"); +});