From 4d6acfed2bd7f9f165bddde8c173303e99da4b68 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:41:50 -0400 Subject: [PATCH 01/18] feat: live voice conversations for Fast sessions Adds a hands-free voice mode to the session composer: microphone audio streams to OpenAI realtime transcription over WebRTC (ephemeral, transcription-scoped tokens minted server-side), completed utterances send through the normal fastSessions.reply path, and replies are read aloud via streaming gpt-4o-mini-tts PCM with barge-in on detected speech. Voice rides R_VOICE_OPENAI_API_KEY with OPENAI_API_KEY fallback; the key stays on the control plane and unset keys hide the feature. --- apps/docs/docs.json | 3 +- apps/docs/environment-variables.mdx | 1 + apps/docs/voice.mdx | 46 ++ .../[sessionId]/FastSessionTranscript.tsx | 137 ++++++ .../[sessionId]/SessionPromptInput.tsx | 52 ++- apps/web/src/app/api/voice/tts/route.ts | 75 ++++ apps/web/src/components/ai-elements/index.ts | 1 + .../src/components/ai-elements/live-voice.tsx | 112 +++++ .../src/components/system/primitives/icons.ts | 1 + apps/web/src/hooks/useLiveVoice.ts | 398 ++++++++++++++++++ apps/web/src/lib/server/voice.ts | 149 +++++++ apps/web/src/lib/voice-speech.test.ts | 68 +++ apps/web/src/lib/voice-speech.ts | 109 +++++ .../web/src/trpc/commands/voice/index.test.ts | 75 ++++ apps/web/src/trpc/commands/voice/index.ts | 43 ++ apps/web/src/trpc/routers/_app.ts | 11 + packages/env/src/index.ts | 7 + .../types/src/control-plane-env-vars.test.ts | 1 + packages/types/src/control-plane-env-vars.ts | 1 + 19 files changed, 1283 insertions(+), 7 deletions(-) create mode 100644 apps/docs/voice.mdx create mode 100644 apps/web/src/app/api/voice/tts/route.ts create mode 100644 apps/web/src/components/ai-elements/live-voice.tsx create mode 100644 apps/web/src/hooks/useLiveVoice.ts create mode 100644 apps/web/src/lib/server/voice.ts create mode 100644 apps/web/src/lib/voice-speech.test.ts create mode 100644 apps/web/src/lib/voice-speech.ts create mode 100644 apps/web/src/trpc/commands/voice/index.test.ts create mode 100644 apps/web/src/trpc/commands/voice/index.ts diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 59bdaaf7da..47d1760662 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -58,7 +58,8 @@ "goal-mode", "fast-sessions", "memory", - "file-attachments" + "file-attachments", + "voice" ] }, { diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index d348043c07..141f5f6cd0 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -366,6 +366,7 @@ as per-task auth tokens or workspace paths. | `R_ALLOWED_EMAILS` | Optional | Comma-separated email allowlist for deployments that restrict sign-in by email. | | `R_ELEVENLABS_API_KEY` | Optional | ElevenLabs API key for narrated feature-demo videos. The key stays on the control plane; sandboxes reach text-to-speech only through an authenticated Roomote endpoint. A key scoped to text-to-speech only is sufficient and recommended. | | `R_ELEVENLABS_VOICE_ID` | Optional | ElevenLabs voice ID used for feature-demo narration. Required alongside the API key for narration to be available. | +| `R_VOICE_OPENAI_API_KEY` | Optional | OpenAI key dedicated to the live voice conversation feature (realtime transcription and spoken replies). Falls back to `OPENAI_API_KEY` when unset. The key stays on the control plane; browsers receive only short-lived transcription-scoped tokens and synthesized audio. | During Microsoft Teams setup, Roomote uses the Microsoft Entra app values for the Teams bot by default. Use **Show advanced config** after the Directory diff --git a/apps/docs/voice.mdx b/apps/docs/voice.mdx new file mode 100644 index 0000000000..26f2015aa9 --- /dev/null +++ b/apps/docs/voice.mdx @@ -0,0 +1,46 @@ +--- +title: Voice +icon: audio-lines +description: Talk to Fast hands-free with live transcription and spoken replies. +--- + +Voice turns a Session into a spoken conversation. Start a voice conversation +from the session composer and talk naturally: Roomote transcribes your speech +live, sends each utterance to Fast as an ordinary session message, and reads +the reply back out loud. You can interrupt a spoken reply just by talking, and +the transcript stays in the Session like any typed exchange, so you can move +between voice and text freely. + +## Enabling voice + +Voice uses OpenAI for live transcription and speech synthesis. It is available +on any deployment with an OpenAI API key: + +- `R_VOICE_OPENAI_API_KEY` dedicates a key to voice (for example to bill it + separately from task inference). +- Without it, voice falls back to the deployment's general `OPENAI_API_KEY`. + +When neither key is configured the voice button does not appear. Keys stay on +the control plane: the browser receives only short-lived, transcription-scoped +tokens and synthesized audio, never the API key. + +## Using voice + +1. Open a Session and select the voice button in the composer. +2. Grant microphone access when the browser asks. +3. Speak. A pause ends your turn and sends it to Fast automatically; the live + transcription is shown above the composer while you talk. +4. Fast's reply is read aloud when it arrives. Speak at any time to interrupt + the playback and take the next turn. +5. Select **End** (or the voice button again) to return to typing. + +Voice input requires a browser with microphone and WebRTC support, which +includes current Chrome, Edge, Safari, and Firefox. + +## What gets spoken + +Replies are cleaned up for listening: code blocks are acknowledged but not +read character-by-character, links are read by their label, and long replies +are synthesized in order so playback starts quickly. Work that Fast delegates +to execution continues to appear in the Session as usual; voice reads the +conversational replies. diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index ec377d4da6..8080f14919 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -29,6 +29,7 @@ import { Conversation, ConversationContent, ConversationScrollButton, + LiveVoiceStatusBar, Message, MessageContent, MessageUiOptionsProvider, @@ -39,8 +40,10 @@ import { type SlackMentionScope, } from '@/components/ai-elements/slack-mention-context'; import { WorkspaceHeader } from '@/components/layout'; +import { useLiveVoice } from '@/hooks/useLiveVoice'; import { SessionPromptInput, + type SessionModelSelection, type SessionPromptSubmission, } from './SessionPromptInput'; import { preparePromptAttachments } from '@/lib/prompt-attachments'; @@ -739,6 +742,116 @@ export function FastSessionTranscript({ [sessionId, trpcClient], ); + // --- Live voice conversation ------------------------------------------- + + const [voiceEnabled, setVoiceEnabled] = useState(false); + const modelSelectionRef = useRef({ + model: sessionModel, + reasoningEffort: sessionReasoningEffort, + }); + /** Assistant messages at or before this ts have already been spoken. */ + const lastSpokenTsRef = useRef(0); + const previousPendingRef = useRef( + pendingResponseState.pendingAfter, + ); + const pendingUtterancesRef = useRef([]); + const [utteranceQueueVersion, setUtteranceQueueVersion] = useState(0); + + useEffect(() => { + let cancelled = false; + trpcClient.voice.status + .query() + .then((voiceStatus) => { + if (!cancelled) { + setVoiceEnabled(voiceStatus.enabled); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [trpcClient]); + + const enqueueVoiceUtterance = useCallback((text: string) => { + pendingUtterancesRef.current.push(text); + setUtteranceQueueVersion((version) => version + 1); + }, []); + + const liveVoice = useLiveVoice({ onUtterance: enqueueVoiceUtterance }); + + // Utterances queue rather than dropping when one lands while the previous + // reply is still in flight; the queue drains as each send settles. + useEffect(() => { + if (isSending) { + return; + } + + const next = pendingUtterancesRef.current.shift(); + + if (next === undefined) { + return; + } + + void sendReply({ + text: next, + files: [], + model: modelSelectionRef.current.model, + reasoningEffort: modelSelectionRef.current.reasoningEffort, + }); + }, [isSending, utteranceQueueVersion, sendReply]); + + // Speak the agent's reply once it settles: when the pending-response state + // clears, every not-yet-spoken assistant message since the last spoken one + // is read aloud as a single reply. + const liveVoiceActive = liveVoice.active; + const speakRef = useRef(liveVoice.speak); + speakRef.current = liveVoice.speak; + + useEffect(() => { + const wasPending = previousPendingRef.current !== null; + previousPendingRef.current = pendingResponseState.pendingAfter; + + if ( + !liveVoiceActive || + !wasPending || + pendingResponseState.pendingAfter !== null + ) { + return; + } + + const unspoken = messages.filter( + (message) => + message.role !== 'user' && + message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage && + message.metadata?.visibleInTranscript !== false && + message.ts > lastSpokenTsRef.current, + ); + const texts = unspoken + .map((message) => getTextFromContentBlocks(message.contentBlocks)?.trim()) + .filter((text): text is string => Boolean(text)); + + if (texts.length === 0) { + return; + } + + lastSpokenTsRef.current = Math.max( + ...unspoken.map((message) => message.ts), + ); + speakRef.current(texts.join('\n\n')); + }, [pendingResponseState.pendingAfter, liveVoiceActive, messages]); + + const handleVoiceToggle = useCallback(() => { + if (liveVoice.active) { + liveVoice.stop(); + return; + } + + // Replies that predate the conversation stay silent. + lastSpokenTsRef.current = Date.now(); + pendingUtterancesRef.current = []; + void liveVoice.start(); + }, [liveVoice]); + return ( {canReply && !pendingInputRequest ? (
+ {liveVoice.status !== 'idle' ? ( + + ) : null} { + modelSelectionRef.current = selection; + }} /> {replyError ? (

{replyError}

diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx index 1a89f944ea..71a4bfaf75 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx @@ -15,6 +15,7 @@ import { } from '@/hooks/useGhostSuggestion'; import { type PromptInputMessage, + LiveVoiceButton, PromptInput as PromptInputRoot, PromptInputActionAddAttachments, PromptInputActionMenu, @@ -39,6 +40,19 @@ export type SessionPromptSubmission = PromptInputMessage & { reasoningEffort: ReasoningEffort | null; }; +export type SessionModelSelection = { + model: string | null; + reasoningEffort: ReasoningEffort | null; +}; + +type SessionVoiceControls = { + /** Whether the deployment has voice configured at all. */ + enabled: boolean; + /** Whether a voice conversation is currently running. */ + active: boolean; + onToggle: () => void; +}; + function SessionSubmit({ sending, prompt, @@ -70,6 +84,8 @@ export function SessionPromptInput({ initialReasoningEffort = null, defaultModelId = null, defaultReasoningEffort = null, + voice, + onModelSelectionChange, }: { sessionId: string; isBusy: boolean; @@ -89,6 +105,10 @@ export function SessionPromptInput({ initialReasoningEffort?: ReasoningEffort | null; defaultModelId?: string | null; defaultReasoningEffort?: ReasoningEffort | null; + voice?: SessionVoiceControls; + /** Keeps the parent's view of the picker current, so voice utterances + * round-trip the same model selection a typed reply would. */ + onModelSelectionChange?: (selection: SessionModelSelection) => void; }) { const trpc = useTRPC(); const trpcClient = useTRPCClient(); @@ -190,9 +210,14 @@ export function SessionPromptInput({ const handleModelChange = (nextModel: string) => { const previousModel = model; setModel(nextModel); - void updateModelSelection({ model: nextModel || null }, () => - setModel(previousModel), - ); + onModelSelectionChange?.({ model: nextModel || null, reasoningEffort }); + void updateModelSelection({ model: nextModel || null }, () => { + setModel(previousModel); + onModelSelectionChange?.({ + model: previousModel || null, + reasoningEffort, + }); + }); }; const handleReasoningEffortChange = ( @@ -200,9 +225,17 @@ export function SessionPromptInput({ ) => { const previousReasoningEffort = reasoningEffort; setReasoningEffort(nextReasoningEffort); - void updateModelSelection({ reasoningEffort: nextReasoningEffort }, () => - setReasoningEffort(previousReasoningEffort), - ); + onModelSelectionChange?.({ + model: model || null, + reasoningEffort: nextReasoningEffort, + }); + void updateModelSelection({ reasoningEffort: nextReasoningEffort }, () => { + setReasoningEffort(previousReasoningEffort); + onModelSelectionChange?.({ + model: model || null, + reasoningEffort: previousReasoningEffort, + }); + }); }; const controlsDisabled = isBusy || isUpdatingModelSelection; @@ -277,6 +310,13 @@ export function SessionPromptInput({ />
+ {voice?.enabled ? ( + + ) : null} ; + + try { + parsed = requestSchema.parse(await request.json()); + } catch { + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 }, + ); + } + + try { + const stream = await createVoiceSpeechStream({ + apiKey, + text: parsed.text, + }); + + return new Response(stream, { + headers: { + // 24kHz 16-bit signed little-endian mono PCM. + 'content-type': `audio/pcm;rate=${VOICE_TTS_SAMPLE_RATE}`, + 'cache-control': 'no-store', + }, + }); + } catch { + return NextResponse.json( + { error: 'Speech synthesis failed' }, + { status: 502 }, + ); + } +} diff --git a/apps/web/src/components/ai-elements/index.ts b/apps/web/src/components/ai-elements/index.ts index 2610d04cff..af28931a15 100644 --- a/apps/web/src/components/ai-elements/index.ts +++ b/apps/web/src/components/ai-elements/index.ts @@ -4,6 +4,7 @@ export * from './collapsible-content'; export * from './conversation'; export * from './context'; export * from './custom-link'; +export * from './live-voice'; export * from './message'; export * from './message-ui-options'; export * from './prompt-input'; diff --git a/apps/web/src/components/ai-elements/live-voice.tsx b/apps/web/src/components/ai-elements/live-voice.tsx new file mode 100644 index 0000000000..303897097a --- /dev/null +++ b/apps/web/src/components/ai-elements/live-voice.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { cn } from '@/lib/utils'; + +import { AudioLines, BasicTooltip, X } from '@/components/system'; + +import type { LiveVoiceStatus } from '@/hooks/useLiveVoice'; +import { PromptInputButton } from './prompt-input'; +import { Shimmer } from './shimmer'; + +interface LiveVoiceButtonProps { + /** Whether a voice conversation is running. */ + active: boolean; + /** Toggle the conversation on/off. */ + onClick: () => void; + disabled?: boolean; +} + +/** Composer toggle for the live voice conversation. */ +export const LiveVoiceButton = ({ + active, + onClick, + disabled, +}: LiveVoiceButtonProps) => { + return ( + + + + + + ); +}; + +const STATUS_LABELS: Partial> = { + connecting: 'Connecting', + listening: 'Listening', + speaking: 'Speaking', +}; + +interface LiveVoiceStatusBarProps { + status: LiveVoiceStatus; + /** In-progress transcription of the current utterance. */ + interimTranscript: string; + /** True while the agent is composing a reply. */ + thinking: boolean; + error: string | null; + onStop: () => void; +} + +/** Conversation state strip shown above the composer while voice is active. */ +export const LiveVoiceStatusBar = ({ + status, + interimTranscript, + thinking, + error, + onStop, +}: LiveVoiceStatusBarProps) => { + const label = thinking + ? 'Thinking' + : (STATUS_LABELS[status] ?? STATUS_LABELS.listening); + + return ( +
+ + + + +
+ {error ? ( + {error} + ) : interimTranscript ? ( + + {interimTranscript} + + ) : ( + + {label ?? 'Listening'} + + )} +
+ +
+ ); +}; diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts index f237256fdd..19757c2518 100644 --- a/apps/web/src/components/system/primitives/icons.ts +++ b/apps/web/src/components/system/primitives/icons.ts @@ -17,6 +17,7 @@ export { ArrowUpFromLine, ArrowUpRightIcon, AtSignIcon, + AudioLines, BookOpenText, BookCopy, BookMarked, diff --git a/apps/web/src/hooks/useLiveVoice.ts b/apps/web/src/hooks/useLiveVoice.ts new file mode 100644 index 0000000000..73833b9dae --- /dev/null +++ b/apps/web/src/hooks/useLiveVoice.ts @@ -0,0 +1,398 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useTRPCClient } from '@/trpc/client'; +import { chunkSpeakableText, toSpeakableText } from '@/lib/voice-speech'; + +/** + * Live voice conversation controller. Streams the microphone to OpenAI's + * realtime transcription API over WebRTC (using a short-lived token minted + * server-side), surfaces completed utterances to the caller, and plays + * synthesized replies from the deployment's TTS endpoint. Server-side VAD + * ends each utterance hands-free, and detected speech interrupts playback so + * the user can talk over a long reply. + */ + +const OPENAI_REALTIME_CALLS_URL = 'https://api.openai.com/v1/realtime/calls'; +const TTS_SAMPLE_RATE = 24_000; +/** Feed the player in ~250ms batches so playback starts almost immediately. */ +const MIN_PLAYBACK_SAMPLES = TTS_SAMPLE_RATE / 4; + +export type LiveVoiceStatus = + | 'idle' + | 'connecting' + | 'listening' + | 'speaking' + | 'error'; + +interface UseLiveVoiceOptions { + /** Called with each completed utterance, ready to send to the agent. */ + onUtterance: (text: string) => void; + /** Blocks starting a conversation (e.g. while the composer is busy). */ + disabled?: boolean; +} + +interface UseLiveVoiceReturn { + /** Whether a voice conversation is running. */ + active: boolean; + status: LiveVoiceStatus; + /** In-progress transcription of the current utterance. */ + interimTranscript: string; + error: string | null; + start: () => Promise; + stop: () => void; + /** Speak an agent reply (raw markdown; it is cleaned before synthesis). */ + speak: (markdown: string) => void; + stopSpeaking: () => void; +} + +type RealtimeServerEvent = { + type?: string; + delta?: string; + transcript?: string; + error?: { message?: string }; +}; + +export function useLiveVoice({ + onUtterance, + disabled = false, +}: UseLiveVoiceOptions): UseLiveVoiceReturn { + const trpcClient = useTRPCClient(); + const [active, setActive] = useState(false); + const [status, setStatus] = useState('idle'); + const [interimTranscript, setInterimTranscript] = useState(''); + const [error, setError] = useState(null); + + const peerRef = useRef(null); + const dataChannelRef = useRef(null); + const micStreamRef = useRef(null); + const audioContextRef = useRef(null); + const activeRef = useRef(false); + const onUtteranceRef = useRef(onUtterance); + onUtteranceRef.current = onUtterance; + + // Playback state. The generation counter invalidates in-flight synthesis + // whenever playback is interrupted, so a stale fetch can't resume talking. + const playbackGenerationRef = useRef(0); + const playbackAbortRef = useRef(null); + const scheduledSourcesRef = useRef>(new Set()); + const nextPlaybackTimeRef = useRef(0); + const speakingRef = useRef(false); + + const setSpeaking = useCallback((speaking: boolean) => { + speakingRef.current = speaking; + setStatus((current) => { + if (!activeRef.current) return current; + return speaking ? 'speaking' : 'listening'; + }); + }, []); + + const stopSpeaking = useCallback(() => { + playbackGenerationRef.current += 1; + playbackAbortRef.current?.abort(); + playbackAbortRef.current = null; + + for (const source of scheduledSourcesRef.current) { + try { + source.stop(); + } catch { + // Already finished. + } + } + scheduledSourcesRef.current.clear(); + nextPlaybackTimeRef.current = 0; + + if (speakingRef.current) { + setSpeaking(false); + } + }, [setSpeaking]); + + const stop = useCallback(() => { + activeRef.current = false; + stopSpeaking(); + dataChannelRef.current?.close(); + dataChannelRef.current = null; + peerRef.current?.close(); + peerRef.current = null; + micStreamRef.current?.getTracks().forEach((track) => track.stop()); + micStreamRef.current = null; + void audioContextRef.current?.close().catch(() => undefined); + audioContextRef.current = null; + setActive(false); + setStatus('idle'); + setInterimTranscript(''); + }, [stopSpeaking]); + + const handleServerEvent = useCallback( + (raw: string) => { + let event: RealtimeServerEvent; + + try { + event = JSON.parse(raw) as RealtimeServerEvent; + } catch { + return; + } + + switch (event.type) { + case 'input_audio_buffer.speech_started': + // Barge-in: the user talking over a reply silences it. + stopSpeaking(); + break; + case 'conversation.item.input_audio_transcription.delta': + if (event.delta) { + setInterimTranscript((current) => current + event.delta); + } + break; + case 'conversation.item.input_audio_transcription.completed': { + setInterimTranscript(''); + const transcript = event.transcript?.trim(); + if (transcript) { + onUtteranceRef.current(transcript); + } + break; + } + case 'error': + setError(event.error?.message ?? 'Voice transcription error'); + break; + default: + break; + } + }, + [stopSpeaking], + ); + + const start = useCallback(async () => { + if (activeRef.current || disabled) { + return; + } + + setError(null); + setStatus('connecting'); + + try { + const token = await trpcClient.voice.createRealtimeToken.mutate(); + const micStream = await navigator.mediaDevices.getUserMedia({ + audio: { + // Echo cancellation keeps the agent's own spoken reply (played + // through the speakers) from triggering barge-in. + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + const peer = new RTCPeerConnection(); + const [audioTrack] = micStream.getAudioTracks(); + + if (!audioTrack) { + throw new Error('No microphone available'); + } + + peer.addTrack(audioTrack, micStream); + + const dataChannel = peer.createDataChannel('oai-events'); + dataChannel.onmessage = (event) => handleServerEvent(String(event.data)); + + const offer = await peer.createOffer(); + await peer.setLocalDescription(offer); + + const response = await fetch(OPENAI_REALTIME_CALLS_URL, { + method: 'POST', + headers: { + authorization: `Bearer ${token.value}`, + 'content-type': 'application/sdp', + }, + body: offer.sdp, + }); + + if (!response.ok) { + throw new Error('Voice session handshake failed'); + } + + await peer.setRemoteDescription({ + type: 'answer', + sdp: await response.text(), + }); + + peerRef.current = peer; + dataChannelRef.current = dataChannel; + micStreamRef.current = micStream; + activeRef.current = true; + setActive(true); + setStatus('listening'); + } catch (caught) { + stop(); + setStatus('error'); + setError( + caught instanceof Error && caught.name === 'NotAllowedError' + ? 'Microphone access was denied' + : 'Could not start the voice conversation', + ); + } + }, [disabled, handleServerEvent, stop, trpcClient]); + + const schedulePcm = useCallback( + (context: AudioContext, samples: Float32Array) => { + const buffer = context.createBuffer(1, samples.length, TTS_SAMPLE_RATE); + buffer.copyToChannel(samples, 0); + + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect(context.destination); + + const startAt = Math.max( + context.currentTime, + nextPlaybackTimeRef.current, + ); + nextPlaybackTimeRef.current = startAt + buffer.duration; + scheduledSourcesRef.current.add(source); + source.onended = () => { + scheduledSourcesRef.current.delete(source); + if ( + scheduledSourcesRef.current.size === 0 && + !playbackAbortRef.current + ) { + setSpeaking(false); + } + }; + source.start(startAt); + }, + [setSpeaking], + ); + + const speak = useCallback( + (markdown: string) => { + if (!activeRef.current) { + return; + } + + const chunks = chunkSpeakableText(toSpeakableText(markdown)); + + if (chunks.length === 0) { + return; + } + + stopSpeaking(); + + const generation = playbackGenerationRef.current; + const abortController = new AbortController(); + playbackAbortRef.current = abortController; + + const context = + audioContextRef.current ?? + new AudioContext({ sampleRate: TTS_SAMPLE_RATE }); + audioContextRef.current = context; + void context.resume().catch(() => undefined); + nextPlaybackTimeRef.current = context.currentTime; + setSpeaking(true); + + void (async () => { + try { + for (const chunk of chunks) { + if (playbackGenerationRef.current !== generation) { + return; + } + + const response = await fetch('/api/voice/tts', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: chunk }), + signal: abortController.signal, + }); + + if (!response.ok || !response.body) { + throw new Error('Speech synthesis failed'); + } + + const reader = response.body.getReader(); + // 16-bit samples can split across network chunks; carry the odd + // byte over, and batch small reads so sources aren't tiny. + let carry = new Uint8Array(0); + let pending: Float32Array[] = []; + let pendingSamples = 0; + + const flush = () => { + if (pendingSamples === 0) return; + const merged = new Float32Array(pendingSamples); + let offset = 0; + for (const part of pending) { + merged.set(part, offset); + offset += part.length; + } + pending = []; + pendingSamples = 0; + schedulePcm(context, merged); + }; + + while (true) { + const { done, value } = await reader.read(); + + if (playbackGenerationRef.current !== generation) { + await reader.cancel().catch(() => undefined); + return; + } + + if (done) { + break; + } + + const bytes = new Uint8Array(carry.length + value.length); + bytes.set(carry, 0); + bytes.set(value, carry.length); + const usable = bytes.length - (bytes.length % 2); + carry = bytes.slice(usable); + + if (usable === 0) { + continue; + } + + const ints = new Int16Array(bytes.buffer.slice(0, usable)); + const floats = new Float32Array(ints.length); + for (let i = 0; i < ints.length; i++) { + floats[i] = (ints[i] ?? 0) / 32_768; + } + pending.push(floats); + pendingSamples += floats.length; + + if (pendingSamples >= MIN_PLAYBACK_SAMPLES) { + flush(); + } + } + + flush(); + } + } catch { + // Aborted playback or a failed synthesis: fall back to silence. + } finally { + if (playbackGenerationRef.current === generation) { + playbackAbortRef.current = null; + if (scheduledSourcesRef.current.size === 0) { + setSpeaking(false); + } + } + } + })(); + }, + [schedulePcm, setSpeaking, stopSpeaking], + ); + + // `stop` is stable (its dependency chain bottoms out in setState), so this + // runs only on unmount. + useEffect(() => { + return () => { + stop(); + }; + }, [stop]); + + return { + active, + status, + interimTranscript, + error, + start, + stop, + speak, + stopSpeaking, + }; +} diff --git a/apps/web/src/lib/server/voice.ts b/apps/web/src/lib/server/voice.ts new file mode 100644 index 0000000000..83c788a6bd --- /dev/null +++ b/apps/web/src/lib/server/voice.ts @@ -0,0 +1,149 @@ +import { resolveModelProviderEnvValue } from '@roomote/db/server'; + +/** + * Live voice conversation support: server-side OpenAI access for realtime + * transcription tokens and spoken replies. The API key stays on the control + * plane — the browser receives only short-lived ephemeral realtime tokens + * (for microphone transcription over WebRTC) and synthesized audio streams. + * + * Unset credentials mean the feature is off: status reports disabled, the + * token mint refuses, and the TTS endpoint 404s. + */ + +/** + * A dedicated voice key wins over the deployment's general model-provider + * key, so an operator can bill voice separately from task inference without + * the two settings fighting (mirrors the Brain's key precedence). + */ +const VOICE_OPENAI_ENV_VAR_NAMES = [ + 'R_VOICE_OPENAI_API_KEY', + 'OPENAI_API_KEY', +] as const; + +const OPENAI_API_BASE_URL = 'https://api.openai.com'; + +/** Realtime transcription model used for live microphone speech-to-text. */ +const VOICE_TRANSCRIPTION_MODEL = 'gpt-live-transcribe'; + +const VOICE_TTS_MODEL = 'gpt-4o-mini-tts'; +const VOICE_TTS_VOICE = 'marin'; +/** OpenAI's TTS input cap; clients chunk longer replies. */ +export const VOICE_TTS_MAX_INPUT_CHARS = 4_096; +/** PCM output: 24kHz, 16-bit signed little-endian, mono. */ +export const VOICE_TTS_SAMPLE_RATE = 24_000; + +const CLIENT_SECRET_TTL_SECONDS = 600; +const CLIENT_SECRET_TIMEOUT_MS = 15_000; +const VOICE_TTS_TIMEOUT_MS = 60_000; + +export async function resolveVoiceOpenAiKey(): Promise { + const apiKey = await resolveModelProviderEnvValue(VOICE_OPENAI_ENV_VAR_NAMES); + return apiKey?.trim() || undefined; +} + +export type VoiceRealtimeClientSecret = { + /** Ephemeral token the browser presents to OpenAI's realtime endpoint. */ + value: string; + /** Unix seconds when the token stops working. */ + expiresAt: number; +}; + +/** + * Mint an ephemeral realtime client secret scoped to a transcription-only + * session: the browser streams microphone audio to OpenAI over WebRTC and + * receives transcript events, but no model responses. Server-side VAD turns + * each pause into a completed transcript, which the client forwards to the + * fast agent as an ordinary session reply. + */ +export async function createVoiceRealtimeClientSecret( + apiKey: string, +): Promise { + const response = await fetch( + `${OPENAI_API_BASE_URL}/v1/realtime/client_secrets`, + { + method: 'POST', + headers: { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + expires_after: { + anchor: 'created_at', + seconds: CLIENT_SECRET_TTL_SECONDS, + }, + session: { + type: 'transcription', + audio: { + input: { + noise_reduction: { type: 'near_field' }, + transcription: { model: VOICE_TRANSCRIPTION_MODEL }, + // Server VAD keeps the conversation hands-free: OpenAI detects + // the end of an utterance and emits the completed transcript + // without the user pressing anything. The silence window leans + // long so mid-sentence pauses don't split a request in two. + turn_detection: { + type: 'server_vad', + silence_duration_ms: 800, + }, + }, + }, + }, + }), + signal: AbortSignal.timeout(CLIENT_SECRET_TIMEOUT_MS), + }, + ); + + if (!response.ok) { + throw new Error( + `OpenAI realtime client secret request failed with status ${response.status}`, + ); + } + + const payload = (await response.json()) as { + value?: string; + expires_at?: number; + }; + + if (!payload.value || typeof payload.expires_at !== 'number') { + throw new Error( + 'OpenAI realtime client secret response did not include a token', + ); + } + + return { value: payload.value, expiresAt: payload.expires_at }; +} + +/** + * Stream synthesized speech for one reply chunk. Returns the upstream PCM + * byte stream so the route handler can pass it straight through to the + * browser as it arrives. + */ +export async function createVoiceSpeechStream(options: { + apiKey: string; + text: string; +}): Promise> { + const response = await fetch(`${OPENAI_API_BASE_URL}/v1/audio/speech`, { + method: 'POST', + headers: { + authorization: `Bearer ${options.apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: VOICE_TTS_MODEL, + voice: VOICE_TTS_VOICE, + input: options.text, + // PCM streams with the lowest latency (no container to buffer) and + // feeds the browser's audio pipeline directly. + response_format: 'pcm', + }), + signal: AbortSignal.timeout(VOICE_TTS_TIMEOUT_MS), + }); + + if (!response.ok || !response.body) { + throw new Error( + `OpenAI speech synthesis request failed with status ${response.status}`, + ); + } + + return response.body; +} diff --git a/apps/web/src/lib/voice-speech.test.ts b/apps/web/src/lib/voice-speech.test.ts new file mode 100644 index 0000000000..a577368b29 --- /dev/null +++ b/apps/web/src/lib/voice-speech.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { chunkSpeakableText, toSpeakableText } from './voice-speech'; + +describe('toSpeakableText', () => { + it('summarizes fenced code blocks instead of reading them', () => { + const result = toSpeakableText( + 'Here is the fix:\n```ts\nconst x = 1;\n```\nDeployed.', + ); + + expect(result).toContain('Code block omitted.'); + expect(result).not.toContain('const x = 1'); + }); + + it('keeps link labels and drops URLs', () => { + expect(toSpeakableText('See [the docs](https://example.com/a?b=c).')).toBe( + 'See the docs.', + ); + expect(toSpeakableText('Raw: https://example.com/long/path')).toBe( + 'Raw: a link', + ); + }); + + it('strips markdown structure markers', () => { + const result = toSpeakableText( + '# Title\n\n- **bold** item\n1. `inline` step\n> quoted', + ); + + expect(result).toBe('Title\nbold item\ninline step\nquoted'); + }); + + it('drops image syntax without leaving punctuation behind', () => { + expect(toSpeakableText('Before ![diagram](https://x/y.png) after')).toBe( + 'Before diagram after', + ); + }); +}); + +describe('chunkSpeakableText', () => { + it('returns short text as a single chunk', () => { + expect(chunkSpeakableText('Hello there.', 100)).toEqual(['Hello there.']); + }); + + it('returns nothing for blank input', () => { + expect(chunkSpeakableText(' ', 100)).toEqual([]); + }); + + it('splits on sentence boundaries under the cap', () => { + const chunks = chunkSpeakableText( + 'First sentence here. Second sentence follows. Third one ends it.', + 30, + ); + + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(30); + } + expect(chunks.join(' ')).toBe( + 'First sentence here. Second sentence follows. Third one ends it.', + ); + }); + + it('hard-splits a single unbreakable run at the cap', () => { + const chunks = chunkSpeakableText('a'.repeat(25), 10); + + expect(chunks).toEqual(['a'.repeat(10), 'a'.repeat(10), 'a'.repeat(5)]); + }); +}); diff --git a/apps/web/src/lib/voice-speech.ts b/apps/web/src/lib/voice-speech.ts new file mode 100644 index 0000000000..6ec406e58a --- /dev/null +++ b/apps/web/src/lib/voice-speech.ts @@ -0,0 +1,109 @@ +/** + * Text preparation for spoken replies in the live voice conversation + * feature. Agent replies are markdown written for reading; text-to-speech + * reads them literally, so structural syntax is stripped or summarized + * before synthesis. + */ + +/** Mirrors the server-side OpenAI TTS input cap (`VOICE_TTS_MAX_INPUT_CHARS`). */ +const VOICE_SPEECH_CHUNK_CHARS = 4_000; + +/** + * Convert an agent's markdown reply into text worth speaking aloud. Code + * blocks are summarized rather than read character-by-character, links keep + * their label but drop the URL, and formatting markers disappear. + */ +export function toSpeakableText(markdown: string): string { + let text = markdown; + + // Fenced code blocks: reading code aloud is noise; acknowledge and move on. + text = text.replace(/```[\s\S]*?(?:```|$)/g, ' Code block omitted. '); + + // Images before links, so ![alt](url) doesn't leave a stray "!". + text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1'); + text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1'); + + // Bare URLs read as gibberish. + text = text.replace(/https?:\/\/\S+/g, 'a link'); + + // Inline code keeps its content, minus the backticks. + text = text.replace(/`([^`]+)`/g, '$1'); + + // Table rows: drop separator lines, read cells as phrases. + text = text.replace(/^\s*\|?[-:| ]+\|[-:| ]*$/gm, ''); + text = text.replace(/\s*\|\s*/g, ', '); + + // Headings, blockquotes, list markers, emphasis, strikethrough. + text = text.replace(/^#{1,6}\s+/gm, ''); + text = text.replace(/^\s*>\s?/gm, ''); + text = text.replace(/^\s*[-*+]\s+/gm, ''); + text = text.replace(/^\s*\d+\.\s+/gm, ''); + text = text.replace(/(\*\*|__|~~)/g, ''); + + // Collapse the leftover whitespace so pauses stay natural. + text = text.replace(/[ \t]+/g, ' '); + text = text.replace(/\n{2,}/g, '\n'); + + return text.trim(); +} + +/** + * Split a long reply into synthesis-sized chunks, preferring paragraph and + * sentence boundaries, so each request stays under the TTS input cap and + * playback can begin before the whole reply is synthesized. + */ +export function chunkSpeakableText( + text: string, + maxChars = VOICE_SPEECH_CHUNK_CHARS, +): string[] { + const trimmed = text.trim(); + + if (!trimmed) { + return []; + } + + const chunks: string[] = []; + let remaining = trimmed; + + while (remaining.length > maxChars) { + const window = remaining.slice(0, maxChars); + const newlineBreak = window.lastIndexOf('\n'); + const sentenceBreak = findLastSentenceEnd(window); + const spaceBreak = window.lastIndexOf(' '); + const breakAt = + newlineBreak > 0 + ? newlineBreak + : sentenceBreak > 0 + ? sentenceBreak + : spaceBreak; + const splitAt = breakAt > 0 ? breakAt + 1 : maxChars; + const chunk = remaining.slice(0, splitAt).trim(); + + if (chunk) { + chunks.push(chunk); + } + + remaining = remaining.slice(splitAt).trim(); + } + + if (remaining) { + chunks.push(remaining); + } + + return chunks; +} + +function findLastSentenceEnd(window: string): number { + for (let i = window.length - 2; i >= 0; i--) { + const char = window[i]; + + if ( + (char === '.' || char === '!' || char === '?') && + window[i + 1] === ' ' + ) { + return i + 1; + } + } + + return -1; +} diff --git a/apps/web/src/trpc/commands/voice/index.test.ts b/apps/web/src/trpc/commands/voice/index.test.ts new file mode 100644 index 0000000000..93185f541f --- /dev/null +++ b/apps/web/src/trpc/commands/voice/index.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRPCError } from '@trpc/server'; + +const { mockResolveVoiceOpenAiKey, mockCreateVoiceRealtimeClientSecret } = + vi.hoisted(() => ({ + mockResolveVoiceOpenAiKey: vi.fn(), + mockCreateVoiceRealtimeClientSecret: vi.fn(), + })); + +vi.mock('@/lib/server/voice', () => ({ + resolveVoiceOpenAiKey: mockResolveVoiceOpenAiKey, + createVoiceRealtimeClientSecret: mockCreateVoiceRealtimeClientSecret, +})); + +import { createVoiceRealtimeTokenCommand, getVoiceStatusCommand } from '.'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getVoiceStatusCommand', () => { + it('reports enabled when a key resolves', async () => { + mockResolveVoiceOpenAiKey.mockResolvedValue('sk-test'); + + await expect(getVoiceStatusCommand()).resolves.toEqual({ enabled: true }); + }); + + it('reports disabled when no key is configured', async () => { + mockResolveVoiceOpenAiKey.mockResolvedValue(undefined); + + await expect(getVoiceStatusCommand()).resolves.toEqual({ enabled: false }); + }); +}); + +describe('createVoiceRealtimeTokenCommand', () => { + it('mints a token with the resolved key', async () => { + mockResolveVoiceOpenAiKey.mockResolvedValue('sk-test'); + mockCreateVoiceRealtimeClientSecret.mockResolvedValue({ + value: 'ek_abc', + expiresAt: 1_700_000_000, + }); + + await expect(createVoiceRealtimeTokenCommand()).resolves.toEqual({ + value: 'ek_abc', + expiresAt: 1_700_000_000, + }); + expect(mockCreateVoiceRealtimeClientSecret).toHaveBeenCalledWith('sk-test'); + }); + + it('refuses when voice is not configured', async () => { + mockResolveVoiceOpenAiKey.mockResolvedValue(undefined); + + await expect(createVoiceRealtimeTokenCommand()).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + expect(mockCreateVoiceRealtimeClientSecret).not.toHaveBeenCalled(); + }); + + it('maps upstream failures to BAD_GATEWAY without leaking details', async () => { + mockResolveVoiceOpenAiKey.mockResolvedValue('sk-test'); + mockCreateVoiceRealtimeClientSecret.mockRejectedValue( + new Error('status 500'), + ); + + const error = await createVoiceRealtimeTokenCommand().catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe('BAD_GATEWAY'); + expect((error as TRPCError).message).toBe( + 'Failed to start a voice session', + ); + }); +}); diff --git a/apps/web/src/trpc/commands/voice/index.ts b/apps/web/src/trpc/commands/voice/index.ts new file mode 100644 index 0000000000..8dc1b602a2 --- /dev/null +++ b/apps/web/src/trpc/commands/voice/index.ts @@ -0,0 +1,43 @@ +import { TRPCError } from '@trpc/server'; + +import { + createVoiceRealtimeClientSecret, + resolveVoiceOpenAiKey, + type VoiceRealtimeClientSecret, +} from '@/lib/server/voice'; + +/** + * Whether live voice conversation is available on this deployment. Voice + * rides an OpenAI key (`R_VOICE_OPENAI_API_KEY`, falling back to the general + * `OPENAI_API_KEY`); without one the UI hides the feature entirely. + */ +export async function getVoiceStatusCommand(): Promise<{ enabled: boolean }> { + return { enabled: Boolean(await resolveVoiceOpenAiKey()) }; +} + +/** + * Mint a short-lived ephemeral token the browser uses to open a WebRTC + * transcription session directly with OpenAI. The deployment's API key never + * leaves the server; the token is scoped to transcription only and expires + * on its own. + */ +export async function createVoiceRealtimeTokenCommand(): Promise { + const apiKey = await resolveVoiceOpenAiKey(); + + if (!apiKey) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Voice is not configured for this deployment', + }); + } + + try { + return await createVoiceRealtimeClientSecret(apiKey); + } catch (error) { + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: 'Failed to start a voice session', + cause: error, + }); + } +} diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 27a5d09454..140144dbcf 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -46,6 +46,10 @@ import { startFastSessionInputSchema, updateFastSessionModelSelectionInputSchema, } from '../commands/fast-sessions/input'; +import { + createVoiceRealtimeTokenCommand, + getVoiceStatusCommand, +} from '../commands/voice'; import { getSessionByIdCommand, getSessionForTask, @@ -2960,6 +2964,13 @@ export const appRouter = createRouter({ ), }), + voice: createRouter({ + status: protectedProcedure.query(() => getVoiceStatusCommand()), + createRealtimeToken: protectedProcedure.mutation(() => + createVoiceRealtimeTokenCommand(), + ), + }), + sessions: createRouter({ list: protectedProcedure .input(sessionsListInputSchema) diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 20f931fb90..d115692542 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -147,6 +147,12 @@ const serverSchema = { // feature is off and the endpoint 404s. R_ELEVENLABS_API_KEY: z.string().min(1).optional(), R_ELEVENLABS_VOICE_ID: z.string().min(1).optional(), + // OpenAI key for the live voice conversation feature (realtime + // transcription + spoken replies in the web app). Falls back to the + // deployment's general OPENAI_API_KEY when unset. The key stays on the + // control plane: the browser only ever receives short-lived ephemeral + // realtime tokens and synthesized audio, never the key itself. + R_VOICE_OPENAI_API_KEY: z.string().min(1).optional(), R_INTERCOM_APP_ID: z.string().min(1).optional(), R_POSTHOG_PROJECT_KEY: z.string().min(1).optional(), R_POSTHOG_HOST: z.string().url().optional(), @@ -573,6 +579,7 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'R_CUSTOM_MCP_ALLOWED_PRIVATE_CIDRS', 'R_ELEVENLABS_API_KEY', 'R_ELEVENLABS_VOICE_ID', + 'R_VOICE_OPENAI_API_KEY', 'R_INTERCOM_APP_ID', 'R_POSTHOG_PROJECT_KEY', 'R_POSTHOG_HOST', diff --git a/packages/types/src/control-plane-env-vars.test.ts b/packages/types/src/control-plane-env-vars.test.ts index 40caaf0e3f..e3a83e6b5a 100644 --- a/packages/types/src/control-plane-env-vars.test.ts +++ b/packages/types/src/control-plane-env-vars.test.ts @@ -30,6 +30,7 @@ describe('CONTROL_PLANE_ENV_VAR_NAMES', () => { 'R_TRIAL_OPENROUTER_API_KEY', 'R_ELEVENLABS_API_KEY', 'R_ELEVENLABS_VOICE_ID', + 'R_VOICE_OPENAI_API_KEY', ]) { expect(CONTROL_PLANE_ENV_VAR_NAMES.has(name)).toBe(true); } diff --git a/packages/types/src/control-plane-env-vars.ts b/packages/types/src/control-plane-env-vars.ts index 68265e601f..41d3c76fae 100644 --- a/packages/types/src/control-plane-env-vars.ts +++ b/packages/types/src/control-plane-env-vars.ts @@ -111,6 +111,7 @@ export const INSTANCE_SECRET_ENV_VAR_NAMES: ReadonlySet = new Set([ export const MEDIA_PROVIDER_ENV_VAR_NAMES: ReadonlySet = new Set([ 'R_ELEVENLABS_API_KEY', 'R_ELEVENLABS_VOICE_ID', + 'R_VOICE_OPENAI_API_KEY', ]); /** From e7e280f63faa073e758e7f7693874607d1acdabc Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:06:03 -0400 Subject: [PATCH 02/18] feat: continuous transcription with client-side turn detection gpt-live-transcribe streams word-by-word deltas but rejects server-side turn_detection, so the browser now runs a lightweight energy-based VAD: a pause commits the audio buffer to finalize the utterance, and detected speech interrupts reply playback (with a higher threshold while audio is playing so speaker echo does not cut the agent off). --- apps/web/src/hooks/useLiveVoice.ts | 140 ++++++++++++++++++++++++++--- apps/web/src/lib/server/voice.ts | 26 +++--- 2 files changed, 143 insertions(+), 23 deletions(-) diff --git a/apps/web/src/hooks/useLiveVoice.ts b/apps/web/src/hooks/useLiveVoice.ts index 73833b9dae..4cf83f43d2 100644 --- a/apps/web/src/hooks/useLiveVoice.ts +++ b/apps/web/src/hooks/useLiveVoice.ts @@ -9,9 +9,11 @@ import { chunkSpeakableText, toSpeakableText } from '@/lib/voice-speech'; * Live voice conversation controller. Streams the microphone to OpenAI's * realtime transcription API over WebRTC (using a short-lived token minted * server-side), surfaces completed utterances to the caller, and plays - * synthesized replies from the deployment's TTS endpoint. Server-side VAD - * ends each utterance hands-free, and detected speech interrupts playback so - * the user can talk over a long reply. + * synthesized replies from the deployment's TTS endpoint. The transcription + * model (`gpt-live-transcribe`) streams word-by-word deltas continuously and + * has no server-side turn detection, so a local energy-based VAD watches the + * microphone: a pause commits the audio buffer (finalizing the utterance) + * and detected speech interrupts playback so the user can talk over a reply. */ const OPENAI_REALTIME_CALLS_URL = 'https://api.openai.com/v1/realtime/calls'; @@ -19,6 +21,14 @@ const TTS_SAMPLE_RATE = 24_000; /** Feed the player in ~250ms batches so playback starts almost immediately. */ const MIN_PLAYBACK_SAMPLES = TTS_SAMPLE_RATE / 4; +const VAD_INTERVAL_MS = 50; +/** RMS above this counts as the user speaking. */ +const VAD_SPEECH_RMS = 0.02; +/** A pause this long ends the utterance and commits it. */ +const VAD_SILENCE_MS = 800; +/** Shorter bursts (a cough, a keyboard clack) are not worth committing. */ +const VAD_MIN_SPEECH_MS = 250; + export type LiveVoiceStatus = | 'idle' | 'connecting' @@ -108,8 +118,119 @@ export function useLiveVoice({ } }, [setSpeaking]); + // Local VAD state: an analyser taps the mic stream and a timer classifies + // each 50ms window as speech or silence. + const vadTimerRef = useRef(null); + const vadAnalyserRef = useRef(null); + const vadSourceRef = useRef(null); + const vadSamplesRef = useRef | null>(null); + const vadStateRef = useRef({ + speaking: false, + speechStartAt: 0, + lastVoiceAt: 0, + hadSpeech: false, + }); + + const ensureAudioContext = useCallback(() => { + const context = + audioContextRef.current ?? + new AudioContext({ sampleRate: TTS_SAMPLE_RATE }); + audioContextRef.current = context; + void context.resume().catch(() => undefined); + return context; + }, []); + + /** Finalize the buffered utterance; the completed transcript follows as a + * server event. */ + const commitUtterance = useCallback(() => { + const channel = dataChannelRef.current; + + if (channel?.readyState === 'open') { + channel.send(JSON.stringify({ type: 'input_audio_buffer.commit' })); + } + }, []); + + const stopVad = useCallback(() => { + if (vadTimerRef.current !== null) { + window.clearInterval(vadTimerRef.current); + vadTimerRef.current = null; + } + vadSourceRef.current?.disconnect(); + vadSourceRef.current = null; + vadAnalyserRef.current = null; + vadSamplesRef.current = null; + }, []); + + const startVad = useCallback( + (micStream: MediaStream) => { + const context = ensureAudioContext(); + const source = context.createMediaStreamSource(micStream); + const analyser = context.createAnalyser(); + analyser.fftSize = 1024; + source.connect(analyser); + vadSourceRef.current = source; + vadAnalyserRef.current = analyser; + vadSamplesRef.current = new Float32Array(analyser.fftSize); + vadStateRef.current = { + speaking: false, + speechStartAt: 0, + lastVoiceAt: 0, + hadSpeech: false, + }; + + vadTimerRef.current = window.setInterval(() => { + const currentAnalyser = vadAnalyserRef.current; + const samples = vadSamplesRef.current; + + if (!currentAnalyser || !samples) { + return; + } + + currentAnalyser.getFloatTimeDomainData(samples); + let sum = 0; + for (let i = 0; i < samples.length; i++) { + const value = samples[i] ?? 0; + sum += value * value; + } + const rms = Math.sqrt(sum / samples.length); + // Residual echo of the agent's own reply must not read as the user + // interrupting, so the bar is higher while a reply is playing. + const threshold = speakingRef.current + ? VAD_SPEECH_RMS * 2 + : VAD_SPEECH_RMS; + const now = Date.now(); + const state = vadStateRef.current; + + if (rms >= threshold) { + if (!state.speaking) { + state.speaking = true; + state.speechStartAt = now; + // Barge-in: the user talking over a reply silences it. + stopSpeaking(); + } + state.lastVoiceAt = now; + state.hadSpeech = true; + return; + } + + if (state.speaking && now - state.lastVoiceAt >= VAD_SILENCE_MS) { + state.speaking = false; + const spokeLongEnough = + state.lastVoiceAt - state.speechStartAt >= VAD_MIN_SPEECH_MS; + + if (state.hadSpeech && spokeLongEnough) { + commitUtterance(); + } + state.hadSpeech = false; + } + }, VAD_INTERVAL_MS); + }, + [commitUtterance, ensureAudioContext, stopSpeaking], + ); + const stop = useCallback(() => { activeRef.current = false; + stopVad(); stopSpeaking(); dataChannelRef.current?.close(); dataChannelRef.current = null; @@ -122,7 +243,7 @@ export function useLiveVoice({ setActive(false); setStatus('idle'); setInterimTranscript(''); - }, [stopSpeaking]); + }, [stopSpeaking, stopVad]); const handleServerEvent = useCallback( (raw: string) => { @@ -219,6 +340,7 @@ export function useLiveVoice({ dataChannelRef.current = dataChannel; micStreamRef.current = micStream; activeRef.current = true; + startVad(micStream); setActive(true); setStatus('listening'); } catch (caught) { @@ -230,7 +352,7 @@ export function useLiveVoice({ : 'Could not start the voice conversation', ); } - }, [disabled, handleServerEvent, stop, trpcClient]); + }, [disabled, handleServerEvent, startVad, stop, trpcClient]); const schedulePcm = useCallback( (context: AudioContext, samples: Float32Array) => { @@ -279,11 +401,7 @@ export function useLiveVoice({ const abortController = new AbortController(); playbackAbortRef.current = abortController; - const context = - audioContextRef.current ?? - new AudioContext({ sampleRate: TTS_SAMPLE_RATE }); - audioContextRef.current = context; - void context.resume().catch(() => undefined); + const context = ensureAudioContext(); nextPlaybackTimeRef.current = context.currentTime; setSpeaking(true); @@ -374,7 +492,7 @@ export function useLiveVoice({ } })(); }, - [schedulePcm, setSpeaking, stopSpeaking], + [ensureAudioContext, schedulePcm, setSpeaking, stopSpeaking], ); // `stop` is stable (its dependency chain bottoms out in setState), so this diff --git a/apps/web/src/lib/server/voice.ts b/apps/web/src/lib/server/voice.ts index 83c788a6bd..865f758db1 100644 --- a/apps/web/src/lib/server/voice.ts +++ b/apps/web/src/lib/server/voice.ts @@ -22,7 +22,13 @@ const VOICE_OPENAI_ENV_VAR_NAMES = [ const OPENAI_API_BASE_URL = 'https://api.openai.com'; -/** Realtime transcription model used for live microphone speech-to-text. */ +/** + * Realtime transcription model used for live microphone speech-to-text. + * `gpt-live-transcribe` streams word-by-word deltas while the user is still + * talking, but the API rejects `turn_detection` for it, so utterance + * boundaries come from the browser: client-side VAD watches the microphone + * and commits the audio buffer at each pause (see `useLiveVoice`). + */ const VOICE_TRANSCRIPTION_MODEL = 'gpt-live-transcribe'; const VOICE_TTS_MODEL = 'gpt-4o-mini-tts'; @@ -51,9 +57,9 @@ export type VoiceRealtimeClientSecret = { /** * Mint an ephemeral realtime client secret scoped to a transcription-only * session: the browser streams microphone audio to OpenAI over WebRTC and - * receives transcript events, but no model responses. Server-side VAD turns - * each pause into a completed transcript, which the client forwards to the - * fast agent as an ordinary session reply. + * receives transcript events, but no model responses. The browser's own VAD + * commits the buffer at each pause, which finalizes the utterance that gets + * forwarded to the fast agent as an ordinary session reply. */ export async function createVoiceRealtimeClientSecret( apiKey: string, @@ -77,14 +83,10 @@ export async function createVoiceRealtimeClientSecret( input: { noise_reduction: { type: 'near_field' }, transcription: { model: VOICE_TRANSCRIPTION_MODEL }, - // Server VAD keeps the conversation hands-free: OpenAI detects - // the end of an utterance and emits the completed transcript - // without the user pressing anything. The silence window leans - // long so mid-sentence pauses don't split a request in two. - turn_detection: { - type: 'server_vad', - silence_duration_ms: 800, - }, + // gpt-live-transcribe does not support server-side turn + // detection; the client commits turns manually from its own + // VAD, keeping the word-by-word delta stream. + turn_detection: null, }, }, }, From 5756284d90bce52f9eee411d50be349d9a8355c6 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:37:05 -0400 Subject: [PATCH 03/18] fix: speak replies on turn settle, guard voice start races, cover voice in transcript tests - Spoken replies now wait for the composite agent-working signal (send in flight, turn responding, response pending) to clear instead of the first visible assistant message, so a Fast progress kickoff no longer swallows the real result. - useLiveVoice tracks a start generation: a stop() or repeat start() during the handshake makes the stale attempt release its mic and peer instead of activating after the user cancelled. The composer toggle also cancels a connecting handshake. - FastSessionTranscript.client.test.tsx mocks voice.status and useLiveVoice, and adds coverage for the toggle and turn-settle speech. --- .../FastSessionTranscript.client.test.tsx | 180 ++++++++++++++++++ .../[sessionId]/FastSessionTranscript.tsx | 45 ++--- apps/web/src/hooks/useLiveVoice.ts | 57 +++++- 3 files changed, 250 insertions(+), 32 deletions(-) diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index a964886c7e..201fee385f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -25,6 +25,8 @@ const { openTasksPanel, narrationState, composerSuggestionState, + voiceStatusQuery, + liveVoiceState, } = vi.hoisted(() => ({ replyMutate: vi.fn(), reviewActionMutate: vi.fn(), @@ -36,6 +38,33 @@ const { composerSuggestionState: { data: undefined as { suggestion: string; messageCount: number } | undefined, }, + voiceStatusQuery: vi.fn(), + liveVoiceState: { + active: false, + status: 'idle' as + | 'idle' + | 'connecting' + | 'listening' + | 'speaking' + | 'error', + start: vi.fn(), + stop: vi.fn(), + speak: vi.fn(), + stopSpeaking: vi.fn(), + }, +})); + +vi.mock('@/hooks/useLiveVoice', () => ({ + useLiveVoice: () => ({ + active: liveVoiceState.active, + status: liveVoiceState.status, + interimTranscript: '', + error: null, + start: liveVoiceState.start, + stop: liveVoiceState.stop, + speak: liveVoiceState.speak, + stopSpeaking: liveVoiceState.stopSpeaking, + }), })); vi.mock('@/hooks/useNarrationMode', () => ({ @@ -49,6 +78,9 @@ vi.mock('@/trpc/client', () => ({ reviewAction: { mutate: reviewActionMutate }, updateModelSelection: { mutate: updateModelSelectionMutate }, }, + voice: { + status: { query: voiceStatusQuery }, + }, }), useTRPC: () => ({ slack: { @@ -198,6 +230,14 @@ beforeEach(() => { composerSuggestionState.data = undefined; openTaskPanel.mockReset(); openTasksPanel.mockReset(); + voiceStatusQuery.mockReset(); + voiceStatusQuery.mockResolvedValue({ enabled: false }); + liveVoiceState.active = false; + liveVoiceState.status = 'idle'; + liveVoiceState.start.mockReset(); + liveVoiceState.stop.mockReset(); + liveVoiceState.speak.mockReset(); + liveVoiceState.stopSpeaking.mockReset(); vi.stubGlobal('EventSource', FakeEventSource); }); @@ -1913,4 +1953,144 @@ describe('FastSessionTranscript', () => { expect(screen.queryByText('Draft text')).not.toBeInTheDocument(); expect(screen.getByText('Earlier answer')).toBeInTheDocument(); }); + + describe('live voice', () => { + it('hides the voice toggle until the deployment reports voice enabled', async () => { + voiceStatusQuery.mockResolvedValue({ enabled: false }); + render( + , + ); + + await waitFor(() => expect(voiceStatusQuery).toHaveBeenCalled()); + expect( + screen.queryByRole('button', { name: /^voice conversation$/i }), + ).not.toBeInTheDocument(); + }); + + it('starts a conversation from the voice toggle when voice is enabled', async () => { + voiceStatusQuery.mockResolvedValue({ enabled: true }); + render( + , + ); + + const toggle = await screen.findByRole('button', { + name: /^voice conversation$/i, + }); + fireEvent.click(toggle); + expect(liveVoiceState.start).toHaveBeenCalledTimes(1); + expect(liveVoiceState.stop).not.toHaveBeenCalled(); + }); + + it('cancels a connecting handshake from the toggle', async () => { + voiceStatusQuery.mockResolvedValue({ enabled: true }); + liveVoiceState.status = 'connecting'; + render( + , + ); + + const toggle = await screen.findByRole('button', { + name: /^voice conversation$/i, + }); + fireEvent.click(toggle); + expect(liveVoiceState.stop).toHaveBeenCalledTimes(1); + expect(liveVoiceState.start).not.toHaveBeenCalled(); + }); + + it('speaks the reply only once the turn settles, not on the first visible message', async () => { + vi.spyOn(Date, 'now').mockReturnValue(10); + voiceStatusQuery.mockResolvedValue({ enabled: true }); + replyMutate.mockResolvedValue({ success: true }); + const transcript = ( + + ); + const { rerender } = render(transcript); + // The stream's initial session snapshot; only later state changes count. + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: false, + }); + }); + + // Starting the conversation marks earlier replies as already spoken. + fireEvent.click( + await screen.findByRole('button', { name: /^voice conversation$/i }), + ); + expect(liveVoiceState.start).toHaveBeenCalledTimes(1); + liveVoiceState.active = true; + liveVoiceState.status = 'listening'; + rerender(transcript); + + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: 'Follow up' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + await waitFor(() => expect(replyMutate).toHaveBeenCalledTimes(1)); + + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: true, + }); + }); + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'assistant-progress', + role: 'assistant', + text: 'Looking into it', + ts: 11, + }), + ], + }); + }); + // The progress kickoff clears the pending state but the turn is still + // running, so nothing is spoken yet. + expect(liveVoiceState.speak).not.toHaveBeenCalled(); + + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'assistant-final', + role: 'assistant', + text: 'Here is the result', + ts: 12, + }), + ], + }); + }); + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: false, + }); + }); + + expect(liveVoiceState.speak).toHaveBeenCalledTimes(1); + expect(liveVoiceState.speak).toHaveBeenCalledWith( + 'Looking into it\n\nHere is the result', + ); + }); + }); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 8080f14919..ba92c96dd1 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -751,9 +751,7 @@ export function FastSessionTranscript({ }); /** Assistant messages at or before this ts have already been spoken. */ const lastSpokenTsRef = useRef(0); - const previousPendingRef = useRef( - pendingResponseState.pendingAfter, - ); + const previousAgentWorkingRef = useRef(false); const pendingUtterancesRef = useRef([]); const [utteranceQueueVersion, setUtteranceQueueVersion] = useState(0); @@ -800,22 +798,25 @@ export function FastSessionTranscript({ }); }, [isSending, utteranceQueueVersion, sendReply]); - // Speak the agent's reply once it settles: when the pending-response state - // clears, every not-yet-spoken assistant message since the last spoken one - // is read aloud as a single reply. + // Speak the agent's reply once the turn settles. `pendingAfter` alone clears + // on the first visible assistant message, which for Fast can be a progress + // kickoff ahead of the real result, so this waits for the composite + // "agent working" signal (send in flight, turn responding, or response + // pending) to fall back to false and then reads every not-yet-spoken + // assistant message as a single reply. + const agentWorking = + isSending || + conversationResponding === true || + pendingResponseState.pendingAfter !== null; const liveVoiceActive = liveVoice.active; const speakRef = useRef(liveVoice.speak); speakRef.current = liveVoice.speak; useEffect(() => { - const wasPending = previousPendingRef.current !== null; - previousPendingRef.current = pendingResponseState.pendingAfter; - - if ( - !liveVoiceActive || - !wasPending || - pendingResponseState.pendingAfter !== null - ) { + const wasWorking = previousAgentWorkingRef.current; + previousAgentWorkingRef.current = agentWorking; + + if (!liveVoiceActive || !wasWorking || agentWorking) { return; } @@ -838,10 +839,11 @@ export function FastSessionTranscript({ ...unspoken.map((message) => message.ts), ); speakRef.current(texts.join('\n\n')); - }, [pendingResponseState.pendingAfter, liveVoiceActive, messages]); + }, [agentWorking, liveVoiceActive, messages]); const handleVoiceToggle = useCallback(() => { - if (liveVoice.active) { + // Toggling while the handshake is still connecting cancels it. + if (liveVoice.active || liveVoice.status === 'connecting') { liveVoice.stop(); return; } @@ -933,10 +935,7 @@ export function FastSessionTranscript({ @@ -948,11 +947,7 @@ export function FastSessionTranscript({ historyMessageCount={suggestionHistory.messageCount} assistantMessageCount={suggestionHistory.assistantCount} taskStateRevision={taskStateRevision} - agentWorking={ - isSending || - conversationResponding === true || - pendingResponseState.pendingAfter !== null - } + agentWorking={agentWorking} initialModel={sessionModel} initialReasoningEffort={sessionReasoningEffort} defaultModelId={defaultModelId} diff --git a/apps/web/src/hooks/useLiveVoice.ts b/apps/web/src/hooks/useLiveVoice.ts index 4cf83f43d2..ef0acc0971 100644 --- a/apps/web/src/hooks/useLiveVoice.ts +++ b/apps/web/src/hooks/useLiveVoice.ts @@ -79,6 +79,12 @@ export function useLiveVoice({ const micStreamRef = useRef(null); const audioContextRef = useRef(null); const activeRef = useRef(false); + // Bumped by every start() and stop(). An in-flight start compares its own + // generation after each await so a stop() (or a second start()) issued + // mid-handshake makes the stale attempt release its resources instead of + // activating a conversation the user already cancelled. + const startGenerationRef = useRef(0); + const connectingRef = useRef(false); const onUtteranceRef = useRef(onUtterance); onUtteranceRef.current = onUtterance; @@ -229,6 +235,8 @@ export function useLiveVoice({ ); const stop = useCallback(() => { + startGenerationRef.current += 1; + connectingRef.current = false; activeRef.current = false; stopVad(); stopSpeaking(); @@ -284,16 +292,28 @@ export function useLiveVoice({ ); const start = useCallback(async () => { - if (activeRef.current || disabled) { + if (activeRef.current || connectingRef.current || disabled) { return; } + const generation = ++startGenerationRef.current; + const isStale = () => startGenerationRef.current !== generation; + connectingRef.current = true; setError(null); setStatus('connecting'); + let micStream: MediaStream | null = null; + let peer: RTCPeerConnection | null = null; + const releaseAttempt = () => { + peer?.close(); + micStream?.getTracks().forEach((track) => track.stop()); + }; + try { const token = await trpcClient.voice.createRealtimeToken.mutate(); - const micStream = await navigator.mediaDevices.getUserMedia({ + if (isStale()) return; + + micStream = await navigator.mediaDevices.getUserMedia({ audio: { // Echo cancellation keeps the agent's own spoken reply (played // through the speakers) from triggering barge-in. @@ -302,8 +322,12 @@ export function useLiveVoice({ autoGainControl: true, }, }); + if (isStale()) { + releaseAttempt(); + return; + } - const peer = new RTCPeerConnection(); + peer = new RTCPeerConnection(); const [audioTrack] = micStream.getAudioTracks(); if (!audioTrack) { @@ -317,6 +341,10 @@ export function useLiveVoice({ const offer = await peer.createOffer(); await peer.setLocalDescription(offer); + if (isStale()) { + releaseAttempt(); + return; + } const response = await fetch(OPENAI_REALTIME_CALLS_URL, { method: 'POST', @@ -331,19 +359,34 @@ export function useLiveVoice({ throw new Error('Voice session handshake failed'); } - await peer.setRemoteDescription({ - type: 'answer', - sdp: await response.text(), - }); + const answerSdp = await response.text(); + if (isStale()) { + releaseAttempt(); + return; + } + + await peer.setRemoteDescription({ type: 'answer', sdp: answerSdp }); + if (isStale()) { + releaseAttempt(); + return; + } peerRef.current = peer; dataChannelRef.current = dataChannel; micStreamRef.current = micStream; + connectingRef.current = false; activeRef.current = true; startVad(micStream); setActive(true); setStatus('listening'); } catch (caught) { + if (isStale()) { + // A stop() already reset the hook; just drop what this attempt held. + releaseAttempt(); + return; + } + + releaseAttempt(); stop(); setStatus('error'); setError( From dbdb73560b5f43d7dc218bdb2c50b0942d83c442 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:43:25 -0400 Subject: [PATCH 04/18] fix: voice cutoff from server timestamps, stop voice on structured input - The spoken-reply cutoff is now the newest transcript timestamp at the moment voice starts instead of the browser clock, so a client clock running ahead of the server no longer silences every reply. - A pending structured input request replaces the composer and its voice controls, so the conversation is stopped when one arrives rather than leaving the microphone open with no End control. --- .../FastSessionTranscript.client.test.tsx | 110 ++++++++++++++++++ .../[sessionId]/FastSessionTranscript.tsx | 24 +++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 201fee385f..77771b4c21 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -2092,5 +2092,115 @@ describe('FastSessionTranscript', () => { 'Looking into it\n\nHere is the result', ); }); + + it('sets the spoken cutoff from server timestamps, not the browser clock', async () => { + // Browser clock far ahead of the server-assigned message timestamps. + vi.spyOn(Date, 'now').mockReturnValue(1_000_000); + voiceStatusQuery.mockResolvedValue({ enabled: true }); + const transcript = ( + + ); + const { rerender } = render(transcript); + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: false, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: /^voice conversation$/i }), + ); + liveVoiceState.active = true; + liveVoiceState.status = 'listening'; + rerender(transcript); + + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: true, + }); + }); + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'assistant-1', + role: 'assistant', + text: 'Server-timed reply', + ts: 2, + }), + ], + }); + }); + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: false, + }); + }); + + expect(liveVoiceState.speak).toHaveBeenCalledTimes(1); + expect(liveVoiceState.speak).toHaveBeenCalledWith('Server-timed reply'); + }); + + it('stops the voice conversation when a structured input request arrives', async () => { + voiceStatusQuery.mockResolvedValue({ enabled: true }); + liveVoiceState.active = true; + liveVoiceState.status = 'listening'; + render( + , + ); + await screen.findAllByRole('button', { name: /end voice conversation/i }); + expect(liveVoiceState.stop).not.toHaveBeenCalled(); + + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + { + ...textMessage({ + id: 'request-1', + role: 'assistant', + text: 'Choose one', + ts: 5, + }), + eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput, + payload: { + requestId: 'rui:request-1', + status: 'pending', + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + questions: [ + { + id: 'choice', + header: 'Choice', + question: 'Choose one', + isOther: false, + isSecret: false, + options: [{ label: 'One', description: 'First choice' }], + }, + ], + }, + }, + ], + }); + }); + + expect(screen.getByText('Structured input request')).toBeVisible(); + expect(liveVoiceState.stop).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index ba92c96dd1..bab414c5dc 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -848,11 +848,29 @@ export function FastSessionTranscript({ return; } - // Replies that predate the conversation stay silent. - lastSpokenTsRef.current = Date.now(); + // Replies that predate the conversation stay silent. The cutoff comes + // from the transcript's own (server-assigned) timestamps rather than the + // browser clock, which may run ahead of the server. + lastSpokenTsRef.current = messages.reduce( + (latest, message) => Math.max(latest, message.ts), + 0, + ); pendingUtterancesRef.current = []; void liveVoice.start(); - }, [liveVoice]); + }, [liveVoice, messages]); + + // A structured input request replaces the composer (and with it the voice + // controls), so end the conversation rather than leaving the microphone + // open with no way to stop it. + const liveVoiceConnecting = liveVoice.status === 'connecting'; + const stopLiveVoiceRef = useRef(liveVoice.stop); + stopLiveVoiceRef.current = liveVoice.stop; + + useEffect(() => { + if (pendingInputRequest && (liveVoiceActive || liveVoiceConnecting)) { + stopLiveVoiceRef.current(); + } + }, [pendingInputRequest, liveVoiceActive, liveVoiceConnecting]); return ( Date: Fri, 4 Sep 2026 15:59:45 -0400 Subject: [PATCH 05/18] perf: speak replies as they stream instead of after the turn settles The voice loop waited for the whole Fast turn to finish, then sent the entire reply to TTS as one request, so the user heard nothing until closeout plus first-byte on a long synthesis. - Speak incrementally: each completed sentence of a streaming reply is queued the moment it lands, the remainder when the persisted row finalizes it. A per-message cursor keeps the persisted row from repeating what the stream already said, and progress messages are read without ever skipping the result that follows. - Pipeline TTS: speak() now appends to a queue drained by one loop that keeps two synthesis requests in flight ahead of playback, uses short (~400 char) requests for fast first byte, and coalesces sentences that arrive while a request is pending. - Barge-in mutes the rest of the interrupted reply rather than pausing it (surfaced as an interruptions counter for the transcript). - VAD pause 800ms -> 600ms; the server memoizes the OpenAI key lookup for 30s since synthesis is now many small requests. --- .../FastSessionTranscript.client.test.tsx | 130 +++++-- .../[sessionId]/FastSessionTranscript.tsx | 91 +++-- apps/web/src/hooks/useLiveVoice.ts | 320 ++++++++++++------ apps/web/src/lib/server/voice.ts | 19 +- apps/web/src/lib/voice-speech.test.ts | 39 ++- apps/web/src/lib/voice-speech.ts | 33 ++ 6 files changed, 460 insertions(+), 172 deletions(-) diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 77771b4c21..0af8e4f1a9 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -51,6 +51,7 @@ const { stop: vi.fn(), speak: vi.fn(), stopSpeaking: vi.fn(), + interruptions: 0, }, })); @@ -64,6 +65,7 @@ vi.mock('@/hooks/useLiveVoice', () => ({ stop: liveVoiceState.stop, speak: liveVoiceState.speak, stopSpeaking: liveVoiceState.stopSpeaking, + interruptions: liveVoiceState.interruptions, }), })); @@ -238,6 +240,7 @@ beforeEach(() => { liveVoiceState.stop.mockReset(); liveVoiceState.speak.mockReset(); liveVoiceState.stopSpeaking.mockReset(); + liveVoiceState.interruptions = 0; vi.stubGlobal('EventSource', FakeEventSource); }); @@ -2008,11 +2011,10 @@ describe('FastSessionTranscript', () => { expect(liveVoiceState.start).not.toHaveBeenCalled(); }); - it('speaks the reply only once the turn settles, not on the first visible message', async () => { + it('speaks each completed sentence as the reply streams, then the rest on persist', async () => { vi.spyOn(Date, 'now').mockReturnValue(10); voiceStatusQuery.mockResolvedValue({ enabled: true }); - replyMutate.mockResolvedValue({ success: true }); - const transcript = ( + const transcript = () => ( { canReply /> ); - const { rerender } = render(transcript); - // The stream's initial session snapshot; only later state changes count. - act(() => { - FakeEventSource.instances[0]!.emit('session', { - conversationResponding: false, - }); - }); + const { rerender } = render(transcript()); // Starting the conversation marks earlier replies as already spoken. fireEvent.click( await screen.findByRole('button', { name: /^voice conversation$/i }), ); - expect(liveVoiceState.start).toHaveBeenCalledTimes(1); liveVoiceState.active = true; liveVoiceState.status = 'listening'; - rerender(transcript); + rerender(transcript()); + expect(liveVoiceState.speak).not.toHaveBeenCalled(); - const input = screen.getByPlaceholderText('Message agent'); - fireEvent.change(input, { target: { value: 'Follow up' } }); - fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); - await waitFor(() => expect(replyMutate).toHaveBeenCalledTimes(1)); + act(() => { + FakeEventSource.instances[0]!.emit( + 'chunk', + chunkEvent('assistant-1:event', 'First sentence. Second', 11), + ); + }); + // The finished sentence goes out immediately; the open one waits. + expect(liveVoiceState.speak).toHaveBeenCalledTimes(1); + expect(liveVoiceState.speak).toHaveBeenLastCalledWith('First sentence.'); act(() => { - FakeEventSource.instances[0]!.emit('session', { - conversationResponding: true, - }); + FakeEventSource.instances[0]!.emit( + 'chunk', + chunkEvent('assistant-1:event', ' part is here', 11), + ); }); + expect(liveVoiceState.speak).toHaveBeenCalledTimes(1); + + // The persisted row finalizes the reply under the same id: only the + // unspoken remainder is queued. act(() => { FakeEventSource.instances[0]!.emit('messages', { messages: [ textMessage({ - id: 'assistant-progress', + id: 'assistant-1', role: 'assistant', - text: 'Looking into it', + text: 'First sentence. Second part is here.', ts: 11, }), ], }); }); - // The progress kickoff clears the pending state but the turn is still - // running, so nothing is spoken yet. - expect(liveVoiceState.speak).not.toHaveBeenCalled(); + expect(liveVoiceState.speak).toHaveBeenCalledTimes(2); + expect(liveVoiceState.speak).toHaveBeenLastCalledWith( + 'Second part is here.', + ); + // A later message in the same turn is spoken too; nothing is skipped + // because an earlier progress message was read first. act(() => { FakeEventSource.instances[0]!.emit('messages', { messages: [ textMessage({ - id: 'assistant-final', + id: 'assistant-2', role: 'assistant', - text: 'Here is the result', + text: 'Here is the result.', ts: 12, }), ], }); }); + expect(liveVoiceState.speak).toHaveBeenCalledTimes(3); + expect(liveVoiceState.speak).toHaveBeenLastCalledWith( + 'Here is the result.', + ); + }); + + it('mutes the rest of a reply the user talked over', async () => { + voiceStatusQuery.mockResolvedValue({ enabled: true }); + liveVoiceState.active = true; + liveVoiceState.status = 'listening'; + const transcript = () => ( + + ); + const { rerender } = render(transcript()); + await screen.findAllByRole('button', { name: /end voice conversation/i }); + + act(() => { + FakeEventSource.instances[0]!.emit( + 'chunk', + chunkEvent('assistant-1:event', 'Long answer begins. And', 5), + ); + }); + expect(liveVoiceState.speak).toHaveBeenCalledTimes(1); + + // The user interrupts mid-reply. + liveVoiceState.interruptions = 1; + rerender(transcript()); + act(() => { - FakeEventSource.instances[0]!.emit('session', { - conversationResponding: false, + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'assistant-1', + role: 'assistant', + text: 'Long answer begins. And keeps going on.', + ts: 5, + }), + ], }); }); - expect(liveVoiceState.speak).toHaveBeenCalledTimes(1); - expect(liveVoiceState.speak).toHaveBeenCalledWith( - 'Looking into it\n\nHere is the result', - ); + + // The next reply is a fresh one and is spoken. + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'assistant-2', + role: 'assistant', + text: 'Sure, switching.', + ts: 6, + }), + ], + }); + }); + expect(liveVoiceState.speak).toHaveBeenCalledTimes(2); + expect(liveVoiceState.speak).toHaveBeenLastCalledWith('Sure, switching.'); }); it('sets the spoken cutoff from server timestamps, not the browser clock', async () => { // Browser clock far ahead of the server-assigned message timestamps. vi.spyOn(Date, 'now').mockReturnValue(1_000_000); voiceStatusQuery.mockResolvedValue({ enabled: true }); - const transcript = ( + const transcript = () => ( { canReply /> ); - const { rerender } = render(transcript); + const { rerender } = render(transcript()); act(() => { FakeEventSource.instances[0]!.emit('session', { conversationResponding: false, @@ -2123,7 +2183,7 @@ describe('FastSessionTranscript', () => { ); liveVoiceState.active = true; liveVoiceState.status = 'listening'; - rerender(transcript); + rerender(transcript()); act(() => { FakeEventSource.instances[0]!.emit('session', { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index bab414c5dc..88aafbe948 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -41,6 +41,7 @@ import { } from '@/components/ai-elements/slack-mention-context'; import { WorkspaceHeader } from '@/components/layout'; import { useLiveVoice } from '@/hooks/useLiveVoice'; +import { findSpeakableBoundary } from '@/lib/voice-speech'; import { SessionPromptInput, type SessionModelSelection, @@ -749,9 +750,14 @@ export function FastSessionTranscript({ model: sessionModel, reasoningEffort: sessionReasoningEffort, }); - /** Assistant messages at or before this ts have already been spoken. */ - const lastSpokenTsRef = useRef(0); - const previousAgentWorkingRef = useRef(false); + /** Assistant messages at or before this ts predate the conversation. */ + const voiceCutoffTsRef = useRef(0); + /** + * Per assistant message (keyed by transcript id), how many characters of + * its text have already been handed to speech. `Infinity` marks a reply + * the user interrupted, which stays silent even as more of it arrives. + */ + const spokenCursorsRef = useRef(new Map()); const pendingUtterancesRef = useRef([]); const [utteranceQueueVersion, setUtteranceQueueVersion] = useState(0); @@ -798,12 +804,6 @@ export function FastSessionTranscript({ }); }, [isSending, utteranceQueueVersion, sendReply]); - // Speak the agent's reply once the turn settles. `pendingAfter` alone clears - // on the first visible assistant message, which for Fast can be a progress - // kickoff ahead of the real result, so this waits for the composite - // "agent working" signal (send in flight, turn responding, or response - // pending) to fall back to false and then reads every not-yet-spoken - // assistant message as a single reply. const agentWorking = isSending || conversationResponding === true || @@ -812,34 +812,64 @@ export function FastSessionTranscript({ const speakRef = useRef(liveVoice.speak); speakRef.current = liveVoice.speak; + // Speak replies as they arrive rather than after the turn settles: each + // completed sentence of a streaming reply is queued the moment it lands, + // and whatever remains is queued when the persisted row finalizes it. The + // per-message cursor means a persisted row that replaces its streamed + // chunks continues where the stream left off instead of repeating. useEffect(() => { - const wasWorking = previousAgentWorkingRef.current; - previousAgentWorkingRef.current = agentWorking; - - if (!liveVoiceActive || !wasWorking || agentWorking) { + if (!liveVoiceActive) { return; } - const unspoken = messages.filter( - (message) => - message.role !== 'user' && - message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage && - message.metadata?.visibleInTranscript !== false && - message.ts > lastSpokenTsRef.current, - ); - const texts = unspoken - .map((message) => getTextFromContentBlocks(message.contentBlocks)?.trim()) - .filter((text): text is string => Boolean(text)); + for (const message of uiMessages) { + if ( + message.role !== 'assistant' || + message.visibleInTranscript === false || + message.ts <= voiceCutoffTsRef.current || + !message.text || + (message.kind !== 'text' && + message.updateType !== ACP_ENVELOPE_EVENT_TYPES.AssistantMessage) + ) { + continue; + } + + const cursor = spokenCursorsRef.current.get(message.id) ?? 0; + + if (cursor === Infinity) { + continue; + } + + const boundary = message.partial + ? findSpeakableBoundary(message.text, cursor) + : message.text.length; - if (texts.length === 0) { + if (boundary <= cursor) { + continue; + } + + spokenCursorsRef.current.set(message.id, boundary); + speakRef.current(message.text.slice(cursor, boundary).trim()); + } + }, [uiMessages, liveVoiceActive]); + + // Talking over a reply drops the rest of it: every reply known at the + // moment of interruption is muted so later chunks of it stay silent. + const uiMessagesRef = useRef(uiMessages); + uiMessagesRef.current = uiMessages; + const liveVoiceInterruptions = liveVoice.interruptions; + + useEffect(() => { + if (liveVoiceInterruptions === 0) { return; } - lastSpokenTsRef.current = Math.max( - ...unspoken.map((message) => message.ts), - ); - speakRef.current(texts.join('\n\n')); - }, [agentWorking, liveVoiceActive, messages]); + for (const message of uiMessagesRef.current) { + if (message.role === 'assistant') { + spokenCursorsRef.current.set(message.id, Infinity); + } + } + }, [liveVoiceInterruptions]); const handleVoiceToggle = useCallback(() => { // Toggling while the handshake is still connecting cancels it. @@ -851,10 +881,11 @@ export function FastSessionTranscript({ // Replies that predate the conversation stay silent. The cutoff comes // from the transcript's own (server-assigned) timestamps rather than the // browser clock, which may run ahead of the server. - lastSpokenTsRef.current = messages.reduce( + voiceCutoffTsRef.current = messages.reduce( (latest, message) => Math.max(latest, message.ts), 0, ); + spokenCursorsRef.current.clear(); pendingUtterancesRef.current = []; void liveVoice.start(); }, [liveVoice, messages]); diff --git a/apps/web/src/hooks/useLiveVoice.ts b/apps/web/src/hooks/useLiveVoice.ts index ef0acc0971..6b26a78785 100644 --- a/apps/web/src/hooks/useLiveVoice.ts +++ b/apps/web/src/hooks/useLiveVoice.ts @@ -21,11 +21,19 @@ const TTS_SAMPLE_RATE = 24_000; /** Feed the player in ~250ms batches so playback starts almost immediately. */ const MIN_PLAYBACK_SAMPLES = TTS_SAMPLE_RATE / 4; +/** + * Each synthesis request stays short so its first audio byte arrives fast; + * sentences queued while a request is in flight coalesce up to this size. + */ +const TTS_CHUNK_CHARS = 400; +/** Synthesis requests kept in flight ahead of the one being played. */ +const TTS_PREFETCH = 2; + const VAD_INTERVAL_MS = 50; /** RMS above this counts as the user speaking. */ const VAD_SPEECH_RMS = 0.02; /** A pause this long ends the utterance and commits it. */ -const VAD_SILENCE_MS = 800; +const VAD_SILENCE_MS = 600; /** Shorter bursts (a cough, a keyboard clack) are not worth committing. */ const VAD_MIN_SPEECH_MS = 250; @@ -52,11 +60,26 @@ interface UseLiveVoiceReturn { error: string | null; start: () => Promise; stop: () => void; - /** Speak an agent reply (raw markdown; it is cleaned before synthesis). */ + /** + * Queue agent reply text for speech (raw markdown; it is cleaned before + * synthesis). Calls append to whatever is already playing, so a reply can + * be spoken sentence by sentence as it streams in. + */ speak: (markdown: string) => void; stopSpeaking: () => void; + /** + * Incremented each time the user talks over a reply. Callers use it to + * drop the rest of the interrupted reply instead of resuming it later. + */ + interruptions: number; } +type SpeechQueueItem = { + text: string; + /** Prefetched synthesis response, once the request has been started. */ + response?: Promise; +}; + type RealtimeServerEvent = { type?: string; delta?: string; @@ -73,6 +96,7 @@ export function useLiveVoice({ const [status, setStatus] = useState('idle'); const [interimTranscript, setInterimTranscript] = useState(''); const [error, setError] = useState(null); + const [interruptions, setInterruptions] = useState(0); const peerRef = useRef(null); const dataChannelRef = useRef(null); @@ -95,6 +119,8 @@ export function useLiveVoice({ const scheduledSourcesRef = useRef>(new Set()); const nextPlaybackTimeRef = useRef(0); const speakingRef = useRef(false); + const speechQueueRef = useRef([]); + const drainingRef = useRef(false); const setSpeaking = useCallback((speaking: boolean) => { speakingRef.current = speaking; @@ -108,6 +134,8 @@ export function useLiveVoice({ playbackGenerationRef.current += 1; playbackAbortRef.current?.abort(); playbackAbortRef.current = null; + speechQueueRef.current = []; + drainingRef.current = false; for (const source of scheduledSourcesRef.current) { try { @@ -124,6 +152,15 @@ export function useLiveVoice({ } }, [setSpeaking]); + /** Barge-in: the user talking over a reply silences it for good. */ + const interrupt = useCallback(() => { + if (!speakingRef.current && speechQueueRef.current.length === 0) { + return; + } + stopSpeaking(); + setInterruptions((count) => count + 1); + }, [stopSpeaking]); + // Local VAD state: an analyser taps the mic stream and a timer classifies // each 50ms window as speech or silence. const vadTimerRef = useRef(null); @@ -211,8 +248,7 @@ export function useLiveVoice({ if (!state.speaking) { state.speaking = true; state.speechStartAt = now; - // Barge-in: the user talking over a reply silences it. - stopSpeaking(); + interrupt(); } state.lastVoiceAt = now; state.hadSpeech = true; @@ -231,7 +267,7 @@ export function useLiveVoice({ } }, VAD_INTERVAL_MS); }, - [commitUtterance, ensureAudioContext, stopSpeaking], + [commitUtterance, ensureAudioContext, interrupt], ); const stop = useCallback(() => { @@ -265,8 +301,7 @@ export function useLiveVoice({ switch (event.type) { case 'input_audio_buffer.speech_started': - // Barge-in: the user talking over a reply silences it. - stopSpeaking(); + interrupt(); break; case 'conversation.item.input_audio_transcription.delta': if (event.delta) { @@ -288,7 +323,7 @@ export function useLiveVoice({ break; } }, - [stopSpeaking], + [interrupt], ); const start = useCallback(async () => { @@ -426,116 +461,190 @@ export function useLiveVoice({ [setSpeaking], ); + const fetchSpeech = useCallback( + (text: string, signal: AbortSignal) => + fetch('/api/voice/tts', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text }), + signal, + }), + [], + ); + + /** Stream one synthesis response into the audio scheduler. */ + const playResponse = useCallback( + async ( + context: AudioContext, + response: Response, + generation: number, + ): Promise => { + if (!response.ok || !response.body) { + throw new Error('Speech synthesis failed'); + } + + const reader = response.body.getReader(); + // 16-bit samples can split across network chunks; carry the odd byte + // over, and batch small reads so sources aren't tiny. + let carry = new Uint8Array(0); + let pending: Float32Array[] = []; + let pendingSamples = 0; + + const flush = () => { + if (pendingSamples === 0) return; + const merged = new Float32Array(pendingSamples); + let offset = 0; + for (const part of pending) { + merged.set(part, offset); + offset += part.length; + } + pending = []; + pendingSamples = 0; + schedulePcm(context, merged); + }; + + while (true) { + const { done, value } = await reader.read(); + + if (playbackGenerationRef.current !== generation) { + await reader.cancel().catch(() => undefined); + return false; + } + + if (done) { + break; + } + + const bytes = new Uint8Array(carry.length + value.length); + bytes.set(carry, 0); + bytes.set(value, carry.length); + const usable = bytes.length - (bytes.length % 2); + carry = bytes.slice(usable); + + if (usable === 0) { + continue; + } + + const ints = new Int16Array(bytes.buffer.slice(0, usable)); + const floats = new Float32Array(ints.length); + for (let i = 0; i < ints.length; i++) { + floats[i] = (ints[i] ?? 0) / 32_768; + } + pending.push(floats); + pendingSamples += floats.length; + + if (pendingSamples >= MIN_PLAYBACK_SAMPLES) { + flush(); + } + } + + flush(); + return true; + }, + [schedulePcm], + ); + + /** + * Drain the speech queue: synthesize each item in order while keeping the + * next few requests in flight, so the gap between sentences is playback + * time rather than round-trip time. + */ + const drainSpeechQueue = useCallback(() => { + if (drainingRef.current) { + return; + } + + const generation = playbackGenerationRef.current; + const abortController = new AbortController(); + playbackAbortRef.current = abortController; + drainingRef.current = true; + + const context = ensureAudioContext(); + if (scheduledSourcesRef.current.size === 0) { + nextPlaybackTimeRef.current = context.currentTime; + } + setSpeaking(true); + + const prefetch = () => { + for (const item of speechQueueRef.current.slice(0, TTS_PREFETCH)) { + if (!item.response) { + item.response = fetchSpeech(item.text, abortController.signal); + // The drain loop awaits this later; keep an early failure from + // surfacing as an unhandled rejection in the meantime. + item.response.catch(() => undefined); + } + } + }; + + void (async () => { + try { + while (speechQueueRef.current.length > 0) { + if (playbackGenerationRef.current !== generation) { + return; + } + + prefetch(); + const item = speechQueueRef.current.shift(); + if (!item) break; + const response = await (item.response ?? + fetchSpeech(item.text, abortController.signal)); + if (playbackGenerationRef.current !== generation) { + return; + } + prefetch(); + if (!(await playResponse(context, response, generation))) { + return; + } + } + } catch { + // Aborted playback or a failed synthesis: fall back to silence. + } finally { + if (playbackGenerationRef.current === generation) { + drainingRef.current = false; + playbackAbortRef.current = null; + if (scheduledSourcesRef.current.size === 0) { + setSpeaking(false); + } + } + } + })(); + }, [ensureAudioContext, fetchSpeech, playResponse, setSpeaking]); + const speak = useCallback( (markdown: string) => { if (!activeRef.current) { return; } - const chunks = chunkSpeakableText(toSpeakableText(markdown)); + const chunks = chunkSpeakableText( + toSpeakableText(markdown), + TTS_CHUNK_CHARS, + ); if (chunks.length === 0) { return; } - stopSpeaking(); - - const generation = playbackGenerationRef.current; - const abortController = new AbortController(); - playbackAbortRef.current = abortController; - - const context = ensureAudioContext(); - nextPlaybackTimeRef.current = context.currentTime; - setSpeaking(true); - - void (async () => { - try { - for (const chunk of chunks) { - if (playbackGenerationRef.current !== generation) { - return; - } - - const response = await fetch('/api/voice/tts', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ text: chunk }), - signal: abortController.signal, - }); - - if (!response.ok || !response.body) { - throw new Error('Speech synthesis failed'); - } - - const reader = response.body.getReader(); - // 16-bit samples can split across network chunks; carry the odd - // byte over, and batch small reads so sources aren't tiny. - let carry = new Uint8Array(0); - let pending: Float32Array[] = []; - let pendingSamples = 0; - - const flush = () => { - if (pendingSamples === 0) return; - const merged = new Float32Array(pendingSamples); - let offset = 0; - for (const part of pending) { - merged.set(part, offset); - offset += part.length; - } - pending = []; - pendingSamples = 0; - schedulePcm(context, merged); - }; - - while (true) { - const { done, value } = await reader.read(); - - if (playbackGenerationRef.current !== generation) { - await reader.cancel().catch(() => undefined); - return; - } - - if (done) { - break; - } - - const bytes = new Uint8Array(carry.length + value.length); - bytes.set(carry, 0); - bytes.set(value, carry.length); - const usable = bytes.length - (bytes.length % 2); - carry = bytes.slice(usable); - - if (usable === 0) { - continue; - } - - const ints = new Int16Array(bytes.buffer.slice(0, usable)); - const floats = new Float32Array(ints.length); - for (let i = 0; i < ints.length; i++) { - floats[i] = (ints[i] ?? 0) / 32_768; - } - pending.push(floats); - pendingSamples += floats.length; - - if (pendingSamples >= MIN_PLAYBACK_SAMPLES) { - flush(); - } - } - - flush(); - } - } catch { - // Aborted playback or a failed synthesis: fall back to silence. - } finally { - if (playbackGenerationRef.current === generation) { - playbackAbortRef.current = null; - if (scheduledSourcesRef.current.size === 0) { - setSpeaking(false); - } - } + const queue = speechQueueRef.current; + for (const chunk of chunks) { + // Text arriving while earlier sentences are still waiting for their + // request merges into the last unsent item: fewer round trips when + // the agent is ahead of playback, no extra delay when it is not. + const last = queue.at(-1); + if ( + last && + !last.response && + last.text.length + chunk.length + 1 <= TTS_CHUNK_CHARS + ) { + last.text = `${last.text} ${chunk}`; + } else { + queue.push({ text: chunk }); } - })(); + } + + drainSpeechQueue(); }, - [ensureAudioContext, schedulePcm, setSpeaking, stopSpeaking], + [drainSpeechQueue], ); // `stop` is stable (its dependency chain bottoms out in setState), so this @@ -555,5 +664,6 @@ export function useLiveVoice({ stop, speak, stopSpeaking, + interruptions, }; } diff --git a/apps/web/src/lib/server/voice.ts b/apps/web/src/lib/server/voice.ts index 865f758db1..6becd77867 100644 --- a/apps/web/src/lib/server/voice.ts +++ b/apps/web/src/lib/server/voice.ts @@ -42,9 +42,26 @@ const CLIENT_SECRET_TTL_SECONDS = 600; const CLIENT_SECRET_TIMEOUT_MS = 15_000; const VOICE_TTS_TIMEOUT_MS = 60_000; +/** + * Spoken replies arrive one sentence at a time, so the key lookup (a + * settings read plus decryption) is memoized briefly instead of repeated on + * every synthesis request. + */ +const VOICE_KEY_CACHE_TTL_MS = 30_000; +let cachedVoiceKey: { value: string | undefined; expiresAt: number } | null = + null; + export async function resolveVoiceOpenAiKey(): Promise { + const now = Date.now(); + + if (cachedVoiceKey && cachedVoiceKey.expiresAt > now) { + return cachedVoiceKey.value; + } + const apiKey = await resolveModelProviderEnvValue(VOICE_OPENAI_ENV_VAR_NAMES); - return apiKey?.trim() || undefined; + const value = apiKey?.trim() || undefined; + cachedVoiceKey = { value, expiresAt: now + VOICE_KEY_CACHE_TTL_MS }; + return value; } export type VoiceRealtimeClientSecret = { diff --git a/apps/web/src/lib/voice-speech.test.ts b/apps/web/src/lib/voice-speech.test.ts index a577368b29..65d1e96c5e 100644 --- a/apps/web/src/lib/voice-speech.test.ts +++ b/apps/web/src/lib/voice-speech.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { chunkSpeakableText, toSpeakableText } from './voice-speech'; +import { + chunkSpeakableText, + findSpeakableBoundary, + toSpeakableText, +} from './voice-speech'; describe('toSpeakableText', () => { it('summarizes fenced code blocks instead of reading them', () => { @@ -66,3 +70,36 @@ describe('chunkSpeakableText', () => { expect(chunks).toEqual(['a'.repeat(10), 'a'.repeat(10), 'a'.repeat(5)]); }); }); + +describe('findSpeakableBoundary', () => { + it('returns the end of the last complete sentence', () => { + const text = 'First sentence. Second sentence! Third is still going'; + expect(findSpeakableBoundary(text, 0)).toBe( + 'First sentence. Second sentence!'.length, + ); + }); + + it('treats a newline as a boundary', () => { + const text = 'A heading\nStill typing'; + expect(findSpeakableBoundary(text, 0)).toBe('A heading\n'.length); + }); + + it('returns the start when no sentence has finished', () => { + expect(findSpeakableBoundary('Still typing', 0)).toBe(0); + expect(findSpeakableBoundary('Version 3.5 is out', 0)).toBe(0); + }); + + it('only advances past the given start', () => { + const text = 'Done. More coming'; + const first = findSpeakableBoundary(text, 0); + expect(first).toBe('Done.'.length); + expect(findSpeakableBoundary(text, first)).toBe(first); + }); + + it('holds back text inside an unclosed code fence', () => { + const open = 'Here is code. ```ts\nconst a = 1. Or so;'; + expect(findSpeakableBoundary(open, 0)).toBe('Here is code.'.length); + const closed = `${open}\n\`\`\`\nAll done. `; + expect(findSpeakableBoundary(closed, 0)).toBe(closed.length - 1); + }); +}); diff --git a/apps/web/src/lib/voice-speech.ts b/apps/web/src/lib/voice-speech.ts index 6ec406e58a..e3c7a5999a 100644 --- a/apps/web/src/lib/voice-speech.ts +++ b/apps/web/src/lib/voice-speech.ts @@ -107,3 +107,36 @@ function findLastSentenceEnd(window: string): number { return -1; } + +/** + * Find where a reply that is still streaming can safely be cut for speech: + * the end of the last complete sentence (or line) at or after `from`. Text + * inside an unclosed code fence is held back until the fence closes, since + * `toSpeakableText` summarizes fenced blocks as a whole. Returns `from` when + * nothing new is ready. + */ +export function findSpeakableBoundary(text: string, from: number): number { + let limit = text.length; + const fences = [...text.slice(from).matchAll(/```/g)]; + + if (fences.length % 2 === 1) { + limit = from + (fences[fences.length - 1]?.index ?? 0); + } + + for (let i = limit - 1; i > from; i--) { + const char = text[i]; + + if (char === '\n') { + return i + 1; + } + + if ( + (char === '.' || char === '!' || char === '?') && + /\s/.test(text[i + 1] ?? '') + ) { + return i + 1; + } + } + + return from; +} From 8c6e5b075611d05339a62c81c35b9cd809dda72a Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:14:28 -0400 Subject: [PATCH 06/18] feat: start a voice-mode Fast session from the home and New Session composers The home page composer and the New Session dialog get the same live voice toggle as the session composer. A session needs content to exist, so the composer listens for the first utterance, starts the Fast session with it (plus any typed text), and opens /sessions/?voice=1. The session page reads that flag, starts voice as soon as the deployment confirms it is configured with no spoken cutoff so the first reply is read aloud, and drops the flag from the URL so a reload does not restart the conversation. - TaskPromptInput gains optional voice controls and a banner slot for the status strip; dictation is disabled while a conversation is active. - useVoiceEnabled shares the status lookup between the transcript and the new-session form. - Voice stays hidden for environment launches (those are tasks, not sessions) and on deployments without voice configured. - Docs updated. --- apps/docs/voice.mdx | 11 +- .../(authenticated)/home/Home.client.test.tsx | 80 +++++++++++++ .../FastSessionTranscript.client.test.tsx | 59 ++++++++++ .../[sessionId]/FastSessionTranscript.tsx | 56 +++++++--- .../(sandbox)/sessions/[sessionId]/page.tsx | 11 +- apps/web/src/components/tasks/NewTaskForm.tsx | 105 ++++++++++++++++-- .../tasks/TaskPromptInput.client.test.tsx | 33 ++++++ .../src/components/tasks/TaskPromptInput.tsx | 27 ++++- apps/web/src/hooks/useVoiceEnabled.ts | 32 ++++++ 9 files changed, 382 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/hooks/useVoiceEnabled.ts diff --git a/apps/docs/voice.mdx b/apps/docs/voice.mdx index 26f2015aa9..afcce07ffa 100644 --- a/apps/docs/voice.mdx +++ b/apps/docs/voice.mdx @@ -26,12 +26,15 @@ tokens and synthesized audio, never the API key. ## Using voice -1. Open a Session and select the voice button in the composer. +1. Select the voice button in a composer: in an open Session, or on the home + page and the **New Session** dialog to start a fresh Session by speaking. 2. Grant microphone access when the browser asks. 3. Speak. A pause ends your turn and sends it to Fast automatically; the live - transcription is shown above the composer while you talk. -4. Fast's reply is read aloud when it arrives. Speak at any time to interrupt - the playback and take the next turn. + transcription is shown above the composer while you talk. From the home + page or dialog, your first utterance starts the Session and opens it + already in voice mode. +4. Fast's reply is read aloud as it arrives, sentence by sentence. Speak at + any time to interrupt the playback and take the next turn. 5. Select **End** (or the voice button again) to return to typing. Voice input requires a browser with microphone and WebRTC support, which diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index d78f50347d..0102754b19 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -20,6 +20,7 @@ let capturedSubmitWithMetaKey: boolean | undefined; let capturedDefaultReasoningEffort: string | null | undefined; const { + voiceState, mockPush, mockToast, mockToastError, @@ -29,6 +30,13 @@ const { mockPreparePromptAttachments, mockStartFastSession, } = vi.hoisted(() => ({ + voiceState: { + enabled: false, + active: false, + start: vi.fn(), + stop: vi.fn(), + onUtterance: undefined as ((text: string) => void) | undefined, + }, mockPush: vi.fn(), mockToast: vi.fn(), mockToastError: vi.fn(), @@ -91,6 +99,27 @@ vi.mock('@/hooks/task-runs', () => ({ }), })); +vi.mock('@/hooks/useVoiceEnabled', () => ({ + useVoiceEnabled: () => voiceState.enabled, +})); + +vi.mock('@/hooks/useLiveVoice', () => ({ + useLiveVoice: ({ onUtterance }: { onUtterance: (text: string) => void }) => { + voiceState.onUtterance = onUtterance; + return { + active: voiceState.active, + status: voiceState.active ? 'listening' : 'idle', + interimTranscript: '', + error: null, + start: voiceState.start, + stop: voiceState.stop, + speak: vi.fn(), + stopSpeaking: vi.fn(), + interruptions: 0, + }; + }, +})); + vi.mock('@/lib/prompt-attachments', async () => { const actual = await vi.importActual< typeof import('@/lib/prompt-attachments') @@ -142,6 +171,8 @@ vi.mock('@/components/tasks', async () => { submitDisabledReason, submitWithMetaKey, tools, + voice, + banner, }: { onSubmit: (message: PromptInputMessage) => Promise | void; onPromptTextChange?: (value: string) => void; @@ -150,6 +181,8 @@ vi.mock('@/components/tasks', async () => { submitDisabledReason?: string; submitWithMetaKey?: boolean; tools?: import('react').ReactNode; + voice?: { active: boolean; onToggle: () => void }; + banner?: import('react').ReactNode; }) => { capturedSubmitWithMetaKey = submitWithMetaKey; @@ -168,10 +201,20 @@ vi.mock('@/components/tasks', async () => { } }} > + {banner} {tools} + {voice ? ( + + ) : null}
{placeholder}