The rule
Voice is input/output only. The agent loop doesn’t move.- A finished spoken turn (
turn.ended) is sent through the same function the composer’s Send button calls. Tools, approvals, steering and the thread behave as if it were typed. - The assistant reply the thread already renders is streamed to the engine as
speak.delta/speak.done. - Barge-in (
speak.interrupted) calls the composer’s Stop.
Modules (src/lib/voice/)
UI lives in
src/components/voice/ (VoiceSession hosts VoiceBar and VoiceFullScreen; VoiceOrb wraps the brand AllternitOrb; SpokenLabel) and src/components/settings/VoiceSettingsPanel.tsx.
Surfaces
All three surfaces build from the same components:
Phone sizing comes from
useIsPhoneViewport(): full-screen controls are 60 px round, padding respects env(safe-area-inset-*), and the PWA’s interactive-widget=resizes-content viewport keeps the bar above the keyboard. Under prefers-reduced-motion the orb is still (its own CSS) and the level ring is off; every state is also text (role="status").
Endpoint resolution
- Device (Desktop only):
window.allternit.voice.getSessionEndpoint()→{ wsUrl?, httpUrl?, port?, token? }from the preload bridge. Desktop builds without that bridge fall back tohttp://127.0.0.1:8001(platformPORTS.VOICE) and no token.isAvailable()false →engine-not-installed. Web and the PWA never connect to loopback. - Cloud:
POST /api/v1/voice/tickets(allternit-api viaruntimeApiUrl, bearer auth) →{ ticket, url?, expiresAt? }. The session URL isurlorVITE_VOICE_CLOUD_URL; it must bewss://or the client refuses. Status mapping: no token or 401 →signed-out; 402 →no-cloud-minutes; other errors →cloud-unavailable(retried with backoff). - Automatic (
chooseEngine): device when packs report ready and the measured RTF ≤ 0.9 (averaged over the first 3 utterances; unmeasured counts as fine); else cloud when signed in; else a message naming what’s missing.
speak.done, so waiting for model text doesn’t count against the device.
Engine HTTP contract assumed by the UI (Phase 1 engine)
Parsed defensively; confirm or adjust with the engine owner.GET /v1/models→{ ready?: boolean, packs: [{ id, name, sizeBytes, state: "installed"|"downloading"|"available", progress?: 0–1 or 0–100 }] }. A missing endpoint is shown as “Voice engine not installed”.POST /v1/models/{id}/downloadstarts a pack download. The “Get Accurate pack” button looks for a pack whose id isaccurate.GET /v1/voices→["af_heart", …]or{ voices: [{ id, name?, language?, sampleUrl? }] }. Samples default toGET /v1/voices/{id}/sample(any browser-playable audio).
Errors and states
Phone calls: joinCallAudio
joinCallAudio(callId, { mode: 'listen' | 'talk', token, url, room }) in src/lib/gateway/call-webrtc.ts. The caller (CallScreen) gets {token, url, room} from the listen or takeover call control and passes them in; the function doesn’t fetch tokens. It lazy-loads livekit-client (Apache-2.0), connects to url (production: wss://livekit.allternit.com), plays every remote audio track through hidden <audio data-call-audio> elements, unlocks playback with startAudio() (the call runs from a click), publishes the mic only in talk, and returns { callId, leave }. It resolves null (and disconnects) when credentials are missing, the connect fails, or the mic is refused in talk, so the UI never claims a person is on the call when they aren’t.
Tests
src/lib/voice/__tests__/: an in-memory mock Voice Session server (mock-voice-server.ts) drives session-client.test.ts; conversation.test.ts, voice-lib.test.ts (PCM, the shipped playback worklet, endpoints, Automatic mode, engine parsing) and call-audio.test.ts (fake LiveKit room). UI: src/components/voice/__tests__/.
What replaced what
The quick voice bar and full-screen voice replacecomponents/chat/VoiceModeOverlay.tsx and components/ai-elements/voice-call-mode.tsx. The orphaned ai-elements/voice-{presence,overlay,session,toolbar,controls,selector,visualizer,provider}.tsx were removed with the ShellApp VoicePresence mount. Dictation (useComposerVoice) keeps Desktop’s IPC transcription and uses a Voice Session elsewhere instead of a loopback /v1/stt. The legacy providers/voice-provider + services/voice (bot voice previews/TTS on Desktop) remain for the bot voice settings panel.