mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat(voice): pluggable voice backend with Gemini Live & Qwen Realtime (#692)
* feat(voice): pluggable voice backend with Gemini Live & Qwen Realtime Rebased from Overbaker/hapi#401 onto current main. Adds a pluggable voice backend architecture that extends the existing ElevenLabs integration: - Gemini 2.5 Live (gemini-live): Google real-time audio via WebSocket with full function calling (messageCodingAgent, processPermissionRequest) - Qwen Realtime (qwen-realtime): Alibaba DashScope via hub WebSocket proxy (browser cannot set Authorization header directly) - VoiceBackendSession: dynamic backend selector with React.lazy loading, gates voice button until backend module is registered - Hub WS proxies: JWT-authenticated /api/voice/gemini-ws and /api/voice/qwen-ws endpoints in Bun.serve, with message queueing during upstream connect to prevent dropped setup frames - AudioWorklet pipeline: inline Blob URL recorder, 24 kHz PCM player, serial tool call execution, AudioContext created in user gesture for mobile - Backend discovery: GET /voice/backend + POST /voice/gemini-token / POST /voice/qwen-token hub routes; frontend auto-detects active backend Merge notes: - Rebased 135 upstream commits cleanly; HappyComposer keeps upstream's configurable enter-behavior setting (supersedes hard-coded Ctrl+Enter) - Converted gemini test files from bun:test to vitest (web package uses vitest) - All 221 hub tests and 636 web tests pass; TypeScript clean * fix(voice): restore user mic mute state after Gemini turn completes turnComplete handler was unconditionally calling setMuted(false), which re-enabled the mic track even when the user had manually muted. Now restores to state.micMuted instead. * fix(voice): remove hard-coded Chinese language from Gemini backend buildGeminiLiveConfig was appending VOICE_CHINESE_LANGUAGE_BLOCK which forced Gemini to always respond in Mandarin regardless of user locale. Gemini now uses the neutral base prompt and responds in the language the user speaks to it, consistent with the ElevenLabs behaviour. * fix(voice): reset modelSpeaking in cleanup to unblock mic on restart If the session closes while Gemini is mid-speech, cleanup() left state.modelSpeaking=true. The next startSession() would then drop all mic audio in sendAudioChunk() until a model turn eventually flipped the flag — effectively deaf until page reload. * fix(voice): guard stale close handlers in Gemini and Qwen sessions ws.onclose operated on module-level state.ws, not the socket that fired the event. A rapid stop/restart could cause the old socket's onclose to call cleanup() after the new socket was assigned, tearing down the live session. Guard with `if (state.ws !== ws) return` before cleanup. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): remove hard-coded Chinese language from Qwen backend Matches the Gemini fix — both backends now use VOICE_SYSTEM_PROMPT without the Chinese language block, giving consistent English-default behaviour across all non-ElevenLabs backends. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * feat(voice): proactive/reactive toggle in voice settings Adds a "Proactive voice" toggle (default: off = reactive) to the Voice Assistant settings section. Reactive (default): initial context and agent-ready events are fed silently; the assistant waits for the user to speak first. Proactive: original behaviour — Gemini/Qwen narrate context on connect and speak unprompted when the agent finishes a task. ElevenLabs is also affected via onReady sending a user message rather than a silent update. Covers all three backends uniformly. localStorage key: hapi-voice-proactive. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): normalize WS close codes, drop barrel re-exports, fix SSE visibility - hub/server.ts: add toClientCloseCode() to normalize reserved upstream close codes (1005/1006/1015) to 1011 before forwarding to browser; abnormal upstream drops (1006) would otherwise throw on clientWs.close() and leave the browser socket open - realtime/index.ts: remove static GeminiLiveVoiceSession and QwenVoiceSession barrel exports; VoiceBackendSession lazy-imports both, so barrel re-exports created static dependencies that defeated the intended code-split - App.tsx: gate global useVisibilityReporter on !sessionEventSubscription so the always-on SSE connection does not suppress native Web Push notifications for sessions the user is not currently viewing via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): respect language setting in Gemini/Qwen; fix voice-start toggle label - buildGeminiLiveConfig() now accepts optional language param; appends VOICE_CHINESE_LANGUAGE_BLOCK only when language === 'zh' - GeminiLiveVoiceSession passes config.language through - QwenVoiceSession conditionally builds basePrompt from language setting - Fixes silent no-op when user selects Chinese in voice settings on Gemini/Qwen backends (was ElevenLabs-only) - Rename voice-start toggle label to 'Start voice session with summary' - Fix description: clarifies the choice is about session-open behaviour (summary vs greeting), not ongoing narration via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): send greeting trigger in reactive mode for Gemini Gemini Live has no built-in first-message like ElevenLabs agents do; without an explicit turnComplete:true it sits silently. In reactive mode (default, toggle off) now sends a greeting instruction after any silent context feed so Gemini introduces itself and invites the user to speak. Proactive mode is unchanged: the context summary is the opening speech. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): suppress Gemini self-identification and context leak in greeting - VOICE_SYSTEM_PROMPT: explicit instruction never to call itself Gemini, Google, or any underlying model/provider name — always HAPI - Greeting trigger text: instruct to greet as HAPI only, suppress model name and any reference to context/recent activity in the opening line via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): address code review findings — error handling, proxy, audio Gemini + Qwen client: - onerror now sets setupDone/sessionReady and nulls state.ws before calling reject(), so the stale-close guard trips in onclose and prevents a duplicate statusCallback('error') on WS failure Gemini client: - Proactive mode with no initialContext now falls through to the greeting trigger instead of sitting silently - Remove unused handleBargeIn callback (dead code) Qwen client: - Add input_audio_sample_rate: 16000 to session.update so PCM rate is declared explicitly rather than relying on DashScope's default Hub proxy: - Remove no-op ternary in Gemini flush loop and message handler (typeof x === 'string' ? x : x); use upstream.send(msg) directly - Qwen onerror now calls upstreamMap.delete() before closing client, eliminating the stale map entry window - Align Qwen hub fallback model string with QWEN_REALTIME_MODEL constant ('qwen3-omni-flash-realtime') via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): trailing-slash WS URL, Qwen session.update schema hub/voice.ts: - Replace string-concat WS URL construction with buildVoiceWsUrl() which uses URL API to set protocol/pathname cleanly — fixes double-slash when HAPI_PUBLIC_URL has a trailing slash (would silently skip the proxy route) QwenVoiceSession.tsx: - Wrap tool definitions in {type:'function', function:{...}} as required by Qwen-Omni realtime schema — previous flat shape caused session.update rejection before audio capture could start - Use pcm16/pcm24 audio formats matching DashScope spec; remove input_audio_sample_rate (encoded in format name) via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): await audio capture before setMuted; sanitize upstream close codes GeminiLiveVoiceSession + QwenVoiceSession: - startAudioCapture() is now async and awaits recorder.start() before calling setMuted() — previously setMuted ran before getUserMedia resolved so a session restarted while muted would open the mic anyway - statusCallback('connected') now fires after audio is ready - setMuted() called unconditionally (not just when true) to correctly apply saved state in either direction hub/src/web/server.ts: - Both Gemini and Qwen close() handlers now pass the client code through toClientCloseCode() before forwarding to upstream — prevents reserved codes (e.g. 1006) from causing WebSocket.close() to throw and leave the upstream session open until provider timeout - Reason string capped at 123 bytes (WebSocket protocol limit) via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): wrap startAudioCapture in try/catch to propagate mic errors An unhandled rejection inside the async onmessage callback does not propagate to the outer startSession Promise — the UI hangs on 'connecting' and the provider socket stays partially open. Wrapping the await in try/catch calls cleanup()/statusCallback('error')/reject() so failures surface correctly. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): propagate backend discovery failure instead of silently falling back to ElevenLabs fetchVoiceBackend no longer catches errors and defaults to 'elevenlabs' — any network or server failure now throws so VoiceBackendSession can surface it via onStatusChange('error', ...) rather than silently mounting the wrong backend. VoiceBackendSession also resets backend state to null when api changes, so a stale ElevenLabs registration from a prior discovery cannot persist into a new session. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): throw on unrecognised backend value instead of silently falling back to ElevenLabs Unknown backend strings (future values, typos) now throw rather than defaulting to elevenlabs, closing the narrow remaining form of the original misrouting bug. Also removes the unnecessary `as VoiceBackendResponse` cast. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): add Qwen greeting/proactive trigger; fix socket buffer for base64 uploads Qwen session.updated handler now sends the same proactive summary or greeting trigger that Gemini does — previously it started silently in both proactive and reactive modes. maxHttpBufferSize raised to 68 MiB to account for base64 expansion: 50 MiB decoded files become ~66.7 MiB as base64 JSON, so the previous 55 MiB ceiling would disconnect uploads above ~41 MiB before they reached the CLI. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): replace unsupported conversation.item.create with session.update for Qwen text Qwen's realtime API only supports conversation.item.create for function_call_output. Sending it with type:'message' for greetings/context was invalid and could fail before the user spoke. sendTextMessage and sendContextualUpdate now update session instructions via session.update (accumulating context into the system prompt) and trigger response.create only when a spoken reply is needed — matching Qwen's supported client event surface. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): guard session.updated re-entry and reset config on session start session.updated now returns early after the first ack — subsequent session.update calls (instruction appends) also echo session.updated but must not re-trigger audio capture or the greeting path. currentSessionConfig is now reset to null at the top of startSession so a stale config from a failed previous session cannot leak into the new one. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): assert wsUrl presence for Gemini proxy connections Without this guard, a missing wsUrl in the hub token response would silently attempt to connect directly to Google with "proxied" as the API key — producing a confusing auth failure instead of a clear error. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): correct Qwen audio formats and default voice DashScope realtime API accepts only 'pcm' for both input and output audio formats. The pcm16/pcm24 values caused session.update rejection before audio capture could start, leaving the Qwen backend unusable. Also updates the default voice from Mia (not in the qwen3-omni-flash- realtime voice list) to Cherry, which is documented as supported. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): close AudioContext on failed voice session start Failed token fetch, microphone denial, or WebSocket error during setup left state.playbackContext open. Each failure path now calls cleanup() before throwing/rejecting, preventing AudioContext leaks on mobile browsers with hard limits on concurrent contexts. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * chore: restore non-voice files to upstream/main state Reverts changes to files that shouldn't differ from upstream: - .gitignore: remove fork-only AGENTS.local.md entry - web/src/App.tsx: restore dual-subscription SSE pattern (scope-aware) - web/src/hooks/useSSE.ts: restore SSEScope/scope parameter - web/src/hooks/useSSE.test.ts: restore (was accidentally deleted) - web/src/lib/appSseSubscriptions.ts: restore (was accidentally deleted) - web/src/lib/appSseSubscriptions.test.ts: restore (was accidentally deleted) - hub/src/sync/syncEngine.ts: restore (off-topic change) * fix(voice): harden Gemini and Qwen WS proxies against client abuse Hub sends HAPI-owned Gemini setup on proxy connect and rejects client setup frames. Qwen proxy always uses QWEN_REALTIME_MODEL instead of a client query parameter. Shared buildGeminiLiveSetupMessage() keeps wire format in one place. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(voice): harden Qwen proxy — hub-owned setup, client frame allowlist Mirror the Gemini proxy security model for Qwen: - Hub sends initial session.update (voice/tools/instructions) on upstream connect so the browser cannot override config fields. - Proxy message() now calls isQwenSafeClientFrame() and closes the connection (1008) if a client session.update touches any field other than 'instructions' (blocks tool/voice/modality overrides). - QwenVoiceSession no longer sends session.update on session.created; it waits for the hub-relayed session.updated and then sends only instruction-only updates for context/proactive content. - Language passed as query param (?language=zh) so hub builds the correct Chinese system prompt without a client-supplied session.update. - buildQwenSessionUpdateMessage() and isQwenSafeClientFrame() added to @hapi/protocol/voice; 9 new unit tests cover filter edge cases. * fix(voice): respect Qwen session.created→session.update protocol ordering DashScope requires session.update to be sent AFTER session.created is received, not immediately on WebSocket open. Previously the hub sent session.update in upstream.onopen, which violated this ordering and risked the config being processed in an uninitialized session context. Add pendingSetupMap to buffer the hub-owned session.update payload. The onmessage handler now relays session.created to the browser first, then immediately sends the pending session.update to DashScope — matching the protocol ordering the old browser-side code used (which waited for session.created before sending session.update). Also remove maxHttpBufferSize from the socket.io Engine config. That setting is unrelated to voice backends; upstream/main had no such limit set and it is not introduced by this PR. * fix(voice): use Realtime tool shape for Qwen session.update (not chat-completions) Qwen Realtime session.update expects tools as flat objects: { type: 'function', name, description, parameters } The previous code used the chat-completions shape: { type: 'function', function: { name, description, parameters } } DashScope may reject session.update or silently ignore tools with the nested shape, causing tool calls to fail at runtime. Fix applied in buildQwenSessionUpdateMessage(); test updated to assert flat shape and that no nested `function` key is present. * fix(voice): update Qwen Realtime model, voice, and endpoint for intl service Live-tested against DashScope international API: - Model: qwen3-omni-flash-realtime → qwen3.5-omni-flash-realtime (previous model ID did not exist on DashScope) - Default voice: Cherry → Tina (confirmed from session.created response on qwen3.5-omni-flash-realtime) - Default WS base: dashscope.aliyuncs.com → dashscope-intl.aliyuncs.com (international accounts use the -intl endpoint; China endpoint rejects international API keys; QWEN_REALTIME_WS_URL env var still overrides) * fix(voice): correct Qwen text injection and generalise language handling Two dogfooding fixes verified against live Qwen Realtime session: sendTextMessage: switch from instruction-injection to conversation.item.create Qwen Realtime requires a user conversation item before response.create. The previous approach (updateInstructions + response.create) produced "input messages do not contain elements with role user" errors. Now sends {type:message, role:user, content:[{type:input_text}]} then response.create. sendContextualUpdate is unchanged (instruction-only, no response trigger). Language handling: replace zh-only branch with buildVoiceLanguageBlock() Previously, only language='zh' added any instruction; all other languages (including English) sent no language block, causing Qwen to drift to Chinese. buildVoiceLanguageBlock() now covers three cases: - 'zh'/'zh-*': existing Chinese block (unchanged) - explicit code ('en','es','fr',...): "Always respond in [Language]" - undefined/auto: "Detect the user's language and maintain it" Applied to buildGeminiLiveConfig, buildQwenSessionUpdateMessage, and the client-side currentInstructions mirror in QwenVoiceSession. Also removes the Gemini hub proxy's zh-only filter, which was discarding explicit language selections other than Chinese. * fix(hub): gate Gemini client frames until upstream setupComplete Hub sends its owned setup on upstream open, then waits for Google's setupComplete acknowledgment before flushing queued client frames. isGeminiSetupCompleteFrame() detects the {"setupComplete":{}} message; message() queues instead of forwarding while pendingMap is live. Addresses the repeated Major finding from bot review on PR #743. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(hub): cap Gemini setup-window pending queue at 1 MiB An authenticated client could flood the queue between upstream.onopen and Google's setupComplete acknowledgment. Add pendingBytesMap tracking and close with 1009 if the budget is exceeded. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(gemini): pass all language codes to hub proxy, not just zh Language selection for French, Spanish, Japanese etc. was silently dropped — only 'zh' was forwarded as a query param. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(voice): expand LANGUAGE_NAMES to cover full ElevenLabs language set Codes like 'no', 'da', 'fi', 'pt-br', 'bg', 'ro', 'cs', 'el', 'ms', 'tl', 'uk', 'hu', 'hr', 'sk' were falling through to raw-code prompts ("Always respond in no"). Now resolve to proper display names. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, mock } from 'bun:test'
|
import { describe, expect, it, mock, test, afterEach } from 'bun:test'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { SignJWT } from 'jose'
|
import { SignJWT } from 'jose'
|
||||||
import type { WebAppEnv } from '../middleware/auth'
|
import type { WebAppEnv } from '../middleware/auth'
|
||||||
@@ -188,3 +188,155 @@ describe('POST /api/voice/token', () => {
|
|||||||
else delete process.env.ELEVENLABS_AGENT_ID
|
else delete process.env.ELEVENLABS_AGENT_ID
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GET /api/voice/backend', () => {
|
||||||
|
const originalEnv = process.env.VOICE_BACKEND
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.VOICE_BACKEND
|
||||||
|
} else {
|
||||||
|
process.env.VOICE_BACKEND = originalEnv
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns elevenlabs by default', async () => {
|
||||||
|
delete process.env.VOICE_BACKEND
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/backend', { headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json() as { backend: string }
|
||||||
|
expect(body.backend).toBe('elevenlabs')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns gemini-live when configured', async () => {
|
||||||
|
process.env.VOICE_BACKEND = 'gemini-live'
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/backend', { headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json() as { backend: string }
|
||||||
|
expect(body.backend).toBe('gemini-live')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns qwen-realtime when configured', async () => {
|
||||||
|
process.env.VOICE_BACKEND = 'qwen-realtime'
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/backend', { headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json() as { backend: string }
|
||||||
|
expect(body.backend).toBe('qwen-realtime')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to elevenlabs for unknown values', async () => {
|
||||||
|
process.env.VOICE_BACKEND = 'unknown-backend'
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/backend', { headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json() as { backend: string }
|
||||||
|
expect(body.backend).toBe('elevenlabs')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/voice/gemini-token', () => {
|
||||||
|
const origGemini = process.env.GEMINI_API_KEY
|
||||||
|
const origGoogle = process.env.GOOGLE_API_KEY
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (origGemini === undefined) delete process.env.GEMINI_API_KEY
|
||||||
|
else process.env.GEMINI_API_KEY = origGemini
|
||||||
|
if (origGoogle === undefined) delete process.env.GOOGLE_API_KEY
|
||||||
|
else process.env.GOOGLE_API_KEY = origGoogle
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns 400 when no API key configured', async () => {
|
||||||
|
delete process.env.GEMINI_API_KEY
|
||||||
|
delete process.env.GOOGLE_API_KEY
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/gemini-token', { method: 'POST', headers })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json() as { allowed: boolean; error: string }
|
||||||
|
expect(body.allowed).toBe(false)
|
||||||
|
expect(body.error).toContain('not configured')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns proxied wsUrl when GEMINI_API_KEY is set', async () => {
|
||||||
|
process.env.GEMINI_API_KEY = 'test-gemini-key'
|
||||||
|
delete process.env.GOOGLE_API_KEY
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/gemini-token', { method: 'POST', headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json() as { allowed: boolean; apiKey: string; wsUrl: string }
|
||||||
|
expect(body.allowed).toBe(true)
|
||||||
|
expect(body.apiKey).toBe('proxied')
|
||||||
|
expect(body.wsUrl).toContain('/api/voice/gemini-ws')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to GOOGLE_API_KEY', async () => {
|
||||||
|
delete process.env.GEMINI_API_KEY
|
||||||
|
process.env.GOOGLE_API_KEY = 'test-google-key'
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/gemini-token', { method: 'POST', headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json() as { allowed: boolean; apiKey: string; wsUrl: string }
|
||||||
|
expect(body.allowed).toBe(true)
|
||||||
|
expect(body.apiKey).toBe('proxied')
|
||||||
|
expect(body.wsUrl).toContain('/api/voice/gemini-ws')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/voice/qwen-token', () => {
|
||||||
|
const origDash = process.env.DASHSCOPE_API_KEY
|
||||||
|
const origQwen = process.env.QWEN_API_KEY
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (origDash === undefined) delete process.env.DASHSCOPE_API_KEY
|
||||||
|
else process.env.DASHSCOPE_API_KEY = origDash
|
||||||
|
if (origQwen === undefined) delete process.env.QWEN_API_KEY
|
||||||
|
else process.env.QWEN_API_KEY = origQwen
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns 400 when no API key configured', async () => {
|
||||||
|
delete process.env.DASHSCOPE_API_KEY
|
||||||
|
delete process.env.QWEN_API_KEY
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/qwen-token', { method: 'POST', headers })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = await res.json() as { allowed: boolean; error: string }
|
||||||
|
expect(body.allowed).toBe(false)
|
||||||
|
expect(body.error).toContain('not configured')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns wsUrl when DASHSCOPE_API_KEY is set (no raw key exposed)', async () => {
|
||||||
|
process.env.DASHSCOPE_API_KEY = 'test-dash-key'
|
||||||
|
delete process.env.QWEN_API_KEY
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/qwen-token', { method: 'POST', headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json() as { allowed: boolean; wsUrl: string }
|
||||||
|
expect(body.allowed).toBe(true)
|
||||||
|
expect(body.wsUrl).toContain('/api/voice/qwen-ws')
|
||||||
|
expect(body).not.toHaveProperty('apiKey')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to QWEN_API_KEY', async () => {
|
||||||
|
delete process.env.DASHSCOPE_API_KEY
|
||||||
|
process.env.QWEN_API_KEY = 'test-qwen-key'
|
||||||
|
const app = createApp()
|
||||||
|
const headers = await authHeaders()
|
||||||
|
const res = await app.request('/api/voice/qwen-token', { method: 'POST', headers })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json() as { allowed: boolean; wsUrl: string }
|
||||||
|
expect(body.allowed).toBe(true)
|
||||||
|
expect(body.wsUrl).toContain('/api/voice/qwen-ws')
|
||||||
|
expect(body).not.toHaveProperty('apiKey')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,8 +4,19 @@ import type { WebAppEnv } from '../middleware/auth'
|
|||||||
import {
|
import {
|
||||||
ELEVENLABS_API_BASE,
|
ELEVENLABS_API_BASE,
|
||||||
VOICE_AGENT_NAME,
|
VOICE_AGENT_NAME,
|
||||||
buildVoiceAgentConfig
|
buildVoiceAgentConfig,
|
||||||
|
DEFAULT_VOICE_BACKEND
|
||||||
} from '@hapi/protocol/voice'
|
} from '@hapi/protocol/voice'
|
||||||
|
import type { VoiceBackendType } from '@hapi/protocol/voice'
|
||||||
|
|
||||||
|
function buildVoiceWsUrl(base: string, pathname: string): string {
|
||||||
|
const url = new URL(base)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
url.pathname = pathname
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
const tokenRequestSchema = z.object({
|
const tokenRequestSchema = z.object({
|
||||||
customAgentId: z.string().optional(),
|
customAgentId: z.string().optional(),
|
||||||
@@ -166,6 +177,65 @@ async function getOrCreateAgentIdForVoice(apiKey: string, voiceId?: string): Pro
|
|||||||
export function createVoiceRoutes(): Hono<WebAppEnv> {
|
export function createVoiceRoutes(): Hono<WebAppEnv> {
|
||||||
const app = new Hono<WebAppEnv>()
|
const app = new Hono<WebAppEnv>()
|
||||||
|
|
||||||
|
// Return the configured voice backend type
|
||||||
|
app.get('/voice/backend', (c) => {
|
||||||
|
const raw = process.env.VOICE_BACKEND
|
||||||
|
const backend: VoiceBackendType =
|
||||||
|
raw === 'gemini-live' ? 'gemini-live'
|
||||||
|
: raw === 'qwen-realtime' ? 'qwen-realtime'
|
||||||
|
: DEFAULT_VOICE_BACKEND
|
||||||
|
return c.json({ backend })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get Gemini API key for Gemini Live voice sessions
|
||||||
|
// Gemini Live API does not support ephemeral tokens, so we proxy the key.
|
||||||
|
// The key is short-lived in the browser session and never persisted client-side.
|
||||||
|
app.post('/voice/gemini-token', async (c) => {
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
return c.json({
|
||||||
|
allowed: false,
|
||||||
|
error: 'Gemini API key not configured (set GEMINI_API_KEY or GOOGLE_API_KEY)'
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use server-side WS proxy to avoid region restrictions.
|
||||||
|
// The proxy at /api/voice/gemini-ws handles the API key server-side.
|
||||||
|
// Derive wsUrl from the request origin so remote browsers connect back to the hub,
|
||||||
|
// not to localhost. HAPI_PUBLIC_URL overrides when set (e.g. behind a reverse proxy).
|
||||||
|
const requestOrigin = new URL(c.req.url).origin
|
||||||
|
const publicUrl = process.env.HAPI_PUBLIC_URL || requestOrigin
|
||||||
|
const wsProxyUrl = buildVoiceWsUrl(publicUrl, '/api/voice/gemini-ws')
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
allowed: true,
|
||||||
|
apiKey: 'proxied', // Dummy — key is handled server-side
|
||||||
|
wsUrl: wsProxyUrl, // Always proxy — env WS URLs are upstream-only (server-side)
|
||||||
|
baseUrl: process.env.GEMINI_API_BASE || undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check Qwen (DashScope) availability for Qwen Realtime voice sessions
|
||||||
|
// The actual API key is never sent to the browser — it stays server-side in the WS proxy.
|
||||||
|
app.post('/voice/qwen-token', async (c) => {
|
||||||
|
const apiKey = process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
return c.json({
|
||||||
|
allowed: false,
|
||||||
|
error: 'DashScope API key not configured (set DASHSCOPE_API_KEY or QWEN_API_KEY)'
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestOrigin = new URL(c.req.url).origin
|
||||||
|
const publicUrl = process.env.HAPI_PUBLIC_URL || requestOrigin
|
||||||
|
const wsProxyUrl = buildVoiceWsUrl(publicUrl, '/api/voice/qwen-ws')
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
allowed: true,
|
||||||
|
wsUrl: wsProxyUrl // Always proxy — env WS URLs are upstream-only (server-side)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Get ElevenLabs ConvAI conversation token
|
// Get ElevenLabs ConvAI conversation token
|
||||||
app.post('/voice/token', async (c) => {
|
app.post('/voice/token', async (c) => {
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = crypto.randomUUID()
|
||||||
|
|||||||
+294
-5
@@ -6,6 +6,7 @@ import { existsSync } from 'node:fs'
|
|||||||
import { serveStatic } from 'hono/bun'
|
import { serveStatic } from 'hono/bun'
|
||||||
import { getConfiguration } from '../configuration'
|
import { getConfiguration } from '../configuration'
|
||||||
import { PROTOCOL_VERSION } from '@hapi/protocol'
|
import { PROTOCOL_VERSION } from '@hapi/protocol'
|
||||||
|
import { buildGeminiLiveSetupMessage, buildQwenSessionUpdateMessage, isQwenSafeClientFrame, QWEN_REALTIME_MODEL } from '@hapi/protocol/voice'
|
||||||
import type { SyncEngine } from '../sync/syncEngine'
|
import type { SyncEngine } from '../sync/syncEngine'
|
||||||
import { createAuthMiddleware, type WebAppEnv } from './middleware/auth'
|
import { createAuthMiddleware, type WebAppEnv } from './middleware/auth'
|
||||||
import { createAuthRoutes } from './routes/auth'
|
import { createAuthRoutes } from './routes/auth'
|
||||||
@@ -22,13 +23,216 @@ import { createPushRoutes } from './routes/push'
|
|||||||
import { createVoiceRoutes } from './routes/voice'
|
import { createVoiceRoutes } from './routes/voice'
|
||||||
import type { SSEManager } from '../sse/sseManager'
|
import type { SSEManager } from '../sse/sseManager'
|
||||||
import type { VisibilityTracker } from '../visibility/visibilityTracker'
|
import type { VisibilityTracker } from '../visibility/visibilityTracker'
|
||||||
import type { Server as BunServer } from 'bun'
|
import type { Server as BunServer, ServerWebSocket } from 'bun'
|
||||||
import type { Server as SocketEngine } from '@socket.io/bun-engine'
|
import type { Server as SocketEngine } from '@socket.io/bun-engine'
|
||||||
|
import { jwtVerify } from 'jose'
|
||||||
import type { WebSocketData } from '@socket.io/bun-engine'
|
import type { WebSocketData } from '@socket.io/bun-engine'
|
||||||
import { loadEmbeddedAssetMap, type EmbeddedWebAsset } from './embeddedAssets'
|
import { loadEmbeddedAssetMap, type EmbeddedWebAsset } from './embeddedAssets'
|
||||||
import { isBunCompiled } from '../utils/bunCompiled'
|
import { isBunCompiled } from '../utils/bunCompiled'
|
||||||
import type { Store } from '../store'
|
import type { Store } from '../store'
|
||||||
|
|
||||||
|
// Normalise upstream close codes before forwarding to the browser client.
|
||||||
|
// Codes 1005/1006/1015 are reserved and cannot be sent in a close frame;
|
||||||
|
// abnormal upstream drops commonly produce 1006, which would throw on clientWs.close().
|
||||||
|
function toClientCloseCode(code: number): number {
|
||||||
|
return code >= 1000 && code <= 4999 && code !== 1005 && code !== 1006 && code !== 1015
|
||||||
|
? code
|
||||||
|
: 1011
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeWsText(message: string | ArrayBuffer | Uint8Array): string {
|
||||||
|
if (typeof message === 'string') return message
|
||||||
|
const bytes = message instanceof Uint8Array ? message : new Uint8Array(message)
|
||||||
|
return new TextDecoder().decode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGeminiSetupFrame(message: string | ArrayBuffer | Uint8Array): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(decodeWsText(message)) as unknown
|
||||||
|
return parsed !== null && typeof parsed === 'object' && 'setup' in (parsed as object)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGeminiSetupCompleteFrame(message: string | ArrayBuffer | Uint8Array): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(decodeWsText(message)) as unknown
|
||||||
|
return parsed !== null && typeof parsed === 'object' && 'setupComplete' in (parsed as object)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_GEMINI_PENDING_BYTES = 1024 * 1024 // 1 MiB — rejects setup-window floods
|
||||||
|
function frameByteSize(msg: string | ArrayBuffer | Uint8Array): number {
|
||||||
|
return typeof msg === 'string' ? msg.length : (msg as ArrayBuffer | Uint8Array).byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini Live WebSocket proxy — relays browser WS to Google, bypassing region restrictions
|
||||||
|
function createGeminiProxyWebSocketHandler() {
|
||||||
|
const GEMINI_WS_BASE = 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
|
||||||
|
const upstreamMap = new WeakMap<ServerWebSocket<unknown>, WebSocket>()
|
||||||
|
// pendingMap holds queued client frames until Google acknowledges setup via setupComplete.
|
||||||
|
// Flushed on setupComplete; until then message() queues rather than forwards.
|
||||||
|
const pendingMap = new WeakMap<ServerWebSocket<unknown>, Array<string | ArrayBuffer | Uint8Array>>()
|
||||||
|
const pendingBytesMap = new WeakMap<ServerWebSocket<unknown>, number>()
|
||||||
|
|
||||||
|
return {
|
||||||
|
open(clientWs: ServerWebSocket<unknown>) {
|
||||||
|
const data = clientWs.data as { _geminiProxy: boolean; apiKey: string; language?: string }
|
||||||
|
const upstreamUrl = `${process.env.GEMINI_LIVE_WS_URL || GEMINI_WS_BASE}?key=${encodeURIComponent(data.apiKey)}`
|
||||||
|
const pending: Array<string | ArrayBuffer | Uint8Array> = []
|
||||||
|
pendingMap.set(clientWs, pending)
|
||||||
|
pendingBytesMap.set(clientWs, 0)
|
||||||
|
|
||||||
|
const upstream = new WebSocket(upstreamUrl)
|
||||||
|
upstreamMap.set(clientWs, upstream)
|
||||||
|
|
||||||
|
upstream.onopen = () => {
|
||||||
|
// Hub-owned setup only — never forward client setup (prevents generic Gemini proxy abuse).
|
||||||
|
// Do NOT flush pending here: wait for Google's setupComplete before forwarding client frames.
|
||||||
|
upstream.send(JSON.stringify(buildGeminiLiveSetupMessage(data.language)))
|
||||||
|
}
|
||||||
|
upstream.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
if (clientWs.readyState === 1) {
|
||||||
|
clientWs.send(typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer))
|
||||||
|
}
|
||||||
|
} catch { /* client gone */ }
|
||||||
|
// Flush queued client frames only after Google acknowledges setup.
|
||||||
|
const pending = pendingMap.get(clientWs)
|
||||||
|
if (pending && isGeminiSetupCompleteFrame(event.data as string | ArrayBuffer)) {
|
||||||
|
pendingMap.delete(clientWs)
|
||||||
|
pendingBytesMap.delete(clientWs)
|
||||||
|
for (const queued of pending) {
|
||||||
|
try { upstream.send(queued) } catch { /* upstream gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upstream.onerror = () => {
|
||||||
|
pendingMap.delete(clientWs)
|
||||||
|
pendingBytesMap.delete(clientWs)
|
||||||
|
try { clientWs.close(1011, 'Upstream error') } catch { /* */ }
|
||||||
|
}
|
||||||
|
upstream.onclose = (event) => {
|
||||||
|
pendingMap.delete(clientWs)
|
||||||
|
pendingBytesMap.delete(clientWs)
|
||||||
|
try { clientWs.close(toClientCloseCode(event.code), event.reason || 'Upstream closed') } catch { /* client gone */ }
|
||||||
|
upstreamMap.delete(clientWs)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message(clientWs: ServerWebSocket<unknown>, message: string | ArrayBuffer | Uint8Array) {
|
||||||
|
if (isGeminiSetupFrame(message)) {
|
||||||
|
try { clientWs.close(1008, 'Client-provided Gemini setup is not allowed') } catch { /* */ }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const upstream = upstreamMap.get(clientWs)
|
||||||
|
const pending = pendingMap.get(clientWs)
|
||||||
|
if (pending) {
|
||||||
|
// Still awaiting setupComplete — queue, but cap to prevent setup-window floods.
|
||||||
|
const total = (pendingBytesMap.get(clientWs) ?? 0) + frameByteSize(message)
|
||||||
|
if (total > MAX_GEMINI_PENDING_BYTES) {
|
||||||
|
try { clientWs.close(1009, 'Setup-window frame budget exceeded') } catch { /* */ }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingBytesMap.set(clientWs, total)
|
||||||
|
pending.push(message)
|
||||||
|
} else if (upstream?.readyState === WebSocket.OPEN) {
|
||||||
|
upstream.send(message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close(clientWs: ServerWebSocket<unknown>, code: number, reason: string) {
|
||||||
|
const upstream = upstreamMap.get(clientWs)
|
||||||
|
pendingMap.delete(clientWs)
|
||||||
|
pendingBytesMap.delete(clientWs)
|
||||||
|
if (upstream) {
|
||||||
|
try { upstream.close(toClientCloseCode(code), (reason || 'Client closed').slice(0, 123)) } catch { /* */ }
|
||||||
|
upstreamMap.delete(clientWs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qwen Realtime WebSocket proxy — bridges browser (no custom headers) to DashScope (requires Authorization header)
|
||||||
|
function createQwenProxyWebSocketHandler() {
|
||||||
|
const QWEN_WS_BASE = 'wss://dashscope-intl.aliyuncs.com/api-ws/v1/realtime'
|
||||||
|
const upstreamMap = new WeakMap<ServerWebSocket<unknown>, WebSocket>()
|
||||||
|
// Holds the hub-owned session.update payload until session.created arrives from DashScope.
|
||||||
|
// Sending session.update before session.created violates the Qwen Realtime protocol ordering.
|
||||||
|
const pendingSetupMap = new WeakMap<ServerWebSocket<unknown>, string>()
|
||||||
|
|
||||||
|
return {
|
||||||
|
open(clientWs: ServerWebSocket<unknown>) {
|
||||||
|
const data = clientWs.data as { apiKey: string; model: string; language?: string }
|
||||||
|
const upstreamUrl = `${process.env.QWEN_REALTIME_WS_URL || QWEN_WS_BASE}?model=${encodeURIComponent(data.model)}`
|
||||||
|
|
||||||
|
const upstream = new WebSocket(upstreamUrl, {
|
||||||
|
headers: { 'Authorization': `Bearer ${data.apiKey}` }
|
||||||
|
} as unknown as string[])
|
||||||
|
|
||||||
|
upstreamMap.set(clientWs, upstream)
|
||||||
|
pendingSetupMap.set(clientWs, JSON.stringify(buildQwenSessionUpdateMessage(data.language)))
|
||||||
|
|
||||||
|
upstream.onmessage = (event) => {
|
||||||
|
const raw = event.data
|
||||||
|
const text = typeof raw === 'string'
|
||||||
|
? raw
|
||||||
|
: new TextDecoder().decode(raw instanceof Uint8Array ? raw : new Uint8Array(raw as ArrayBuffer))
|
||||||
|
|
||||||
|
// Respect Qwen protocol ordering: relay session.created first, then send hub-owned
|
||||||
|
// session.update. DashScope must receive session.update after session.created.
|
||||||
|
const pendingSetup = pendingSetupMap.get(clientWs)
|
||||||
|
if (pendingSetup) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { type?: string }
|
||||||
|
if (parsed.type === 'session.created') {
|
||||||
|
pendingSetupMap.delete(clientWs)
|
||||||
|
try { if (clientWs.readyState === 1) clientWs.send(text) } catch { /* client gone */ }
|
||||||
|
upstream.send(pendingSetup)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* not JSON — relay as-is below */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (clientWs.readyState === 1) {
|
||||||
|
clientWs.send(typeof raw === 'string' ? raw : new Uint8Array(raw as ArrayBuffer))
|
||||||
|
}
|
||||||
|
} catch { /* client gone */ }
|
||||||
|
}
|
||||||
|
upstream.onerror = () => {
|
||||||
|
pendingSetupMap.delete(clientWs)
|
||||||
|
upstreamMap.delete(clientWs)
|
||||||
|
try { clientWs.close(1011, 'Upstream error') } catch { /* */ }
|
||||||
|
}
|
||||||
|
upstream.onclose = (event) => {
|
||||||
|
pendingSetupMap.delete(clientWs)
|
||||||
|
try { clientWs.close(toClientCloseCode(event.code), event.reason || 'Upstream closed') } catch { /* client gone */ }
|
||||||
|
upstreamMap.delete(clientWs)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message(clientWs: ServerWebSocket<unknown>, message: string | ArrayBuffer | Uint8Array) {
|
||||||
|
if (!isQwenSafeClientFrame(message)) {
|
||||||
|
try { clientWs.close(1008, 'Client session.update may only modify instructions') } catch { /* */ }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const upstream = upstreamMap.get(clientWs)
|
||||||
|
if (upstream?.readyState === WebSocket.OPEN) {
|
||||||
|
upstream.send(message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close(clientWs: ServerWebSocket<unknown>, code: number, reason: string) {
|
||||||
|
pendingSetupMap.delete(clientWs)
|
||||||
|
const upstream = upstreamMap.get(clientWs)
|
||||||
|
if (upstream) {
|
||||||
|
try { upstream.close(toClientCloseCode(code), (reason || 'Client closed').slice(0, 123)) } catch { /* */ }
|
||||||
|
upstreamMap.delete(clientWs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
|
function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
join(process.cwd(), '..', 'web', 'dist'),
|
join(process.cwd(), '..', 'web', 'dist'),
|
||||||
@@ -238,17 +442,102 @@ export async function startWebServer(options: {
|
|||||||
const configuration = getConfiguration()
|
const configuration = getConfiguration()
|
||||||
const socketHandler = options.socketEngine.handler()
|
const socketHandler = options.socketEngine.handler()
|
||||||
|
|
||||||
const server = Bun.serve({
|
// Wrap socket.io websocket handler to also support Gemini/Qwen proxy connections
|
||||||
|
const originalWsHandler = socketHandler.websocket
|
||||||
|
const geminiProxyHandler = createGeminiProxyWebSocketHandler()
|
||||||
|
const qwenProxyHandler = createQwenProxyWebSocketHandler()
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const server = (Bun.serve as any)({
|
||||||
hostname: configuration.listenHost,
|
hostname: configuration.listenHost,
|
||||||
port: configuration.listenPort,
|
port: configuration.listenPort,
|
||||||
idleTimeout: Math.max(30, socketHandler.idleTimeout),
|
idleTimeout: Math.max(30, socketHandler.idleTimeout),
|
||||||
maxRequestBodySize: Math.max(socketHandler.maxRequestBodySize, 68 * 1024 * 1024),
|
maxRequestBodySize: Math.max(socketHandler.maxRequestBodySize, 68 * 1024 * 1024),
|
||||||
websocket: socketHandler.websocket,
|
websocket: {
|
||||||
fetch: (req, server) => {
|
...originalWsHandler,
|
||||||
|
open(ws: unknown) {
|
||||||
|
const wsAny = ws as ServerWebSocket<{ _qwenProxy?: boolean; _geminiProxy?: boolean }>
|
||||||
|
if (wsAny.data?._geminiProxy) {
|
||||||
|
geminiProxyHandler.open(wsAny)
|
||||||
|
} else if (wsAny.data?._qwenProxy) {
|
||||||
|
qwenProxyHandler.open(wsAny)
|
||||||
|
} else {
|
||||||
|
originalWsHandler.open?.(ws as never)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message(ws: unknown, message: unknown) {
|
||||||
|
const wsAny = ws as ServerWebSocket<{ _qwenProxy?: boolean; _geminiProxy?: boolean }>
|
||||||
|
if (wsAny.data?._geminiProxy) {
|
||||||
|
geminiProxyHandler.message(wsAny, message as string)
|
||||||
|
} else if (wsAny.data?._qwenProxy) {
|
||||||
|
qwenProxyHandler.message(wsAny, message as string)
|
||||||
|
} else {
|
||||||
|
originalWsHandler.message?.(ws as never, message as never)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close(ws: unknown, code: number, reason: string) {
|
||||||
|
const wsAny = ws as ServerWebSocket<{ _qwenProxy?: boolean; _geminiProxy?: boolean }>
|
||||||
|
if (wsAny.data?._geminiProxy) {
|
||||||
|
geminiProxyHandler.close(wsAny, code, reason)
|
||||||
|
} else if (wsAny.data?._qwenProxy) {
|
||||||
|
qwenProxyHandler.close(wsAny, code, reason)
|
||||||
|
} else {
|
||||||
|
originalWsHandler.close?.(ws as never, code as never, reason as never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetch: async (req: Request, server: { upgrade: (req: Request, opts?: unknown) => boolean }) => {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
if (url.pathname.startsWith('/socket.io/')) {
|
if (url.pathname.startsWith('/socket.io/')) {
|
||||||
return socketHandler.fetch(req, server)
|
return socketHandler.fetch(req, server as never)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice WebSocket proxies — require JWT auth via query param
|
||||||
|
// (browser WebSocket API cannot set custom headers)
|
||||||
|
if (url.pathname === '/api/voice/gemini-ws' || url.pathname === '/api/voice/qwen-ws') {
|
||||||
|
const token = url.searchParams.get('token')
|
||||||
|
if (!token) {
|
||||||
|
return new Response('Missing authorization token', { status: 401 })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await jwtVerify(token, options.jwtSecret, { algorithms: ['HS256'] })
|
||||||
|
} catch {
|
||||||
|
return new Response('Invalid token', { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini Live WebSocket proxy
|
||||||
|
if (url.pathname === '/api/voice/gemini-ws') {
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response('Gemini API key not configured', { status: 400 })
|
||||||
|
}
|
||||||
|
const language = url.searchParams.get('language') ?? undefined
|
||||||
|
const upgraded = (server as unknown as { upgrade: (req: Request, opts: unknown) => boolean }).upgrade(req, {
|
||||||
|
data: { _geminiProxy: true, apiKey, language }
|
||||||
|
})
|
||||||
|
if (!upgraded) {
|
||||||
|
return new Response('WebSocket upgrade failed', { status: 500 })
|
||||||
|
}
|
||||||
|
return undefined as unknown as Response
|
||||||
|
}
|
||||||
|
// Qwen Realtime WebSocket proxy
|
||||||
|
if (url.pathname === '/api/voice/qwen-ws') {
|
||||||
|
const apiKey = process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY
|
||||||
|
const model = QWEN_REALTIME_MODEL
|
||||||
|
const language = url.searchParams.get('language') ?? undefined
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response('DashScope API key not configured', { status: 400 })
|
||||||
|
}
|
||||||
|
const upgraded = (server as unknown as { upgrade: (req: Request, opts: unknown) => boolean }).upgrade(req, {
|
||||||
|
data: { _qwenProxy: true, apiKey, model, language }
|
||||||
|
})
|
||||||
|
if (!upgraded) {
|
||||||
|
return new Response('WebSocket upgrade failed', { status: 500 })
|
||||||
|
}
|
||||||
|
return undefined as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
return app.fetch(req)
|
return app.fetch(req)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { buildGeminiLiveSetupMessage, buildQwenSessionUpdateMessage, isQwenSafeClientFrame, GEMINI_LIVE_MODEL, GEMINI_LIVE_VOICE, QWEN_REALTIME_VOICE } from './voice'
|
||||||
|
|
||||||
|
describe('buildGeminiLiveSetupMessage', () => {
|
||||||
|
test('locks model and voice to HAPI defaults', () => {
|
||||||
|
const msg = buildGeminiLiveSetupMessage()
|
||||||
|
expect(msg.setup.model).toBe(`models/${GEMINI_LIVE_MODEL}`)
|
||||||
|
const speech = msg.setup.generationConfig as {
|
||||||
|
speechConfig?: { voiceConfig?: { prebuiltVoiceConfig?: { voiceName?: string } } }
|
||||||
|
}
|
||||||
|
expect(speech.speechConfig?.voiceConfig?.prebuiltVoiceConfig?.voiceName).toBe(GEMINI_LIVE_VOICE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('appends Chinese block when language is zh', () => {
|
||||||
|
const en = buildGeminiLiveSetupMessage()
|
||||||
|
const zh = buildGeminiLiveSetupMessage('zh')
|
||||||
|
const enText = (en.setup.systemInstruction as { parts: Array<{ text: string }> }).parts[0].text
|
||||||
|
const zhText = (zh.setup.systemInstruction as { parts: Array<{ text: string }> }).parts[0].text
|
||||||
|
expect(zhText.length).toBeGreaterThan(enText.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildQwenSessionUpdateMessage', () => {
|
||||||
|
test('locks voice to HAPI default', () => {
|
||||||
|
const msg = buildQwenSessionUpdateMessage()
|
||||||
|
const session = msg.session as { voice: string }
|
||||||
|
expect(session.voice).toBe(QWEN_REALTIME_VOICE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('includes both tools', () => {
|
||||||
|
const msg = buildQwenSessionUpdateMessage()
|
||||||
|
// Realtime shape: flat {type, name, description, parameters} — NOT chat-completions {function:{...}}
|
||||||
|
const session = msg.session as { tools: Array<{ type: string; name: string }> }
|
||||||
|
const names = session.tools.map(t => t.name)
|
||||||
|
expect(names).toContain('messageCodingAgent')
|
||||||
|
expect(names).toContain('processPermissionRequest')
|
||||||
|
// Ensure no nested function key (would be wrong chat-completions shape)
|
||||||
|
session.tools.forEach(t => expect((t as Record<string, unknown>).function).toBeUndefined())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('appends Chinese block when language is zh', () => {
|
||||||
|
const en = buildQwenSessionUpdateMessage()
|
||||||
|
const zh = buildQwenSessionUpdateMessage('zh')
|
||||||
|
const enInstr = (en.session as { instructions: string }).instructions
|
||||||
|
const zhInstr = (zh.session as { instructions: string }).instructions
|
||||||
|
expect(zhInstr.length).toBeGreaterThan(enInstr.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isQwenSafeClientFrame', () => {
|
||||||
|
test('allows non-session.update frames', () => {
|
||||||
|
expect(isQwenSafeClientFrame(JSON.stringify({ type: 'input_audio_buffer.append', audio: 'abc' }))).toBe(true)
|
||||||
|
expect(isQwenSafeClientFrame(JSON.stringify({ type: 'response.create' }))).toBe(true)
|
||||||
|
expect(isQwenSafeClientFrame(JSON.stringify({ type: 'conversation.item.create', item: {} }))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows session.update with only instructions', () => {
|
||||||
|
expect(isQwenSafeClientFrame(JSON.stringify({
|
||||||
|
type: 'session.update',
|
||||||
|
session: { instructions: 'updated prompt' }
|
||||||
|
}))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocks session.update that includes tools', () => {
|
||||||
|
expect(isQwenSafeClientFrame(JSON.stringify({
|
||||||
|
type: 'session.update',
|
||||||
|
session: { instructions: 'x', tools: [] }
|
||||||
|
}))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocks session.update that includes voice', () => {
|
||||||
|
expect(isQwenSafeClientFrame(JSON.stringify({
|
||||||
|
type: 'session.update',
|
||||||
|
session: { voice: 'Cherry' }
|
||||||
|
}))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocks full config session.update', () => {
|
||||||
|
expect(isQwenSafeClientFrame(JSON.stringify({
|
||||||
|
type: 'session.update',
|
||||||
|
session: { modalities: ['text', 'audio'], voice: 'Cherry', instructions: 'x', tools: [], tool_choice: 'auto' }
|
||||||
|
}))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows non-JSON (binary audio frames pass through)', () => {
|
||||||
|
expect(isQwenSafeClientFrame('not json {')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
+255
-3
@@ -8,10 +8,16 @@
|
|||||||
export const ELEVENLABS_API_BASE = 'https://api.elevenlabs.io/v1'
|
export const ELEVENLABS_API_BASE = 'https://api.elevenlabs.io/v1'
|
||||||
export const VOICE_AGENT_NAME = 'Hapi Voice Assistant'
|
export const VOICE_AGENT_NAME = 'Hapi Voice Assistant'
|
||||||
|
|
||||||
export const VOICE_SYSTEM_PROMPT = `# Identity
|
export const VOICE_SYSTEM_PROMPT = `# CRITICAL RULE - Tool Usage
|
||||||
|
|
||||||
|
You MUST call the messageCodingAgent tool for ANY request related to coding, files, development, debugging, or tasks for the agent. Do NOT respond verbally to these requests — call the tool FIRST, then briefly confirm. This is your most important behavior.
|
||||||
|
|
||||||
|
# Identity
|
||||||
|
|
||||||
You are Hapi Voice Assistant. You bridge voice communication between users and their AI coding agents in the Hapi ecosystem.
|
You are Hapi Voice Assistant. You bridge voice communication between users and their AI coding agents in the Hapi ecosystem.
|
||||||
|
|
||||||
|
IMPORTANT: Never refer to yourself as Gemini, Google, or any underlying model or provider name. You are HAPI — always.
|
||||||
|
|
||||||
You are friendly, proactive, and highly intelligent with a world-class engineering background. Your approach is warm, witty, and relaxed, balancing professionalism with an approachable vibe.
|
You are friendly, proactive, and highly intelligent with a world-class engineering background. Your approach is warm, witty, and relaxed, balancing professionalism with an approachable vibe.
|
||||||
|
|
||||||
# Environment Overview
|
# Environment Overview
|
||||||
@@ -136,9 +142,96 @@ For builds, tests, or large file operations:
|
|||||||
- Treat garbled input as phonetic hints and ask for clarification
|
- Treat garbled input as phonetic hints and ask for clarification
|
||||||
- Correct yourself immediately if you realize you made an error
|
- Correct yourself immediately if you realize you made an error
|
||||||
- Keep conversations forward-moving with fresh insights
|
- Keep conversations forward-moving with fresh insights
|
||||||
- Assume a technical software developer audience`
|
- Assume a technical software developer audience
|
||||||
|
|
||||||
export const VOICE_FIRST_MESSAGE = "Hey! Hapi here."
|
# First Interaction
|
||||||
|
|
||||||
|
When the user speaks to you for the first time, begin your response with a brief greeting before addressing their request. If their first message is a coding request, greet briefly AND call the tool — do both.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language blocks appended to VOICE_SYSTEM_PROMPT for Gemini/Qwen backends
|
||||||
|
* (ElevenLabs has its own language field).
|
||||||
|
*
|
||||||
|
* Always append one of these — silence causes models to drift to their training
|
||||||
|
* language (Chinese for Qwen, mixed for Gemini).
|
||||||
|
*/
|
||||||
|
export const VOICE_CHINESE_LANGUAGE_BLOCK = `
|
||||||
|
|
||||||
|
# Language
|
||||||
|
|
||||||
|
IMPORTANT: Always respond in Chinese (Mandarin). Use natural spoken Chinese.
|
||||||
|
- Greet users in Chinese
|
||||||
|
- Summarize technical content in Chinese
|
||||||
|
- Use English only for proper nouns, tool names, and code identifiers
|
||||||
|
- Keep the same warm, concise conversational style in Chinese`
|
||||||
|
|
||||||
|
/** When no language is selected: mirror the user's detected speech language. */
|
||||||
|
const VOICE_LANGUAGE_BLOCK_AUTO = `
|
||||||
|
|
||||||
|
# Language
|
||||||
|
|
||||||
|
Detect the language the user is speaking and respond in that same language.
|
||||||
|
Maintain it consistently throughout the session — do not drift between turns.
|
||||||
|
If the language cannot be determined, default to English.`
|
||||||
|
|
||||||
|
/** BCP-47 code → spoken language name (for explicit-language block). */
|
||||||
|
const LANGUAGE_NAMES: Record<string, string> = {
|
||||||
|
en: 'English',
|
||||||
|
es: 'Spanish',
|
||||||
|
fr: 'French',
|
||||||
|
de: 'German',
|
||||||
|
ja: 'Japanese',
|
||||||
|
ko: 'Korean',
|
||||||
|
pt: 'Portuguese',
|
||||||
|
'pt-br': 'Brazilian Portuguese',
|
||||||
|
it: 'Italian',
|
||||||
|
ar: 'Arabic',
|
||||||
|
ru: 'Russian',
|
||||||
|
hi: 'Hindi',
|
||||||
|
th: 'Thai',
|
||||||
|
vi: 'Vietnamese',
|
||||||
|
id: 'Indonesian',
|
||||||
|
nl: 'Dutch',
|
||||||
|
sv: 'Swedish',
|
||||||
|
no: 'Norwegian',
|
||||||
|
da: 'Danish',
|
||||||
|
fi: 'Finnish',
|
||||||
|
pl: 'Polish',
|
||||||
|
tr: 'Turkish',
|
||||||
|
bg: 'Bulgarian',
|
||||||
|
ro: 'Romanian',
|
||||||
|
cs: 'Czech',
|
||||||
|
el: 'Greek',
|
||||||
|
ms: 'Malay',
|
||||||
|
tl: 'Filipino',
|
||||||
|
uk: 'Ukrainian',
|
||||||
|
hu: 'Hungarian',
|
||||||
|
hr: 'Croatian',
|
||||||
|
sk: 'Slovak',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the language instruction block to append to VOICE_SYSTEM_PROMPT.
|
||||||
|
* - Explicit 'zh' → Chinese block
|
||||||
|
* - Other explicit code → "Always respond in [Language]"
|
||||||
|
* - undefined/auto → "detect from user speech and maintain it"
|
||||||
|
*/
|
||||||
|
export function buildVoiceLanguageBlock(language?: string): string {
|
||||||
|
if (!language) return VOICE_LANGUAGE_BLOCK_AUTO
|
||||||
|
if (language === 'zh' || language.startsWith('zh-')) return VOICE_CHINESE_LANGUAGE_BLOCK
|
||||||
|
const name = LANGUAGE_NAMES[language] ?? language
|
||||||
|
return `
|
||||||
|
|
||||||
|
# Language
|
||||||
|
|
||||||
|
IMPORTANT: Always respond in ${name}. Maintain ${name} consistently throughout
|
||||||
|
the session — do not drift to a different language between turns.
|
||||||
|
Use English only for proper nouns, code identifiers, and technical terms with
|
||||||
|
no ${name} equivalent.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ElevenLabs first message — language controlled by ElevenLabs language field */
|
||||||
|
export const VOICE_FIRST_MESSAGE = "Hey! Hapi here — what can I help you with?"
|
||||||
|
|
||||||
export const VOICE_TOOLS = [
|
export const VOICE_TOOLS = [
|
||||||
{
|
{
|
||||||
@@ -261,3 +354,162 @@ export function buildVoiceAgentConfig(): VoiceAgentConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VoiceBackendType = 'elevenlabs' | 'gemini-live' | 'qwen-realtime'
|
||||||
|
|
||||||
|
export const QWEN_REALTIME_MODEL = 'qwen3.5-omni-flash-realtime'
|
||||||
|
export const QWEN_REALTIME_VOICE = 'Tina'
|
||||||
|
|
||||||
|
export const DEFAULT_VOICE_BACKEND: VoiceBackendType = 'elevenlabs'
|
||||||
|
|
||||||
|
export const GEMINI_LIVE_MODEL = 'gemini-2.5-flash-native-audio-latest'
|
||||||
|
export const GEMINI_LIVE_VOICE = 'Aoede'
|
||||||
|
|
||||||
|
export interface VoiceToolDefinition {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
parameters: {
|
||||||
|
type: 'object'
|
||||||
|
required: string[]
|
||||||
|
properties: Record<string, {
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoiceToolSource = Pick<(typeof VOICE_TOOLS)[number], 'name' | 'description' | 'parameters'>
|
||||||
|
|
||||||
|
function cloneVoiceToolDefinition(tool: VoiceToolSource): VoiceToolDefinition {
|
||||||
|
const properties: VoiceToolDefinition['parameters']['properties'] = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(tool.parameters.properties)) {
|
||||||
|
properties[key] = {
|
||||||
|
type: value.type,
|
||||||
|
description: value.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
required: [...tool.parameters.required],
|
||||||
|
properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VOICE_TOOL_DEFINITIONS: VoiceToolDefinition[] = VOICE_TOOLS.map(cloneVoiceToolDefinition)
|
||||||
|
|
||||||
|
export type GeminiLiveFunctionDeclaration = VoiceToolDefinition
|
||||||
|
|
||||||
|
export interface GeminiLiveConfig {
|
||||||
|
model: string
|
||||||
|
systemInstruction: string
|
||||||
|
tools: Array<{
|
||||||
|
functionDeclarations: GeminiLiveFunctionDeclaration[]
|
||||||
|
}>
|
||||||
|
responseModalities: ['AUDIO']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGeminiLiveFunctionDeclarations(): GeminiLiveFunctionDeclaration[] {
|
||||||
|
return VOICE_TOOLS.map(cloneVoiceToolDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGeminiLiveConfig(language?: string): GeminiLiveConfig {
|
||||||
|
const systemInstruction = `${VOICE_SYSTEM_PROMPT}${buildVoiceLanguageBlock(language)}`
|
||||||
|
return {
|
||||||
|
model: GEMINI_LIVE_MODEL,
|
||||||
|
systemInstruction,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
functionDeclarations: buildGeminiLiveFunctionDeclarations()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
responseModalities: ['AUDIO']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hub-owned initial session.update for Qwen Realtime (hub proxy). */
|
||||||
|
export function buildQwenSessionUpdateMessage(language?: string): Record<string, unknown> {
|
||||||
|
const instructions = `${VOICE_SYSTEM_PROMPT}${buildVoiceLanguageBlock(language)}`
|
||||||
|
// Qwen Realtime uses the flat Realtime shape, not the chat-completions nested {function:{...}} shape.
|
||||||
|
const tools = VOICE_TOOL_DEFINITIONS.map((td) => ({
|
||||||
|
type: 'function' as const,
|
||||||
|
name: td.name,
|
||||||
|
description: td.description,
|
||||||
|
parameters: td.parameters
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
type: 'session.update',
|
||||||
|
session: {
|
||||||
|
modalities: ['text', 'audio'],
|
||||||
|
voice: QWEN_REALTIME_VOICE,
|
||||||
|
input_audio_format: 'pcm',
|
||||||
|
output_audio_format: 'pcm',
|
||||||
|
instructions,
|
||||||
|
temperature: 0.7,
|
||||||
|
turn_detection: {
|
||||||
|
type: 'server_vad',
|
||||||
|
threshold: 0.5,
|
||||||
|
silence_duration_ms: 800,
|
||||||
|
prefix_padding_ms: 300
|
||||||
|
},
|
||||||
|
tools,
|
||||||
|
tool_choice: 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a client WebSocket frame is safe to forward to DashScope.
|
||||||
|
* Blocks session.update frames that touch config fields (tools, voice, etc.);
|
||||||
|
* allows instruction-only updates and all runtime event types.
|
||||||
|
*/
|
||||||
|
export function isQwenSafeClientFrame(message: string | ArrayBuffer | Uint8Array): boolean {
|
||||||
|
try {
|
||||||
|
const text = typeof message === 'string'
|
||||||
|
? message
|
||||||
|
: new TextDecoder().decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message)
|
||||||
|
const parsed = JSON.parse(text) as unknown
|
||||||
|
if (!parsed || typeof parsed !== 'object') return true
|
||||||
|
const p = parsed as Record<string, unknown>
|
||||||
|
if (p.type !== 'session.update') return true
|
||||||
|
const session = p.session as Record<string, unknown> | undefined
|
||||||
|
if (!session) return false
|
||||||
|
const keys = Object.keys(session)
|
||||||
|
return keys.length === 1 && keys[0] === 'instructions'
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire-format setup frame for Gemini Live BidiGenerateContent (hub proxy + web client). */
|
||||||
|
export function buildGeminiLiveSetupMessage(language?: string): { setup: Record<string, unknown> } {
|
||||||
|
const liveConfig = buildGeminiLiveConfig(language)
|
||||||
|
return {
|
||||||
|
setup: {
|
||||||
|
model: `models/${liveConfig.model}`,
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['AUDIO'],
|
||||||
|
speechConfig: {
|
||||||
|
voiceConfig: {
|
||||||
|
prebuiltVoiceConfig: { voiceName: GEMINI_LIVE_VOICE }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
systemInstruction: {
|
||||||
|
parts: [{ text: liveConfig.systemInstruction }]
|
||||||
|
},
|
||||||
|
tools: liveConfig.tools.map((t) => ({
|
||||||
|
functionDeclarations: t.functionDeclarations.map((fd) => ({
|
||||||
|
name: fd.name,
|
||||||
|
description: fd.description,
|
||||||
|
parameters: fd.parameters
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -611,4 +611,37 @@ export class ApiClient {
|
|||||||
body: JSON.stringify(event)
|
body: JSON.stringify(event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the current auth token (for WebSocket query-param auth). */
|
||||||
|
getAuthToken(): string | null {
|
||||||
|
return this.getToken ? this.getToken() : this.token
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchVoiceBackend(): Promise<{ backend: string }> {
|
||||||
|
return await this.request('/api/voice/backend')
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchQwenToken(): Promise<{
|
||||||
|
allowed: boolean
|
||||||
|
wsUrl?: string
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
return await this.request('/api/voice/qwen-token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchGeminiToken(): Promise<{
|
||||||
|
allowed: boolean
|
||||||
|
apiKey?: string
|
||||||
|
wsUrl?: string
|
||||||
|
baseUrl?: string
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
return await this.request('/api/voice/gemini-token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
VOICE_AGENT_NAME,
|
VOICE_AGENT_NAME,
|
||||||
buildVoiceAgentConfig
|
buildVoiceAgentConfig
|
||||||
} from '@hapi/protocol/voice'
|
} from '@hapi/protocol/voice'
|
||||||
|
import type { VoiceBackendType } from '@hapi/protocol/voice'
|
||||||
|
|
||||||
export interface VoiceTokenResponse {
|
export interface VoiceTokenResponse {
|
||||||
allowed: boolean
|
allowed: boolean
|
||||||
@@ -177,3 +178,64 @@ export async function createOrUpdateHapiAgent(apiKey: string): Promise<CreateAge
|
|||||||
return { success: false, error: e instanceof Error ? e.message : 'Network error' }
|
return { success: false, error: e instanceof Error ? e.message : 'Network error' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Pluggable voice backend API ---
|
||||||
|
|
||||||
|
export interface QwenTokenResponse {
|
||||||
|
allowed: boolean
|
||||||
|
wsUrl?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a DashScope API key from the hub for Qwen Realtime voice sessions.
|
||||||
|
*/
|
||||||
|
export async function fetchQwenToken(api: ApiClient): Promise<QwenTokenResponse> {
|
||||||
|
try {
|
||||||
|
return await api.fetchQwenToken()
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Network error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceBackendResponse {
|
||||||
|
backend: VoiceBackendType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeminiTokenResponse {
|
||||||
|
allowed: boolean
|
||||||
|
apiKey?: string
|
||||||
|
wsUrl?: string
|
||||||
|
baseUrl?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover which voice backend the hub is configured to use.
|
||||||
|
* Throws on network/server error or unrecognised backend value — callers must handle failures explicitly.
|
||||||
|
*/
|
||||||
|
export async function fetchVoiceBackend(api: ApiClient): Promise<VoiceBackendResponse> {
|
||||||
|
const result = await api.fetchVoiceBackend()
|
||||||
|
const { backend } = result
|
||||||
|
if (backend === 'elevenlabs' || backend === 'gemini-live' || backend === 'qwen-realtime') {
|
||||||
|
return { backend }
|
||||||
|
}
|
||||||
|
throw new Error(`Unrecognised voice backend: ${backend}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a Gemini API key from the hub for Gemini Live voice sessions.
|
||||||
|
*/
|
||||||
|
export async function fetchGeminiToken(api: ApiClient): Promise<GeminiTokenResponse> {
|
||||||
|
try {
|
||||||
|
return await api.fetchGeminiToken()
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Network error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels'
|
|||||||
import { useCursorModels } from '@/hooks/queries/useCursorModels'
|
import { useCursorModels } from '@/hooks/queries/useCursorModels'
|
||||||
import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels'
|
import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels'
|
||||||
import { useVoiceOptional } from '@/lib/voice-context'
|
import { useVoiceOptional } from '@/lib/voice-context'
|
||||||
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
||||||
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
|
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,6 +237,7 @@ export function SessionChat(props: {
|
|||||||
|
|
||||||
// Voice assistant integration
|
// Voice assistant integration
|
||||||
const voice = useVoiceOptional()
|
const voice = useVoiceOptional()
|
||||||
|
const [voiceBackendReady, setVoiceBackendReady] = useState(false)
|
||||||
|
|
||||||
// Register session store for voice client tools
|
// Register session store for voice client tools
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -721,18 +722,19 @@ export function SessionChat(props: {
|
|||||||
autocompleteSuggestions={props.autocompleteSuggestions}
|
autocompleteSuggestions={props.autocompleteSuggestions}
|
||||||
voiceStatus={voice?.status}
|
voiceStatus={voice?.status}
|
||||||
voiceMicMuted={voice?.micMuted}
|
voiceMicMuted={voice?.micMuted}
|
||||||
onVoiceToggle={voice ? handleVoiceToggle : undefined}
|
onVoiceToggle={voice && voiceBackendReady ? handleVoiceToggle : undefined}
|
||||||
onVoiceMicToggle={voice ? handleVoiceMicToggle : undefined}
|
onVoiceMicToggle={voice && voiceBackendReady ? handleVoiceMicToggle : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
|
|
||||||
{/* Voice session component - renders nothing but initializes ElevenLabs */}
|
{/* Voice session component - renders nothing but initializes voice backend */}
|
||||||
{voice && (
|
{voice && (
|
||||||
<RealtimeVoiceSession
|
<VoiceBackendSession
|
||||||
api={props.api}
|
api={props.api}
|
||||||
micMuted={voice.micMuted}
|
micMuted={voice.micMuted}
|
||||||
onStatusChange={voice.setStatus}
|
onStatusChange={voice.setStatus}
|
||||||
|
onReadyChange={setVoiceBackendReady}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -480,6 +480,8 @@ export default {
|
|||||||
'settings.voice.autoDetect': 'Auto-detect',
|
'settings.voice.autoDetect': 'Auto-detect',
|
||||||
'settings.voice.voice': 'Voice',
|
'settings.voice.voice': 'Voice',
|
||||||
'settings.voice.voiceDefault': 'Default',
|
'settings.voice.voiceDefault': 'Default',
|
||||||
|
'settings.voice.proactive': 'Start voice session with summary',
|
||||||
|
'settings.voice.proactive.description': 'When on, starting a voice session opens with a spoken summary of current agent activity. When off, the assistant greets you and waits for you to speak.',
|
||||||
'settings.about.title': 'About',
|
'settings.about.title': 'About',
|
||||||
'settings.about.website': 'Website',
|
'settings.about.website': 'Website',
|
||||||
'settings.about.appVersion': 'App Version',
|
'settings.about.appVersion': 'App Version',
|
||||||
|
|||||||
@@ -482,6 +482,8 @@ export default {
|
|||||||
'settings.voice.autoDetect': '自动检测',
|
'settings.voice.autoDetect': '自动检测',
|
||||||
'settings.voice.voice': '声音',
|
'settings.voice.voice': '声音',
|
||||||
'settings.voice.voiceDefault': '默认',
|
'settings.voice.voiceDefault': '默认',
|
||||||
|
'settings.voice.proactive': '以摘要开始语音会话',
|
||||||
|
'settings.voice.proactive.description': '开启后,启动语音会话时将朗读当前代理活动的摘要。关闭后,助手向您打招呼并等待您先开口。',
|
||||||
'settings.about.title': '关于',
|
'settings.about.title': '关于',
|
||||||
'settings.about.website': '官方网站',
|
'settings.about.website': '官方网站',
|
||||||
'settings.about.appVersion': '应用版本',
|
'settings.about.appVersion': '应用版本',
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { registerVoiceSession, resetRealtimeSessionState } from './RealtimeSession'
|
||||||
|
import { registerSessionStore } from './realtimeClientTools'
|
||||||
|
import { fetchGeminiToken } from '@/api/voice'
|
||||||
|
import { GeminiAudioRecorder } from './gemini/audioRecorder'
|
||||||
|
import { GeminiAudioPlayer } from './gemini/audioPlayer'
|
||||||
|
import { handleGeminiFunctionCalls } from './gemini/toolAdapter'
|
||||||
|
import { buildGeminiLiveSetupMessage } from '@hapi/protocol/voice'
|
||||||
|
import type { VoiceSession, VoiceSessionConfig, StatusCallback } from './types'
|
||||||
|
import type { ApiClient } from '@/api/client'
|
||||||
|
import type { Session } from '@/types/api'
|
||||||
|
import type { GeminiFunctionCall } from './gemini/toolAdapter'
|
||||||
|
|
||||||
|
const DEBUG = import.meta.env.DEV
|
||||||
|
|
||||||
|
// Default Gemini Live WebSocket API endpoint (Google direct)
|
||||||
|
const DEFAULT_GEMINI_LIVE_WS_BASE = 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
|
||||||
|
|
||||||
|
interface GeminiLiveState {
|
||||||
|
ws: WebSocket | null
|
||||||
|
recorder: GeminiAudioRecorder | null
|
||||||
|
player: GeminiAudioPlayer | null
|
||||||
|
playbackContext: AudioContext | null
|
||||||
|
statusCallback: StatusCallback | null
|
||||||
|
apiKey: string | null
|
||||||
|
wsBaseUrl: string | null
|
||||||
|
modelSpeaking: boolean
|
||||||
|
micMuted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: GeminiLiveState = {
|
||||||
|
ws: null,
|
||||||
|
recorder: null,
|
||||||
|
player: null,
|
||||||
|
playbackContext: null,
|
||||||
|
statusCallback: null,
|
||||||
|
apiKey: null,
|
||||||
|
wsBaseUrl: null,
|
||||||
|
modelSpeaking: false,
|
||||||
|
micMuted: false
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (state.recorder) {
|
||||||
|
state.recorder.dispose()
|
||||||
|
state.recorder = null
|
||||||
|
}
|
||||||
|
if (state.player) {
|
||||||
|
state.player.dispose()
|
||||||
|
state.player = null
|
||||||
|
}
|
||||||
|
if (state.playbackContext && state.playbackContext.state !== 'closed') {
|
||||||
|
void state.playbackContext.close()
|
||||||
|
}
|
||||||
|
state.playbackContext = null
|
||||||
|
if (state.ws) {
|
||||||
|
if (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
state.ws.close()
|
||||||
|
}
|
||||||
|
state.ws = null
|
||||||
|
}
|
||||||
|
// Always reset modelSpeaking so a restart doesn't begin with audio capture silenced
|
||||||
|
state.modelSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeminiLiveVoiceSessionImpl implements VoiceSession {
|
||||||
|
private api: ApiClient
|
||||||
|
|
||||||
|
constructor(api: ApiClient) {
|
||||||
|
this.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(config: VoiceSessionConfig): Promise<void> {
|
||||||
|
cleanup()
|
||||||
|
state.statusCallback?.('connecting')
|
||||||
|
|
||||||
|
// Create playback AudioContext immediately while still inside the user
|
||||||
|
// gesture (click/tap). Mobile browsers require this for autoplay policy.
|
||||||
|
// Store in state so cleanup() can close it on failure or stop.
|
||||||
|
state.playbackContext = new AudioContext({ sampleRate: 24000 })
|
||||||
|
await state.playbackContext.resume()
|
||||||
|
|
||||||
|
// Get API key from hub
|
||||||
|
console.log('[GeminiLive] Fetching token...')
|
||||||
|
const tokenResp = await fetchGeminiToken(this.api)
|
||||||
|
console.log('[GeminiLive] Token response:', { allowed: tokenResp.allowed, hasKey: !!tokenResp.apiKey, error: tokenResp.error })
|
||||||
|
if (!tokenResp.allowed || !tokenResp.apiKey) {
|
||||||
|
const msg = tokenResp.error ?? 'Gemini API key not available'
|
||||||
|
console.error('[GeminiLive] Token failed:', msg)
|
||||||
|
state.statusCallback?.('error', msg)
|
||||||
|
cleanup()
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
state.apiKey = tokenResp.apiKey
|
||||||
|
state.wsBaseUrl = tokenResp.wsUrl || null
|
||||||
|
if (!state.wsBaseUrl) {
|
||||||
|
const msg = 'Hub must provide wsUrl for Gemini connections — direct key connection is not supported'
|
||||||
|
state.statusCallback?.('error', msg)
|
||||||
|
cleanup()
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request microphone
|
||||||
|
console.log('[GeminiLive] Requesting microphone...')
|
||||||
|
let permissionStream: MediaStream | null = null
|
||||||
|
try {
|
||||||
|
permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
console.log('[GeminiLive] Microphone granted')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GeminiLive] Microphone denied:', error)
|
||||||
|
state.statusCallback?.('error', 'Microphone permission denied')
|
||||||
|
cleanup()
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
permissionStream?.getTracks().forEach((t) => t.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect WebSocket — use proxy URL if provided (avoids region restrictions)
|
||||||
|
const wsBase = state.wsBaseUrl || DEFAULT_GEMINI_LIVE_WS_BASE
|
||||||
|
const isProxy = !!state.wsBaseUrl
|
||||||
|
const authToken = this.api.getAuthToken() || ''
|
||||||
|
const languageParam = config.language ? `&language=${encodeURIComponent(config.language)}` : ''
|
||||||
|
const wsUrl = isProxy
|
||||||
|
? `${wsBase}${wsBase.includes('?') ? '&' : '?'}token=${encodeURIComponent(authToken)}${languageParam}`
|
||||||
|
: `${wsBase}?key=${encodeURIComponent(state.apiKey)}`
|
||||||
|
console.log('[GeminiLive] Connecting WebSocket to:', wsBase, isProxy ? '(proxied)' : '(direct)')
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
state.ws = ws
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let setupDone = false
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (DEBUG) console.log('[GeminiLive] WebSocket connected', isProxy ? '(hub sends setup)' : ', sending setup')
|
||||||
|
|
||||||
|
// Proxied sessions: hub sends HAPI-owned setup server-side (see gemini-ws proxy).
|
||||||
|
if (!isProxy) {
|
||||||
|
ws.send(JSON.stringify(buildGeminiLiveSetupMessage(config.language)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
let data: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
if (event.data instanceof Blob) {
|
||||||
|
const text = await event.data.text()
|
||||||
|
data = JSON.parse(text) as Record<string, unknown>
|
||||||
|
} else {
|
||||||
|
data = JSON.parse(event.data as string) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (DEBUG) console.warn('[GeminiLive] Failed to parse message')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log all message types for debugging
|
||||||
|
const msgKeys = Object.keys(data).filter(k => k !== 'serverContent' || !('modelTurn' in (data.serverContent as Record<string, unknown> || {})))
|
||||||
|
if (!data.serverContent) {
|
||||||
|
console.log('[GeminiLive] Message:', msgKeys.join(', '), JSON.stringify(data).slice(0, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup complete
|
||||||
|
if (data.setupComplete && !setupDone) {
|
||||||
|
setupDone = true
|
||||||
|
if (DEBUG) console.log('[GeminiLive] Setup complete')
|
||||||
|
|
||||||
|
// Await audio capture so setMuted runs after getUserMedia resolves.
|
||||||
|
// Wrap so a mic failure rejects the outer startSession promise.
|
||||||
|
try {
|
||||||
|
await startAudioCapture(state.playbackContext!)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Microphone error'
|
||||||
|
cleanup()
|
||||||
|
state.statusCallback?.('error', message)
|
||||||
|
reject(error instanceof Error ? error : new Error(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.statusCallback?.('connected')
|
||||||
|
|
||||||
|
const proactive = localStorage.getItem('hapi-voice-proactive') === 'true'
|
||||||
|
|
||||||
|
if (proactive && config.initialContext) {
|
||||||
|
// Proactive with context: speak the summary immediately.
|
||||||
|
sendClientContent(`[Context] ${config.initialContext}`, true)
|
||||||
|
} else {
|
||||||
|
// Reactive, or proactive with no context: feed context silently if
|
||||||
|
// available, then trigger a greeting so Gemini doesn't sit silent.
|
||||||
|
if (config.initialContext) {
|
||||||
|
sendClientContent(`[Context] ${config.initialContext}`, false)
|
||||||
|
}
|
||||||
|
sendClientContent('[Greet the user as HAPI. Say a brief hello and invite them to speak. Do not mention Gemini or any model name. Do not reference any context or recent activity.]', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server content (audio / text / turn complete)
|
||||||
|
const serverContent = data.serverContent as {
|
||||||
|
modelTurn?: { parts?: Array<{ inlineData?: { data: string; mimeType: string }; text?: string }> }
|
||||||
|
turnComplete?: boolean
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
if (serverContent) {
|
||||||
|
if (serverContent.modelTurn?.parts) {
|
||||||
|
// Model is generating — mute mic to prevent barge-in from noise
|
||||||
|
if (!state.modelSpeaking) {
|
||||||
|
state.modelSpeaking = true
|
||||||
|
state.recorder?.setMuted(true)
|
||||||
|
}
|
||||||
|
for (const part of serverContent.modelTurn.parts) {
|
||||||
|
if (part.inlineData?.data) {
|
||||||
|
state.player?.enqueue(part.inlineData.data)
|
||||||
|
}
|
||||||
|
if (part.text) {
|
||||||
|
console.log('[GeminiLive] Text:', part.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (serverContent.turnComplete) {
|
||||||
|
console.log('[GeminiLive] Turn complete')
|
||||||
|
// Restore to user's chosen mute state, not unconditionally unmuted
|
||||||
|
state.modelSpeaking = false
|
||||||
|
state.recorder?.setMuted(state.micMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool calls
|
||||||
|
const toolCall = data.toolCall as {
|
||||||
|
functionCalls?: Array<{ name: string; args: Record<string, unknown>; id: string }>
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
if (toolCall?.functionCalls && toolCall.functionCalls.length > 0) {
|
||||||
|
console.log('[GeminiLive] Tool calls:', toolCall.functionCalls.map((c) => c.name))
|
||||||
|
|
||||||
|
const responses = await handleGeminiFunctionCalls(
|
||||||
|
toolCall.functionCalls as GeminiFunctionCall[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send tool responses back
|
||||||
|
if (state.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
state.ws.send(JSON.stringify({
|
||||||
|
toolResponse: {
|
||||||
|
functionResponses: responses.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
response: r.response
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = (event) => {
|
||||||
|
console.error('[GeminiLive] WebSocket error:', event)
|
||||||
|
if (!setupDone) {
|
||||||
|
setupDone = true
|
||||||
|
cleanup()
|
||||||
|
state.statusCallback?.('error', 'WebSocket connection failed')
|
||||||
|
reject(new Error('WebSocket connection failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
if (state.ws !== ws) return
|
||||||
|
if (DEBUG) console.log('[GeminiLive] WebSocket closed:', event.code, event.reason)
|
||||||
|
cleanup()
|
||||||
|
resetRealtimeSessionState()
|
||||||
|
if (!setupDone) {
|
||||||
|
const message = event.reason || 'WebSocket closed before setup completed'
|
||||||
|
state.statusCallback?.('error', message)
|
||||||
|
reject(new Error(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.statusCallback?.('disconnected')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(): Promise<void> {
|
||||||
|
cleanup()
|
||||||
|
resetRealtimeSessionState()
|
||||||
|
state.statusCallback?.('disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTextMessage(message: string): void {
|
||||||
|
sendClientContent(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendContextualUpdate(update: string): void {
|
||||||
|
// Append context without triggering a response — turnComplete: false accumulates
|
||||||
|
// silently until the next sendTextMessage fires with turnComplete: true
|
||||||
|
sendClientContent(`[System Context Update] ${update}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendClientContent(text: string, turnComplete = true): void {
|
||||||
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return
|
||||||
|
state.ws.send(JSON.stringify({
|
||||||
|
clientContent: {
|
||||||
|
turns: [{ role: 'user', parts: [{ text }] }],
|
||||||
|
turnComplete
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAudioChunk(base64Pcm: string): void {
|
||||||
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return
|
||||||
|
// Don't send audio while model is speaking
|
||||||
|
if (state.modelSpeaking) return
|
||||||
|
state.ws.send(JSON.stringify({
|
||||||
|
realtimeInput: {
|
||||||
|
mediaChunks: [{
|
||||||
|
mimeType: 'audio/pcm;rate=16000',
|
||||||
|
data: base64Pcm
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAudioCapture(playbackContext: AudioContext): Promise<void> {
|
||||||
|
state.player = new GeminiAudioPlayer(playbackContext)
|
||||||
|
state.recorder = new GeminiAudioRecorder()
|
||||||
|
|
||||||
|
await state.recorder.start(
|
||||||
|
(pcm16Chunk) => sendAudioChunk(pcm16Chunk),
|
||||||
|
(error) => {
|
||||||
|
console.error('[GeminiLive] Audio capture error:', error)
|
||||||
|
state.statusCallback?.('error', 'Microphone error')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply mute state after recorder has a stream — safe to call either way
|
||||||
|
state.recorder.setMuted(state.micMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- React component ---
|
||||||
|
|
||||||
|
export interface GeminiLiveVoiceSessionProps {
|
||||||
|
api: ApiClient
|
||||||
|
micMuted?: boolean
|
||||||
|
onStatusChange?: StatusCallback
|
||||||
|
onRegistered?: () => void
|
||||||
|
getSession?: (sessionId: string) => Session | null
|
||||||
|
sendMessage?: (sessionId: string, message: string) => void
|
||||||
|
approvePermission?: (sessionId: string, requestId: string) => Promise<void>
|
||||||
|
denyPermission?: (sessionId: string, requestId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeminiLiveVoiceSession({
|
||||||
|
api,
|
||||||
|
micMuted = false,
|
||||||
|
onStatusChange,
|
||||||
|
onRegistered,
|
||||||
|
getSession,
|
||||||
|
sendMessage,
|
||||||
|
approvePermission,
|
||||||
|
denyPermission
|
||||||
|
}: GeminiLiveVoiceSessionProps) {
|
||||||
|
const hasRegistered = useRef(false)
|
||||||
|
|
||||||
|
// Store status callback
|
||||||
|
useEffect(() => {
|
||||||
|
state.statusCallback = onStatusChange || null
|
||||||
|
return () => { state.statusCallback = null }
|
||||||
|
}, [onStatusChange])
|
||||||
|
|
||||||
|
// Register session store for client tools
|
||||||
|
useEffect(() => {
|
||||||
|
if (getSession && sendMessage && approvePermission && denyPermission) {
|
||||||
|
registerSessionStore({
|
||||||
|
getSession: (sessionId: string) =>
|
||||||
|
getSession(sessionId) as { agentState?: { requests?: Record<string, unknown> } } | null,
|
||||||
|
sendMessage,
|
||||||
|
approvePermission,
|
||||||
|
denyPermission
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [getSession, sendMessage, approvePermission, denyPermission])
|
||||||
|
|
||||||
|
// Register voice session once
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasRegistered.current) {
|
||||||
|
try {
|
||||||
|
registerVoiceSession(new GeminiLiveVoiceSessionImpl(api))
|
||||||
|
hasRegistered.current = true
|
||||||
|
onRegistered?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GeminiLive] Failed to register voice session:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [api]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Sync mic mute state — also persist to module state so startAudioCapture can apply it
|
||||||
|
useEffect(() => {
|
||||||
|
state.micMuted = micMuted
|
||||||
|
if (state.recorder) {
|
||||||
|
state.recorder.setMuted(micMuted)
|
||||||
|
}
|
||||||
|
}, [micMuted])
|
||||||
|
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { registerVoiceSession, resetRealtimeSessionState } from './RealtimeSession'
|
||||||
|
import { registerSessionStore } from './realtimeClientTools'
|
||||||
|
import { fetchQwenToken } from '@/api/voice'
|
||||||
|
import { GeminiAudioRecorder } from './gemini/audioRecorder'
|
||||||
|
import { GeminiAudioPlayer } from './gemini/audioPlayer'
|
||||||
|
import { realtimeClientTools } from './realtimeClientTools'
|
||||||
|
import {
|
||||||
|
VOICE_SYSTEM_PROMPT,
|
||||||
|
buildVoiceLanguageBlock,
|
||||||
|
} from '@hapi/protocol/voice'
|
||||||
|
import type { VoiceSession, VoiceSessionConfig, StatusCallback } from './types'
|
||||||
|
import type { ApiClient } from '@/api/client'
|
||||||
|
import type { Session } from '@/types/api'
|
||||||
|
|
||||||
|
const DEBUG = import.meta.env.DEV
|
||||||
|
|
||||||
|
// Qwen WebSocket connects via Hub proxy (browser can't set Authorization header)
|
||||||
|
|
||||||
|
interface QwenState {
|
||||||
|
ws: WebSocket | null
|
||||||
|
recorder: GeminiAudioRecorder | null
|
||||||
|
player: GeminiAudioPlayer | null
|
||||||
|
playbackContext: AudioContext | null
|
||||||
|
statusCallback: StatusCallback | null
|
||||||
|
apiKey: string | null
|
||||||
|
wsBaseUrl: string | null
|
||||||
|
micMuted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: QwenState = {
|
||||||
|
ws: null,
|
||||||
|
recorder: null,
|
||||||
|
player: null,
|
||||||
|
playbackContext: null,
|
||||||
|
statusCallback: null,
|
||||||
|
apiKey: null,
|
||||||
|
wsBaseUrl: null,
|
||||||
|
micMuted: false
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventCounter = 0
|
||||||
|
function nextEventId(): string {
|
||||||
|
return `evt_${++eventCounter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (state.recorder) {
|
||||||
|
state.recorder.dispose()
|
||||||
|
state.recorder = null
|
||||||
|
}
|
||||||
|
if (state.player) {
|
||||||
|
state.player.dispose()
|
||||||
|
state.player = null
|
||||||
|
}
|
||||||
|
if (state.playbackContext && state.playbackContext.state !== 'closed') {
|
||||||
|
void state.playbackContext.close()
|
||||||
|
}
|
||||||
|
state.playbackContext = null
|
||||||
|
if (state.ws) {
|
||||||
|
if (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
state.ws.close()
|
||||||
|
}
|
||||||
|
state.ws = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendEvent(type: string, payload?: Record<string, unknown>): void {
|
||||||
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return
|
||||||
|
state.ws.send(JSON.stringify({
|
||||||
|
event_id: nextEventId(),
|
||||||
|
type,
|
||||||
|
...payload
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QwenVoiceSessionImpl implements VoiceSession {
|
||||||
|
private api: ApiClient
|
||||||
|
private currentInstructions: string | null = null
|
||||||
|
|
||||||
|
constructor(api: ApiClient) {
|
||||||
|
this.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateInstructions(update: string): void {
|
||||||
|
if (this.currentInstructions === null) return
|
||||||
|
this.currentInstructions = `${this.currentInstructions}\n\n${update}`
|
||||||
|
// Hub filter allows only instruction-only session.update frames.
|
||||||
|
sendEvent('session.update', { session: { instructions: this.currentInstructions } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(config: VoiceSessionConfig): Promise<void> {
|
||||||
|
// Mirror the base instructions the hub will send so subsequent updates accumulate correctly.
|
||||||
|
this.currentInstructions = `${VOICE_SYSTEM_PROMPT}${buildVoiceLanguageBlock(config.language)}`
|
||||||
|
cleanup()
|
||||||
|
state.statusCallback?.('connecting')
|
||||||
|
|
||||||
|
// Create playback AudioContext immediately while still inside the user
|
||||||
|
// gesture (click/tap). Mobile browsers require this for autoplay policy.
|
||||||
|
// Store in state so cleanup() can close it on failure or stop.
|
||||||
|
state.playbackContext = new AudioContext({ sampleRate: 24000 })
|
||||||
|
await state.playbackContext.resume()
|
||||||
|
|
||||||
|
// Check Qwen availability (hub no longer sends the raw API key)
|
||||||
|
const tokenResp = await fetchQwenToken(this.api)
|
||||||
|
if (!tokenResp.allowed) {
|
||||||
|
const msg = tokenResp.error ?? 'DashScope API key not available'
|
||||||
|
state.statusCallback?.('error', msg)
|
||||||
|
cleanup()
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
state.apiKey = null // key stays server-side
|
||||||
|
state.wsBaseUrl = tokenResp.wsUrl || null
|
||||||
|
|
||||||
|
// Request microphone
|
||||||
|
let permissionStream: MediaStream | null = null
|
||||||
|
try {
|
||||||
|
permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
} catch (error) {
|
||||||
|
state.statusCallback?.('error', 'Microphone permission denied')
|
||||||
|
cleanup()
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
permissionStream?.getTracks().forEach((t) => t.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect via Hub WebSocket proxy (DashScope requires Authorization header,
|
||||||
|
// which browser WebSocket API doesn't support)
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const defaultProxyUrl = `${protocol}//${window.location.host}/api/voice/qwen-ws`
|
||||||
|
const proxyUrl = state.wsBaseUrl || defaultProxyUrl
|
||||||
|
const authToken = this.api.getAuthToken() || ''
|
||||||
|
const separator = proxyUrl.includes('?') ? '&' : '?'
|
||||||
|
const langParam = config.language ? `&language=${encodeURIComponent(config.language)}` : ''
|
||||||
|
const wsUrl = `${proxyUrl}${separator}token=${encodeURIComponent(authToken)}${langParam}`
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
state.ws = ws
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let sessionReady = false
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (DEBUG) console.log('[Qwen] WebSocket connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
let data: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.data as string) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
if (DEBUG) console.warn('[Qwen] Failed to parse message')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = data.type as string
|
||||||
|
|
||||||
|
// Session created — hub sends the initial session.update; browser waits for session.updated.
|
||||||
|
if (eventType === 'session.created') {
|
||||||
|
if (DEBUG) console.log('[Qwen] Session created (hub owns setup)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session updated - only act on the first one (initial config ack).
|
||||||
|
// Subsequent session.update calls (for instruction appends) also
|
||||||
|
// echo session.updated — ignore those after setup is complete.
|
||||||
|
if (eventType === 'session.updated') {
|
||||||
|
if (sessionReady) return
|
||||||
|
sessionReady = true
|
||||||
|
if (DEBUG) console.log('[Qwen] Session configured')
|
||||||
|
try {
|
||||||
|
await startAudioCapture(state.playbackContext!)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Microphone error'
|
||||||
|
cleanup()
|
||||||
|
state.statusCallback?.('error', message)
|
||||||
|
reject(error instanceof Error ? error : new Error(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.statusCallback?.('connected')
|
||||||
|
|
||||||
|
const proactive = localStorage.getItem('hapi-voice-proactive') === 'true'
|
||||||
|
if (proactive && config.initialContext) {
|
||||||
|
this.sendTextMessage(`[Context] ${config.initialContext}`)
|
||||||
|
} else {
|
||||||
|
if (config.initialContext) {
|
||||||
|
this.sendContextualUpdate(config.initialContext)
|
||||||
|
}
|
||||||
|
this.sendTextMessage('[Greet the user as HAPI. Say a brief hello and invite them to speak. Do not mention Qwen or any model name. Do not reference any context or recent activity.]')
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio output streaming
|
||||||
|
if (eventType === 'response.audio.delta') {
|
||||||
|
const delta = data.delta as string
|
||||||
|
if (delta) {
|
||||||
|
state.player?.enqueue(delta)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text transcript (for debug)
|
||||||
|
if (eventType === 'response.audio_transcript.delta' && DEBUG) {
|
||||||
|
console.log('[Qwen] Transcript:', data.delta)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function call complete
|
||||||
|
if (eventType === 'response.function_call_arguments.done') {
|
||||||
|
const callId = data.call_id as string
|
||||||
|
const fnName = data.name as string
|
||||||
|
const argsStr = data.arguments as string
|
||||||
|
|
||||||
|
if (DEBUG) console.log('[Qwen] Tool call:', fnName, argsStr)
|
||||||
|
|
||||||
|
let args: Record<string, unknown> = {}
|
||||||
|
try { args = JSON.parse(argsStr) } catch { /* empty */ }
|
||||||
|
|
||||||
|
// Execute the tool
|
||||||
|
const handler = fnName === 'messageCodingAgent'
|
||||||
|
? realtimeClientTools.messageCodingAgent
|
||||||
|
: fnName === 'processPermissionRequest'
|
||||||
|
? realtimeClientTools.processPermissionRequest
|
||||||
|
: null
|
||||||
|
|
||||||
|
const result = handler
|
||||||
|
? await handler(args)
|
||||||
|
: `error (unknown tool: ${fnName})`
|
||||||
|
|
||||||
|
// Send function result back
|
||||||
|
sendEvent('conversation.item.create', {
|
||||||
|
item: {
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: callId,
|
||||||
|
output: typeof result === 'string' ? result : JSON.stringify(result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Trigger model to continue
|
||||||
|
sendEvent('response.create')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAD: user started speaking - barge-in
|
||||||
|
if (eventType === 'input_audio_buffer.speech_started') {
|
||||||
|
if (state.player?.isPlaying()) {
|
||||||
|
state.player.clearQueue()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response done
|
||||||
|
if (eventType === 'response.done' && DEBUG) {
|
||||||
|
const resp = data.response as Record<string, unknown> | undefined
|
||||||
|
const usage = resp?.usage as Record<string, unknown> | undefined
|
||||||
|
if (usage) console.log('[Qwen] Usage:', usage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if (eventType === 'error') {
|
||||||
|
const err = data.error as { message?: string } | undefined
|
||||||
|
const message = err?.message || 'Realtime session setup failed'
|
||||||
|
console.error('[Qwen] Server error:', message)
|
||||||
|
state.statusCallback?.('error', message)
|
||||||
|
if (!sessionReady) {
|
||||||
|
reject(new Error(message))
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = (event) => {
|
||||||
|
console.error('[Qwen] WebSocket error:', event)
|
||||||
|
if (!sessionReady) {
|
||||||
|
sessionReady = true
|
||||||
|
cleanup()
|
||||||
|
state.statusCallback?.('error', 'WebSocket connection failed')
|
||||||
|
reject(new Error('WebSocket connection failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
if (state.ws !== ws) return
|
||||||
|
if (DEBUG) console.log('[Qwen] WebSocket closed:', event.code, event.reason)
|
||||||
|
cleanup()
|
||||||
|
resetRealtimeSessionState()
|
||||||
|
if (!sessionReady) {
|
||||||
|
const message = event.reason || 'WebSocket closed before setup completed'
|
||||||
|
state.statusCallback?.('error', message)
|
||||||
|
reject(new Error(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.statusCallback?.('disconnected')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(): Promise<void> {
|
||||||
|
this.currentInstructions = null
|
||||||
|
cleanup()
|
||||||
|
resetRealtimeSessionState()
|
||||||
|
state.statusCallback?.('disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTextMessage(message: string): void {
|
||||||
|
// Qwen Realtime requires a user conversation item before response.create.
|
||||||
|
sendEvent('conversation.item.create', {
|
||||||
|
item: {
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: message }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
sendEvent('response.create')
|
||||||
|
}
|
||||||
|
|
||||||
|
sendContextualUpdate(update: string): void {
|
||||||
|
// Append context silently — no response.create, so model doesn't speak yet.
|
||||||
|
this.updateInstructions(`[System Context Update] ${update}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAudioCapture(playbackContext: AudioContext): Promise<void> {
|
||||||
|
state.player = new GeminiAudioPlayer(playbackContext)
|
||||||
|
state.recorder = new GeminiAudioRecorder()
|
||||||
|
|
||||||
|
await state.recorder.start(
|
||||||
|
(base64Pcm) => {
|
||||||
|
sendEvent('input_audio_buffer.append', { audio: base64Pcm })
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('[Qwen] Audio capture error:', error)
|
||||||
|
state.statusCallback?.('error', 'Microphone error')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply mute state after recorder has a stream — safe to call either way
|
||||||
|
state.recorder.setMuted(state.micMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- React component ---
|
||||||
|
|
||||||
|
export interface QwenVoiceSessionProps {
|
||||||
|
api: ApiClient
|
||||||
|
micMuted?: boolean
|
||||||
|
onStatusChange?: StatusCallback
|
||||||
|
onRegistered?: () => void
|
||||||
|
getSession?: (sessionId: string) => Session | null
|
||||||
|
sendMessage?: (sessionId: string, message: string) => void
|
||||||
|
approvePermission?: (sessionId: string, requestId: string) => Promise<void>
|
||||||
|
denyPermission?: (sessionId: string, requestId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QwenVoiceSession({
|
||||||
|
api,
|
||||||
|
micMuted = false,
|
||||||
|
onStatusChange,
|
||||||
|
onRegistered,
|
||||||
|
getSession,
|
||||||
|
sendMessage,
|
||||||
|
approvePermission,
|
||||||
|
denyPermission
|
||||||
|
}: QwenVoiceSessionProps) {
|
||||||
|
const hasRegistered = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
state.statusCallback = onStatusChange || null
|
||||||
|
return () => { state.statusCallback = null }
|
||||||
|
}, [onStatusChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (getSession && sendMessage && approvePermission && denyPermission) {
|
||||||
|
registerSessionStore({
|
||||||
|
getSession: (sessionId: string) =>
|
||||||
|
getSession(sessionId) as { agentState?: { requests?: Record<string, unknown> } } | null,
|
||||||
|
sendMessage,
|
||||||
|
approvePermission,
|
||||||
|
denyPermission
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [getSession, sendMessage, approvePermission, denyPermission])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasRegistered.current) {
|
||||||
|
try {
|
||||||
|
registerVoiceSession(new QwenVoiceSessionImpl(api))
|
||||||
|
hasRegistered.current = true
|
||||||
|
onRegistered?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Qwen] Failed to register voice session:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [api]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Sync mic mute state — also persist to module state so startAudioCapture can apply it
|
||||||
|
useEffect(() => {
|
||||||
|
state.micMuted = micMuted
|
||||||
|
if (state.recorder) {
|
||||||
|
state.recorder.setMuted(micMuted)
|
||||||
|
}
|
||||||
|
}, [micMuted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { cleanup() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -136,6 +136,7 @@ export interface RealtimeVoiceSessionProps {
|
|||||||
api: ApiClient
|
api: ApiClient
|
||||||
micMuted?: boolean
|
micMuted?: boolean
|
||||||
onStatusChange?: StatusCallback
|
onStatusChange?: StatusCallback
|
||||||
|
onRegistered?: () => void
|
||||||
getSession?: (sessionId: string) => Session | null
|
getSession?: (sessionId: string) => Session | null
|
||||||
sendMessage?: (sessionId: string, message: string) => void
|
sendMessage?: (sessionId: string, message: string) => void
|
||||||
approvePermission?: (sessionId: string, requestId: string) => Promise<void>
|
approvePermission?: (sessionId: string, requestId: string) => Promise<void>
|
||||||
@@ -146,6 +147,7 @@ export function RealtimeVoiceSession({
|
|||||||
api,
|
api,
|
||||||
micMuted: micMutedProp = false,
|
micMuted: micMutedProp = false,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onRegistered,
|
||||||
getSession,
|
getSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
approvePermission,
|
approvePermission,
|
||||||
@@ -241,6 +243,7 @@ export function RealtimeVoiceSession({
|
|||||||
try {
|
try {
|
||||||
registerVoiceSession(new RealtimeVoiceSessionImpl(api))
|
registerVoiceSession(new RealtimeVoiceSessionImpl(api))
|
||||||
hasRegistered.current = true
|
hasRegistered.current = true
|
||||||
|
onRegistered?.()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Voice] Failed to register voice session:', error)
|
console.error('[Voice] Failed to register voice session:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { lazy, Suspense, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { RealtimeVoiceSession } from './RealtimeVoiceSession'
|
||||||
|
import type { RealtimeVoiceSessionProps } from './RealtimeVoiceSession'
|
||||||
|
import { fetchVoiceBackend } from '@/api/voice'
|
||||||
|
import type { ApiClient } from '@/api/client'
|
||||||
|
import type { VoiceBackendType } from '@hapi/protocol/voice'
|
||||||
|
|
||||||
|
// Lazy-load alternative backends to avoid bundling when using ElevenLabs
|
||||||
|
const GeminiLiveVoiceSession = lazy(() =>
|
||||||
|
import('./GeminiLiveVoiceSession').then((m) => ({ default: m.GeminiLiveVoiceSession }))
|
||||||
|
)
|
||||||
|
const QwenVoiceSession = lazy(() =>
|
||||||
|
import('./QwenVoiceSession').then((m) => ({ default: m.QwenVoiceSession }))
|
||||||
|
)
|
||||||
|
|
||||||
|
export type VoiceBackendSessionProps = RealtimeVoiceSessionProps & {
|
||||||
|
api: ApiClient
|
||||||
|
onReadyChange?: (ready: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically selects the voice session component based on the hub's configured backend.
|
||||||
|
* Queries GET /voice/backend once on mount and renders the appropriate component.
|
||||||
|
* Only signals readiness after the selected backend has mounted and registered its session.
|
||||||
|
*/
|
||||||
|
export function VoiceBackendSession(props: VoiceBackendSessionProps) {
|
||||||
|
const [backend, setBackend] = useState<VoiceBackendType | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setBackend(null)
|
||||||
|
fetchVoiceBackend(props.api).then((resp) => {
|
||||||
|
if (!cancelled) setBackend(resp.backend)
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Could not detect voice backend'
|
||||||
|
props.onStatusChange?.('error', msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
props.onReadyChange?.(false)
|
||||||
|
}
|
||||||
|
}, [props.api]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleRegistered = useCallback(() => {
|
||||||
|
props.onReadyChange?.(true)
|
||||||
|
}, [props.onReadyChange])
|
||||||
|
|
||||||
|
if (!backend) return null
|
||||||
|
|
||||||
|
if (backend === 'gemini-live') {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<GeminiLiveVoiceSession {...props} onRegistered={handleRegistered} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backend === 'qwen-realtime') {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<QwenVoiceSession {...props} onRegistered={handleRegistered} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RealtimeVoiceSession {...props} onRegistered={handleRegistered} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { base64ToArrayBuffer, pcm16ToFloat32 } from './pcmUtils';
|
||||||
|
|
||||||
|
export class GeminiAudioPlayer {
|
||||||
|
private audioContext: AudioContext;
|
||||||
|
private ownsContext: boolean;
|
||||||
|
private lastEndTime: number = 0;
|
||||||
|
private activeSources: AudioBufferSourceNode[] = [];
|
||||||
|
|
||||||
|
constructor(audioContext?: AudioContext) {
|
||||||
|
if (audioContext) {
|
||||||
|
this.audioContext = audioContext;
|
||||||
|
this.ownsContext = false;
|
||||||
|
} else {
|
||||||
|
this.audioContext = new AudioContext({ sampleRate: 24000 });
|
||||||
|
this.ownsContext = true;
|
||||||
|
}
|
||||||
|
this.lastEndTime = this.audioContext.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(base64Pcm: string): void {
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = base64ToArrayBuffer(base64Pcm);
|
||||||
|
const float32Data = pcm16ToFloat32(arrayBuffer);
|
||||||
|
|
||||||
|
if (float32Data.length === 0) return;
|
||||||
|
|
||||||
|
const audioBuffer = this.audioContext.createBuffer(1, float32Data.length, 24000);
|
||||||
|
audioBuffer.copyToChannel(new Float32Array(float32Data), 0);
|
||||||
|
|
||||||
|
const source = this.audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
const startTime = Math.max(this.audioContext.currentTime, this.lastEndTime);
|
||||||
|
|
||||||
|
source.onended = () => {
|
||||||
|
const index = this.activeSources.indexOf(source);
|
||||||
|
if (index > -1) {
|
||||||
|
this.activeSources.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.start(startTime);
|
||||||
|
this.activeSources.push(source);
|
||||||
|
|
||||||
|
this.lastEndTime = startTime + audioBuffer.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearQueue(): void {
|
||||||
|
this.activeSources.forEach(source => {
|
||||||
|
try {
|
||||||
|
source.stop();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore if already stopped
|
||||||
|
}
|
||||||
|
source.disconnect();
|
||||||
|
});
|
||||||
|
this.activeSources = [];
|
||||||
|
this.lastEndTime = this.audioContext.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying(): boolean {
|
||||||
|
return this.lastEndTime > this.audioContext.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.clearQueue();
|
||||||
|
if (this.ownsContext && this.audioContext.state !== 'closed') {
|
||||||
|
this.audioContext.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { float32ToPcm16, arrayBufferToBase64 } from './pcmUtils';
|
||||||
|
|
||||||
|
// Inline worklet source to avoid Vite bundling issues with ?url imports.
|
||||||
|
// AudioWorklet.addModule() requires a URL to valid JS, so we create a Blob URL.
|
||||||
|
const WORKLET_SOURCE = `
|
||||||
|
class PcmRecorderProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.buffer = new Float32Array(4096);
|
||||||
|
this.idx = 0;
|
||||||
|
}
|
||||||
|
process(inputs) {
|
||||||
|
const input = inputs[0];
|
||||||
|
if (input && input.length > 0) {
|
||||||
|
const channel = input[0];
|
||||||
|
for (let i = 0; i < channel.length; i++) {
|
||||||
|
this.buffer[this.idx++] = channel[i];
|
||||||
|
if (this.idx >= 4096) {
|
||||||
|
this.port.postMessage({ samples: this.buffer.slice() });
|
||||||
|
this.idx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerProcessor('pcm-recorder-processor', PcmRecorderProcessor);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createWorkletUrl(): string {
|
||||||
|
const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeminiAudioRecorder {
|
||||||
|
private audioContext: AudioContext | null = null;
|
||||||
|
private mediaStream: MediaStream | null = null;
|
||||||
|
private sourceNode: MediaStreamAudioSourceNode | null = null;
|
||||||
|
private workletNode: AudioWorkletNode | null = null;
|
||||||
|
private scriptNode: ScriptProcessorNode | null = null;
|
||||||
|
|
||||||
|
async start(onChunk: (base64Pcm: string) => void, onError?: (error: Error) => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { sampleRate: 16000, channelCount: 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mediaStream.getTracks().forEach((track) => {
|
||||||
|
track.onended = () => {
|
||||||
|
if (onError) onError(new Error('Microphone disconnected'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.audioContext = new AudioContext({ sampleRate: 16000 });
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
await this.audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sourceNode = this.audioContext.createMediaStreamSource(this.mediaStream);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workletUrl = createWorkletUrl();
|
||||||
|
await this.audioContext.audioWorklet.addModule(workletUrl);
|
||||||
|
URL.revokeObjectURL(workletUrl);
|
||||||
|
|
||||||
|
this.workletNode = new AudioWorkletNode(this.audioContext, 'pcm-recorder-processor');
|
||||||
|
this.workletNode.port.onmessage = (event) => {
|
||||||
|
const pcm16 = float32ToPcm16(event.data.samples);
|
||||||
|
const base64 = arrayBufferToBase64(pcm16);
|
||||||
|
onChunk(base64);
|
||||||
|
};
|
||||||
|
// Connect source → worklet → silent sink → destination.
|
||||||
|
// The downstream connection is required so the audio graph pulls
|
||||||
|
// frames through the worklet node and port.onmessage fires.
|
||||||
|
const sink = this.audioContext.createGain();
|
||||||
|
sink.gain.value = 0;
|
||||||
|
this.sourceNode.connect(this.workletNode);
|
||||||
|
this.workletNode.connect(sink);
|
||||||
|
sink.connect(this.audioContext.destination);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[GeminiLive] AudioWorklet failed, falling back to ScriptProcessorNode', e);
|
||||||
|
this.scriptNode = this.audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
this.scriptNode.onaudioprocess = (event) => {
|
||||||
|
const inputData = event.inputBuffer.getChannelData(0);
|
||||||
|
const pcm16 = float32ToPcm16(new Float32Array(inputData));
|
||||||
|
const base64 = arrayBufferToBase64(pcm16);
|
||||||
|
onChunk(base64);
|
||||||
|
};
|
||||||
|
this.sourceNode.connect(this.scriptNode);
|
||||||
|
this.scriptNode.connect(this.audioContext.destination);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (onError) onError(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.mediaStream) {
|
||||||
|
this.mediaStream.getTracks().forEach(track => {
|
||||||
|
track.onended = null;
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
this.mediaStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.scriptNode) {
|
||||||
|
this.scriptNode.disconnect();
|
||||||
|
this.scriptNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.workletNode) {
|
||||||
|
this.workletNode.disconnect();
|
||||||
|
this.workletNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sourceNode) {
|
||||||
|
this.sourceNode.disconnect();
|
||||||
|
this.sourceNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audioContext) {
|
||||||
|
this.audioContext.close();
|
||||||
|
this.audioContext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMuted(muted: boolean): void {
|
||||||
|
if (this.mediaStream) {
|
||||||
|
this.mediaStream.getAudioTracks().forEach(track => {
|
||||||
|
track.enabled = !muted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// AudioWorklet processor runs in a separate scope with its own globals.
|
||||||
|
// These declarations satisfy TypeScript without pulling in DOM lib types.
|
||||||
|
declare class AudioWorkletProcessor {
|
||||||
|
readonly port: MessagePort
|
||||||
|
constructor()
|
||||||
|
}
|
||||||
|
declare function registerProcessor(name: string, ctor: new () => AudioWorkletProcessor): void
|
||||||
|
|
||||||
|
class PcmRecorderProcessor extends AudioWorkletProcessor {
|
||||||
|
private buffer: Float32Array;
|
||||||
|
private bufferSize = 4096;
|
||||||
|
private bufferIndex = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.buffer = new Float32Array(this.bufferSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs: Float32Array[][]): boolean {
|
||||||
|
const input = inputs[0];
|
||||||
|
if (input && input.length > 0) {
|
||||||
|
const channel = input[0];
|
||||||
|
for (let i = 0; i < channel.length; i++) {
|
||||||
|
this.buffer[this.bufferIndex++] = channel[i];
|
||||||
|
if (this.bufferIndex >= this.bufferSize) {
|
||||||
|
this.port.postMessage({ samples: this.buffer.slice() });
|
||||||
|
this.bufferIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('pcm-recorder-processor', PcmRecorderProcessor);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
float32ToPcm16,
|
||||||
|
pcm16ToFloat32,
|
||||||
|
arrayBufferToBase64,
|
||||||
|
base64ToArrayBuffer
|
||||||
|
} from './pcmUtils'
|
||||||
|
|
||||||
|
describe('pcmUtils', () => {
|
||||||
|
describe('float32ToPcm16 / pcm16ToFloat32 round-trip', () => {
|
||||||
|
test('preserves signal within quantization error', () => {
|
||||||
|
const input = new Float32Array([0, 0.5, -0.5, 1.0, -1.0])
|
||||||
|
const pcm16 = float32ToPcm16(input)
|
||||||
|
const output = pcm16ToFloat32(pcm16)
|
||||||
|
|
||||||
|
expect(output.length).toBe(input.length)
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
expect(Math.abs(output[i] - input[i])).toBeLessThan(0.001)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clamps values outside [-1, 1]', () => {
|
||||||
|
const input = new Float32Array([2.0, -2.0])
|
||||||
|
const pcm16 = float32ToPcm16(input)
|
||||||
|
const output = pcm16ToFloat32(pcm16)
|
||||||
|
|
||||||
|
expect(Math.abs(output[0] - 1.0)).toBeLessThan(0.001)
|
||||||
|
expect(Math.abs(output[1] - (-1.0))).toBeLessThan(0.001)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles empty input', () => {
|
||||||
|
const input = new Float32Array(0)
|
||||||
|
const pcm16 = float32ToPcm16(input)
|
||||||
|
expect(pcm16.byteLength).toBe(0)
|
||||||
|
const output = pcm16ToFloat32(pcm16)
|
||||||
|
expect(output.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('arrayBufferToBase64 / base64ToArrayBuffer round-trip', () => {
|
||||||
|
test('preserves binary data', () => {
|
||||||
|
const original = new Uint8Array([0, 1, 127, 128, 255])
|
||||||
|
const base64 = arrayBufferToBase64(original.buffer)
|
||||||
|
const restored = new Uint8Array(base64ToArrayBuffer(base64))
|
||||||
|
|
||||||
|
expect(restored.length).toBe(original.length)
|
||||||
|
for (let i = 0; i < original.length; i++) {
|
||||||
|
expect(restored[i]).toBe(original[i])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles empty buffer', () => {
|
||||||
|
const empty = new ArrayBuffer(0)
|
||||||
|
const base64 = arrayBufferToBase64(empty)
|
||||||
|
expect(base64).toBe('')
|
||||||
|
const restored = base64ToArrayBuffer(base64)
|
||||||
|
expect(restored.byteLength).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export function float32ToPcm16(samples: Float32Array): ArrayBuffer {
|
||||||
|
const buffer = new ArrayBuffer(samples.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
let s = Math.max(-1, Math.min(1, samples[i]));
|
||||||
|
s = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||||
|
view.setInt16(i * 2, s, true);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pcm16ToFloat32(buffer: ArrayBuffer): Float32Array {
|
||||||
|
const int16Array = new Int16Array(buffer);
|
||||||
|
const float32Array = new Float32Array(int16Array.length);
|
||||||
|
for (let i = 0; i < int16Array.length; i++) {
|
||||||
|
const s = int16Array[i];
|
||||||
|
float32Array[i] = s < 0 ? s / 0x8000 : s / 0x7FFF;
|
||||||
|
}
|
||||||
|
return float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
let binary = '';
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const len = bytes.byteLength;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import { handleGeminiFunctionCall, handleGeminiFunctionCalls } from './toolAdapter'
|
||||||
|
import type { GeminiFunctionCall } from './toolAdapter'
|
||||||
|
|
||||||
|
describe('toolAdapter', () => {
|
||||||
|
test('returns error for unknown tool', async () => {
|
||||||
|
const call: GeminiFunctionCall = {
|
||||||
|
name: 'unknownTool',
|
||||||
|
args: {},
|
||||||
|
id: 'call-1'
|
||||||
|
}
|
||||||
|
const resp = await handleGeminiFunctionCall(call)
|
||||||
|
expect(resp.name).toBe('unknownTool')
|
||||||
|
expect(resp.id).toBe('call-1')
|
||||||
|
expect(resp.response.result).toContain('unknown tool')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles multiple calls in parallel', async () => {
|
||||||
|
const calls: GeminiFunctionCall[] = [
|
||||||
|
{ name: 'unknownA', args: {}, id: 'a' },
|
||||||
|
{ name: 'unknownB', args: {}, id: 'b' }
|
||||||
|
]
|
||||||
|
const responses = await handleGeminiFunctionCalls(calls)
|
||||||
|
expect(responses.length).toBe(2)
|
||||||
|
expect(responses[0].id).toBe('a')
|
||||||
|
expect(responses[1].id).toBe('b')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { realtimeClientTools } from '../realtimeClientTools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini Live API function call from server.
|
||||||
|
* Matches the `toolCall` shape in a BidiGenerateContent serverMessage.
|
||||||
|
*/
|
||||||
|
export interface GeminiFunctionCall {
|
||||||
|
name: string
|
||||||
|
args: Record<string, unknown>
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response sent back to Gemini Live via `toolResponse`.
|
||||||
|
*/
|
||||||
|
export interface GeminiFunctionResponse {
|
||||||
|
name: string
|
||||||
|
id: string
|
||||||
|
response: { result: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientToolHandler = (parameters: unknown) => Promise<string>
|
||||||
|
|
||||||
|
const toolHandlers: Record<string, ClientToolHandler> = {
|
||||||
|
messageCodingAgent: realtimeClientTools.messageCodingAgent,
|
||||||
|
processPermissionRequest: realtimeClientTools.processPermissionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a Gemini Live function call using the existing client tool handlers.
|
||||||
|
* Returns a GeminiFunctionResponse ready to send back over the WebSocket.
|
||||||
|
*/
|
||||||
|
export async function handleGeminiFunctionCall(
|
||||||
|
call: GeminiFunctionCall
|
||||||
|
): Promise<GeminiFunctionResponse> {
|
||||||
|
const handler = toolHandlers[call.name]
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
return {
|
||||||
|
name: call.name,
|
||||||
|
id: call.id,
|
||||||
|
response: { result: `error (unknown tool: ${call.name})` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handler(call.args)
|
||||||
|
return {
|
||||||
|
name: call.name,
|
||||||
|
id: call.id,
|
||||||
|
response: { result }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'unknown error'
|
||||||
|
return {
|
||||||
|
name: call.name,
|
||||||
|
id: call.id,
|
||||||
|
response: { result: `error (${message})` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process multiple function calls sequentially to avoid racing on shared
|
||||||
|
* session state (e.g. processPermissionRequest resolving the same pending
|
||||||
|
* request twice when calls run in parallel).
|
||||||
|
*/
|
||||||
|
export async function handleGeminiFunctionCalls(
|
||||||
|
calls: GeminiFunctionCall[]
|
||||||
|
): Promise<GeminiFunctionResponse[]> {
|
||||||
|
const responses: GeminiFunctionResponse[] = []
|
||||||
|
for (const call of calls) {
|
||||||
|
responses.push(await handleGeminiFunctionCall(call))
|
||||||
|
}
|
||||||
|
return responses
|
||||||
|
}
|
||||||
@@ -151,7 +151,13 @@ export const voiceHooks = {
|
|||||||
reportSession(sessionId)
|
reportSession(sessionId)
|
||||||
const messages = messagesGetter?.(sessionId) ?? []
|
const messages = messagesGetter?.(sessionId) ?? []
|
||||||
const lastAssistantText = extractLastAssistantSpeakable(messages)
|
const lastAssistantText = extractLastAssistantSpeakable(messages)
|
||||||
reportTextUpdate(formatReadyEvent(sessionId, lastAssistantText))
|
const update = formatReadyEvent(sessionId, lastAssistantText)
|
||||||
|
const proactive = localStorage.getItem('hapi-voice-proactive') === 'true'
|
||||||
|
if (proactive) {
|
||||||
|
reportTextUpdate(update)
|
||||||
|
} else {
|
||||||
|
reportContextualUpdate(update)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export {
|
|||||||
// Client tools
|
// Client tools
|
||||||
export { realtimeClientTools, registerSessionStore } from './realtimeClientTools'
|
export { realtimeClientTools, registerSessionStore } from './realtimeClientTools'
|
||||||
|
|
||||||
// Voice session component
|
// Voice session components
|
||||||
export { RealtimeVoiceSession, type RealtimeVoiceSessionProps } from './RealtimeVoiceSession'
|
export { RealtimeVoiceSession, type RealtimeVoiceSessionProps } from './RealtimeVoiceSession'
|
||||||
|
export { VoiceBackendSession, type VoiceBackendSessionProps } from './VoiceBackendSession'
|
||||||
|
|
||||||
// Voice hooks
|
// Voice hooks
|
||||||
export { voiceHooks, registerVoiceHooksStore } from './hooks/voiceHooks'
|
export { voiceHooks, registerVoiceHooksStore } from './hooks/voiceHooks'
|
||||||
|
|||||||
@@ -349,6 +349,20 @@ export default function SettingsPage() {
|
|||||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
|
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
|
||||||
const currentAudioRef = useRef<HTMLAudioElement | null>(null)
|
const currentAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
// Voice proactive mode - read from localStorage, default false (reactive)
|
||||||
|
const [voiceProactive, setVoiceProactive] = useState<boolean>(() => {
|
||||||
|
return localStorage.getItem('hapi-voice-proactive') === 'true'
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleVoiceProactiveChange = (value: boolean) => {
|
||||||
|
setVoiceProactive(value)
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem('hapi-voice-proactive', 'true')
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('hapi-voice-proactive')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fontScaleOptions = getFontScaleOptions()
|
const fontScaleOptions = getFontScaleOptions()
|
||||||
const terminalFontSizeOptions = getTerminalFontSizeOptions()
|
const terminalFontSizeOptions = getTerminalFontSizeOptions()
|
||||||
const composerEnterBehaviorOptions = getComposerEnterBehaviorOptions()
|
const composerEnterBehaviorOptions = getComposerEnterBehaviorOptions()
|
||||||
@@ -988,7 +1002,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={voicePickerContainerRef} className="relative">
|
<div ref={voicePickerContainerRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1075,6 +1088,28 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--app-divider)] px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[var(--app-fg)]">{t('settings.voice.proactive')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={voiceProactive}
|
||||||
|
onClick={() => handleVoiceProactiveChange(!voiceProactive)}
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none ${
|
||||||
|
voiceProactive ? 'bg-[var(--app-link)]' : 'bg-[var(--app-border)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg transition-transform ${
|
||||||
|
voiceProactive ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--app-hint)]">{t('settings.voice.proactive.description')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* About section */}
|
{/* About section */}
|
||||||
|
|||||||
+2
-1
@@ -11,5 +11,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user