diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index a1faf7338..e514a5244 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -18,7 +18,6 @@ interface DictationControlsProps { onRetry?: () => void; onDiscard?: () => void; disabled?: boolean; - retryStatusText?: string; } function formatDuration(seconds: number): string { @@ -42,13 +41,11 @@ export function DictationControls({ onRetry, onDiscard, disabled = false, - retryStatusText, }: DictationControlsProps) { const { theme } = useUnistyles(); - const isRetrying = status === "retrying"; const isFailed = status === "failed"; - const showActiveState = isRecording || isProcessing || isRetrying || isFailed; - const actionsDisabled = isProcessing || isRetrying; + const showActiveState = isRecording || isProcessing || isFailed; + const actionsDisabled = isProcessing; const handleCancel = isFailed && onDiscard ? onDiscard : onCancel; if (!showActiveState) { @@ -123,13 +120,6 @@ export function DictationControls({ )} - {retryStatusText && (isRetrying || isFailed) && ( - - {retryStatusText} - - )} ); } @@ -149,12 +139,11 @@ export function DictationOverlay({ onAcceptAndSend, onRetry, onDiscard, -}: Omit) { +}: Omit) { const { theme } = useUnistyles(); - const isRetrying = status === "retrying"; const isFailed = status === "failed"; - const showActiveState = isRecording || isProcessing || isRetrying || isFailed; - const actionsDisabled = isProcessing || isRetrying; + const showActiveState = isRecording || isProcessing || isFailed; + const actionsDisabled = isProcessing; const handleCancel = isFailed && onDiscard ? onDiscard : onCancel; if (!showActiveState) { diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 750fd072e..5b9a1fc2e 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -210,7 +210,7 @@ export const MessageInput = forwardRef( }); useEffect(() => { - if (isDictating || isDictationProcessing || dictationStatus === "retrying") { + if (isDictating || isDictationProcessing) { return; } sendAfterTranscriptRef.current = false; @@ -246,7 +246,6 @@ export const MessageInput = forwardRef( const showOverlay = isDictating || isDictationProcessing || - dictationStatus === "retrying" || dictationStatus === "failed"; overlayTransition.value = withTiming(showOverlay ? 1 : 0, { duration: 200, diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx index ff5b54d6d..6dab61d88 100644 --- a/packages/app/src/components/volume-meter.tsx +++ b/packages/app/src/components/volume-meter.tsx @@ -88,7 +88,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp return; } - if (volume > 0.01) { + if (volume > 0.001) { // Active volume - animate heights based on volume const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2); const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.05); @@ -136,7 +136,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; const volumeBoost = isMuted ? 0 : volume * 0.3; return { - height: line1Height.value * (isMuted || volume > 0.01 ? 1 : line1Pulse.value), + height: line1Height.value * (isMuted || volume > 0.001 ? 1 : line1Pulse.value), opacity: baseOpacity + volumeBoost, }; }); @@ -146,7 +146,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; const volumeBoost = isMuted ? 0 : volume * 0.3; return { - height: line2Height.value * (isMuted || volume > 0.01 ? 1 : line2Pulse.value), + height: line2Height.value * (isMuted || volume > 0.001 ? 1 : line2Pulse.value), opacity: baseOpacity + volumeBoost, }; }); @@ -156,7 +156,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; const volumeBoost = isMuted ? 0 : volume * 0.3; return { - height: line3Height.value * (isMuted || volume > 0.01 ? 1 : line3Pulse.value), + height: line3Height.value * (isMuted || volume > 0.001 ? 1 : line3Pulse.value), opacity: baseOpacity + volumeBoost, }; }); diff --git a/packages/app/src/dictation/dictation-stream-sender.test.ts b/packages/app/src/dictation/dictation-stream-sender.test.ts new file mode 100644 index 000000000..3ebc4f1b1 --- /dev/null +++ b/packages/app/src/dictation/dictation-stream-sender.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { DictationStreamSender } from "@/dictation/dictation-stream-sender"; + +type FakeFinish = { dictationId: string; finalSeq: number }; +type FakeStart = { dictationId: string; format: string }; +type FakeChunk = { dictationId: string; seq: number; audio: string; format: string }; + +class FakeDaemonClient { + isConnected = true; + starts: FakeStart[] = []; + chunks: FakeChunk[] = []; + finishes: FakeFinish[] = []; + cancels: string[] = []; + + async startDictationStream(dictationId: string, format: string): Promise { + this.starts.push({ dictationId, format }); + } + + sendDictationStreamChunk(dictationId: string, seq: number, audio: string, format: string): void { + this.chunks.push({ dictationId, seq, audio, format }); + } + + async finishDictationStream(dictationId: string, finalSeq: number): Promise<{ dictationId: string; text: string }> { + this.finishes.push({ dictationId, finalSeq }); + return { dictationId, text: "ok" }; + } + + cancelDictationStream(dictationId: string): void { + this.cancels.push(dictationId); + } +} + +const tick = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("DictationStreamSender", () => { + it("enqueues segments and sends them after stream start", async () => { + const client = new FakeDaemonClient(); + const ids = ["d1"]; + const sender = new DictationStreamSender({ + client: client as any, + format: "audio/pcm;rate=16000;bits=16", + createDictationId: () => ids.shift() ?? "dX", + }); + + sender.enqueueSegment("seg0"); + sender.enqueueSegment("seg1"); + + await tick(); + + expect(client.starts).toEqual([{ dictationId: "d1", format: "audio/pcm;rate=16000;bits=16" }]); + expect(client.chunks).toEqual([ + { dictationId: "d1", seq: 0, audio: "seg0", format: "audio/pcm;rate=16000;bits=16" }, + { dictationId: "d1", seq: 1, audio: "seg1", format: "audio/pcm;rate=16000;bits=16" }, + ]); + }); + + it("restarts stream and resends from seq=0 on reconnect", async () => { + const client = new FakeDaemonClient(); + const ids = ["d1", "d2"]; + const sender = new DictationStreamSender({ + client: client as any, + format: "audio/pcm;rate=16000;bits=16", + createDictationId: () => ids.shift() ?? "dX", + }); + + sender.enqueueSegment("seg0"); + sender.enqueueSegment("seg1"); + await tick(); + + await sender.restartStream("reconnect"); + + expect(client.starts.map((s) => s.dictationId)).toEqual(["d1", "d2"]); + const d2Chunks = client.chunks.filter((c) => c.dictationId === "d2"); + expect(d2Chunks.map((c) => [c.seq, c.audio])).toEqual([ + [0, "seg0"], + [1, "seg1"], + ]); + }); + + it("finish flushes all queued segments and sends finish with finalSeq", async () => { + const client = new FakeDaemonClient(); + const ids = ["d1"]; + const sender = new DictationStreamSender({ + client: client as any, + format: "audio/pcm;rate=16000;bits=16", + createDictationId: () => ids.shift() ?? "dX", + }); + + sender.enqueueSegment("seg0"); + sender.enqueueSegment("seg1"); + + const finalSeq = sender.getFinalSeq(); + const result = await sender.finish(finalSeq); + + expect(result.text).toBe("ok"); + expect(client.chunks.map((c) => c.seq)).toEqual([0, 1]); + expect(client.finishes).toEqual([{ dictationId: "d1", finalSeq: 1 }]); + }); + + it("keeps segments while disconnected and sends them after restart when reconnected", async () => { + const client = new FakeDaemonClient(); + client.isConnected = false; + const ids = ["d1"]; + const sender = new DictationStreamSender({ + client: client as any, + format: "audio/pcm;rate=16000;bits=16", + createDictationId: () => ids.shift() ?? "dX", + }); + + sender.enqueueSegment("seg0"); + sender.enqueueSegment("seg1"); + + expect(client.starts).toHaveLength(0); + expect(client.chunks).toHaveLength(0); + + client.isConnected = true; + await sender.restartStream("reconnect"); + + expect(client.chunks.map((c) => c.seq)).toEqual([0, 1]); + }); +}); + diff --git a/packages/app/src/dictation/dictation-stream-sender.ts b/packages/app/src/dictation/dictation-stream-sender.ts new file mode 100644 index 000000000..6819bd6a4 --- /dev/null +++ b/packages/app/src/dictation/dictation-stream-sender.ts @@ -0,0 +1,189 @@ +import { generateMessageId } from "@/types/stream"; +import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; + +export type DictationStreamSenderParams = { + client: DaemonClientV2 | null; + format: string; + createDictationId?: () => string; +}; + +type DictationFinishResult = { dictationId: string; text: string }; + +/** + * Small, non-React state machine for dictation streaming. + * + * Responsibilities: + * - Maintain an ordered buffer of base64 PCM segments + * - Start/restart a dictation stream (dictationId) + * - Send missing segments (seq) when connected + * - Finish/cancel the stream + * + * This class intentionally keeps sending synchronous (no internal async mutex), + * so enqueues can't "miss" a flush due to in-flight await/coalescing bugs. + */ +export class DictationStreamSender { + private client: DaemonClientV2 | null; + private readonly format: string; + private readonly createDictationId: () => string; + + private dictationId: string | null = null; + private sendSeq = 0; + private segments: string[] = []; + private streamReady = false; + + private startGeneration = 0; + private startPromise: Promise | null = null; + + constructor(params: DictationStreamSenderParams) { + this.client = params.client; + this.format = params.format; + this.createDictationId = params.createDictationId ?? generateMessageId; + } + + setClient(client: DaemonClientV2 | null): void { + this.client = client; + } + + getDictationId(): string | null { + return this.dictationId; + } + + getSegmentCount(): number { + return this.segments.length; + } + + getFinalSeq(): number { + return this.segments.length - 1; + } + + hasSegments(): boolean { + return this.segments.length > 0; + } + + clearAll(): void { + this.dictationId = null; + this.sendSeq = 0; + this.segments = []; + this.streamReady = false; + this.startPromise = null; + this.startGeneration += 1; + } + + resetStreamForReplay(): void { + this.dictationId = null; + this.sendSeq = 0; + this.streamReady = false; + this.startPromise = null; + this.startGeneration += 1; + } + + enqueueSegment(base64Pcm: string): void { + this.segments.push(base64Pcm); + + const client = this.client; + if (!client?.isConnected) { + return; + } + + if (!this.dictationId) { + void this.restartStream("enqueue"); + return; + } + + this.flush(); + } + + flush(): number { + const client = this.client; + const dictationId = this.dictationId; + if (!client?.isConnected || !dictationId || !this.streamReady) { + return 0; + } + + let sent = 0; + while (this.sendSeq < this.segments.length) { + const seq = this.sendSeq; + const audio = this.segments[seq]!; + client.sendDictationStreamChunk(dictationId, seq, audio, this.format); + this.sendSeq = seq + 1; + sent += 1; + } + return sent; + } + + async restartStream(reason: string): Promise { + const client = this.client; + if (!client?.isConnected) { + return; + } + + this.startGeneration += 1; + const generation = this.startGeneration; + + const dictationId = this.createDictationId(); + this.dictationId = dictationId; + this.sendSeq = 0; + this.streamReady = false; + + const start = (async () => { + await client.startDictationStream(dictationId, this.format); + if (this.startGeneration !== generation) { + return; + } + if (this.dictationId !== dictationId) { + return; + } + this.streamReady = true; + this.flush(); + })().catch((error) => { + // If starting failed, keep the segments for retry but clear the stream so finish can error cleanly. + if (this.startGeneration === generation && this.dictationId === dictationId) { + this.dictationId = null; + this.streamReady = false; + } + throw error; + }).finally(() => { + if (this.startPromise === start) { + this.startPromise = null; + } + }); + + this.startPromise = start; + await start; + void reason; + } + + async finish(finalSeq: number): Promise { + const client = this.client; + if (!client) { + throw new Error("Daemon client unavailable"); + } + if (!client.isConnected) { + throw new Error("Daemon client is disconnected"); + } + + if (!this.dictationId) { + await this.restartStream("finalize"); + } + if (this.startPromise) { + await this.startPromise; + } + + const dictationId = this.dictationId; + if (!dictationId || !this.streamReady) { + throw new Error("Failed to start dictation stream"); + } + + this.flush(); + return client.finishDictationStream(dictationId, finalSeq); + } + + cancel(): void { + const client = this.client; + const dictationId = this.dictationId; + if (client?.isConnected && dictationId) { + client.cancelDictationStream(dictationId); + } + this.resetStreamForReplay(); + } +} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 6edc9b662..a18f37b15 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -251,6 +251,10 @@ export function useAgentFormState( model: "", workingDir: "", })); + const formStateRef = useRef(formState); + useEffect(() => { + formStateRef.current = formState; + }, [formState]); // Track if we've done initial resolution (to avoid flickering) const hasResolvedRef = useRef(false); @@ -299,16 +303,16 @@ export function useAgentFormState( preferences, availableModels, userModified, - formState + formStateRef.current ); // Only update if something changed if ( - resolved.serverId !== formState.serverId || - resolved.provider !== formState.provider || - resolved.modeId !== formState.modeId || - resolved.model !== formState.model || - resolved.workingDir !== formState.workingDir + resolved.serverId !== formStateRef.current.serverId || + resolved.provider !== formStateRef.current.provider || + resolved.modeId !== formStateRef.current.modeId || + resolved.model !== formStateRef.current.model || + resolved.workingDir !== formStateRef.current.workingDir ) { setFormState(resolved); } @@ -322,7 +326,6 @@ export function useAgentFormState( preferences, availableModels, userModified, - formState, ]); // Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load). diff --git a/packages/app/src/hooks/use-dictation-audio-source.d.ts b/packages/app/src/hooks/use-dictation-audio-source.d.ts new file mode 100644 index 000000000..45d7043ea --- /dev/null +++ b/packages/app/src/hooks/use-dictation-audio-source.d.ts @@ -0,0 +1,2 @@ +export * from "./use-dictation-audio-source.native"; + diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.ts b/packages/app/src/hooks/use-dictation-audio-source.native.ts new file mode 100644 index 000000000..5d3aff7e1 --- /dev/null +++ b/packages/app/src/hooks/use-dictation-audio-source.native.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio"; + +import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; + +export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource { + const onPcmSegmentRef = useRef(config.onPcmSegment); + const onErrorRef = useRef(config.onError); + + useEffect(() => { + onPcmSegmentRef.current = config.onPcmSegment; + onErrorRef.current = config.onError; + }, [config.onPcmSegment, config.onError]); + + const speechmatics = useSpeechmaticsAudio({ + enableContinuousStreaming: true, + onAudioSegment: ({ audioData }) => { + onPcmSegmentRef.current(audioData); + }, + onError: (err) => { + onErrorRef.current?.(err); + }, + volumeThreshold: 0.3, + silenceDuration: 2000, + speechConfirmationDuration: 300, + detectionGracePeriod: 200, + }); + + const start = useCallback(async () => { + await speechmatics.start(); + }, [speechmatics]); + + const stop = useCallback(async () => { + await speechmatics.stop(); + }, [speechmatics]); + + return { + start, + stop, + volume: speechmatics.volume, + }; +} + diff --git a/packages/app/src/hooks/use-dictation-audio-source.types.ts b/packages/app/src/hooks/use-dictation-audio-source.types.ts new file mode 100644 index 000000000..fd34966b6 --- /dev/null +++ b/packages/app/src/hooks/use-dictation-audio-source.types.ts @@ -0,0 +1,11 @@ +export type DictationAudioSourceConfig = { + onPcmSegment: (pcm16Base64: string) => void; + onError?: (error: Error) => void; +}; + +export type DictationAudioSource = { + start: () => Promise; + stop: () => Promise; + volume: number; +}; + diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts new file mode 100644 index 000000000..cd6cb9ff4 --- /dev/null +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -0,0 +1,247 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; + +const getAudioContextCtor = (): (typeof AudioContext) | null => { + if (typeof window === "undefined") { + return null; + } + const ctor = + (window as typeof window & { webkitAudioContext?: typeof AudioContext }).AudioContext || + (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + return ctor ?? null; +}; + +const floatToInt16 = (sample: number): number => { + const clamped = Math.max(-1, Math.min(1, sample)); + return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); +}; + +const resampleToPcm16 = (input: Float32Array, inputRate: number, outputRate: number): Int16Array => { + if (input.length === 0) { + return new Int16Array(0); + } + if (inputRate === outputRate) { + const out = new Int16Array(input.length); + for (let i = 0; i < input.length; i++) { + out[i] = floatToInt16(input[i]); + } + return out; + } + + const ratio = inputRate / outputRate; + const outputLength = Math.max(1, Math.round(input.length / ratio)); + const out = new Int16Array(outputLength); + for (let i = 0; i < outputLength; i++) { + const sourceIndex = i * ratio; + const i0 = Math.floor(sourceIndex); + const i1 = Math.min(input.length - 1, i0 + 1); + const frac = sourceIndex - i0; + const sample = input[i0] * (1 - frac) + input[i1] * frac; + out[i] = floatToInt16(sample); + } + return out; +}; + +const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => { + if (a.length === 0) { + return b; + } + if (b.length === 0) { + return a; + } + const out = new Int16Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +}; + +const int16ToBase64 = (pcm: Int16Array): string => { + const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +}; + +export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource { + const [volume, setVolume] = useState(0); + + const onPcmSegmentRef = useRef(config.onPcmSegment); + const onErrorRef = useRef(config.onError); + + useEffect(() => { + onPcmSegmentRef.current = config.onPcmSegment; + onErrorRef.current = config.onError; + }, [config.onPcmSegment, config.onError]); + + const refs = useRef<{ + stream: MediaStream | null; + context: AudioContext | null; + source: MediaStreamAudioSourceNode | null; + processor: ScriptProcessorNode | null; + gain: GainNode | null; + pending: Int16Array; + started: boolean; + }>({ stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false }); + + const start = useCallback(async () => { + const missingNavigator = + typeof navigator === "undefined" || + !navigator.mediaDevices || + typeof navigator.mediaDevices.getUserMedia !== "function"; + + const secureContext = + typeof window !== "undefined" && typeof window.isSecureContext === "boolean" + ? window.isSecureContext + : true; + const currentOrigin = + typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; + + if (missingNavigator) { + throw new Error("Microphone capture is not supported in this environment"); + } + if (!secureContext) { + throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`); + } + + const AudioContextCtor = getAudioContextCtor(); + if (!AudioContextCtor) { + throw new Error("AudioContext unavailable"); + } + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + noiseSuppression: true, + echoCancellation: true, + autoGainControl: true, + }, + }); + + const context = new AudioContextCtor(); + const source = context.createMediaStreamSource(stream); + const processor = context.createScriptProcessor(4096, 1, 1); + const gain = context.createGain(); + gain.gain.value = 0; + + const outputRate = 16000; + const chunkSamples = outputRate; // ~1s + + refs.current.started = true; + + processor.onaudioprocess = (event) => { + if (!refs.current.started) { + return; + } + const input = event.inputBuffer.getChannelData(0); + + let sumSquares = 0; + for (let i = 0; i < input.length; i++) { + const sample = input[i]; + sumSquares += sample * sample; + } + const rms = Math.sqrt(sumSquares / Math.max(1, input.length)); + const normalized = Math.min(1, Math.max(0, rms * 2)); + setVolume(normalized); + + const next = resampleToPcm16(input, context.sampleRate, outputRate); + refs.current.pending = concatInt16(refs.current.pending, next); + + while (refs.current.pending.length >= chunkSamples) { + const chunk = refs.current.pending.slice(0, chunkSamples); + refs.current.pending = refs.current.pending.slice(chunkSamples); + onPcmSegmentRef.current(int16ToBase64(chunk)); + } + }; + + source.connect(processor); + processor.connect(gain); + gain.connect(context.destination); + + refs.current = { stream, context, source, processor, gain, pending: new Int16Array(0), started: true }; + }, []); + + const stop = useCallback(async () => { + refs.current.started = false; + setVolume(0); + + const { processor, source, gain, context, stream, pending } = refs.current; + + if (processor) { + try { + processor.onaudioprocess = null; + } catch { + // no-op + } + try { + processor.disconnect(); + } catch { + // no-op + } + } + if (source) { + try { + source.disconnect(); + } catch { + // no-op + } + } + if (gain) { + try { + gain.disconnect(); + } catch { + // no-op + } + } + if (stream) { + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // no-op + } + }); + } + if (context) { + try { + await context.close(); + } catch { + // no-op + } + } + + if (pending.length > 0) { + onPcmSegmentRef.current(int16ToBase64(pending)); + } + + refs.current = { stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false }; + }, []); + + useEffect(() => { + return () => { + void stop().catch((err) => { + onErrorRef.current?.(err instanceof Error ? err : new Error(String(err))); + }); + }; + }, [stop]); + + return useMemo( + () => ({ + start: async () => { + try { + await start(); + } catch (err) { + const normalized = err instanceof Error ? err : new Error(String(err)); + onErrorRef.current?.(normalized); + throw normalized; + } + }, + stop, + volume, + }), + [start, stop, volume] + ); +} + diff --git a/packages/app/src/hooks/use-dictation.d.ts b/packages/app/src/hooks/use-dictation.d.ts new file mode 100644 index 000000000..83efec689 --- /dev/null +++ b/packages/app/src/hooks/use-dictation.d.ts @@ -0,0 +1 @@ +export * from "./use-dictation"; diff --git a/packages/app/src/hooks/use-dictation.shared.ts b/packages/app/src/hooks/use-dictation.shared.ts new file mode 100644 index 000000000..68540a809 --- /dev/null +++ b/packages/app/src/hooks/use-dictation.shared.ts @@ -0,0 +1,40 @@ +export type DictationStatus = "idle" | "recording" | "uploading" | "failed"; + +export type UseDictationOptions = { + client: import("@server/client/daemon-client-v2").DaemonClientV2 | null; + onTranscript: (text: string, meta: { requestId: string }) => void; + onError?: (error: Error) => void; + onPermanentFailure?: (error: Error, context: { requestId: string }) => void; + canStart?: () => boolean; + canConfirm?: () => boolean; + autoStopWhenHidden?: { isVisible: boolean }; + enableDuration?: boolean; +}; + +export type UseDictationResult = { + isRecording: boolean; + isProcessing: boolean; + volume: number; + duration: number; + error: string | null; + status: DictationStatus; + startDictation: () => Promise; + cancelDictation: () => Promise; + confirmDictation: () => Promise; + retryFailedDictation: () => Promise; + discardFailedDictation: () => void; + reset: () => void; +}; + +export const DURATION_TICK_MS = 1000; +export const PCM_DICTATION_FORMAT = "audio/pcm;rate=16000;bits=16"; + +export const toError = (error: unknown): Error => { + if (error instanceof Error) { + return error; + } + if (typeof error === "string" && error.trim().length > 0) { + return new Error(error); + } + return new Error("An unexpected error occurred while handling dictation."); +}; diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index e4d59c906..3a13bafdd 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -1,160 +1,23 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; -import { useAudioRecorder } from "@/hooks/use-audio-recorder"; -import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; -import type { TranscriptionResultMessage } from "@server/shared/messages"; +import { DictationStreamSender } from "@/dictation/dictation-stream-sender"; +import { useDictationAudioSource } from "@/hooks/use-dictation-audio-source"; import { generateMessageId } from "@/types/stream"; import { AttemptGuard } from "@/utils/attempt-guard"; - -export type DictationStatus = "idle" | "recording" | "uploading" | "retrying" | "failed"; - -type DictationRetryReason = "dispatch" | "timeout" | "response" | "disconnected"; - -export type DictationRetryInfo = { - attempt: number; - maxAttempts: number; - reason: DictationRetryReason; - errorMessage: string; - nextRetryMs: number; -}; - -export type FailedDictationRecording = { - requestId: string; - durationSeconds: number; - sizeBytes: number; - format: string; - recordedAt: number; - errorMessage: string; -}; - -export type DictationOutcome = - | { type: "success"; requestId: string; timestamp: number } - | { type: "failure"; requestId: string; errorMessage: string; timestamp: number }; - -export type UseDictationOptions = { - client: DaemonClientV2 | null; - onTranscript: (text: string, meta: { requestId: string }) => void; - onError?: (error: Error) => void; - onRetryAttempt?: (info: DictationRetryInfo) => void; - onPermanentFailure?: (error: Error, context: { requestId: string }) => void; - canStart?: () => boolean; - canConfirm?: () => boolean; - autoStopWhenHidden?: { isVisible: boolean }; - enableDuration?: boolean; -}; - -export type UseDictationResult = { - isRecording: boolean; - isProcessing: boolean; - volume: number; - duration: number; - pendingRequestId: string | null; - error: string | null; - status: DictationStatus; - retryAttempt: number; - maxRetryAttempts: number; - retryInfo: DictationRetryInfo | null; - failedRecording: FailedDictationRecording | null; - lastOutcome: DictationOutcome | null; - startDictation: () => Promise; - cancelDictation: () => Promise; - confirmDictation: () => Promise; - retryFailedDictation: () => Promise; - discardFailedDictation: () => void; - reset: () => void; -}; - -const DURATION_TICK_MS = 1000; -const MAX_AUTO_RETRY_ATTEMPTS = 5; -const RETRY_BASE_DELAY_MS = 2000; -const RETRY_MAX_DELAY_MS = 12000; -const RETRY_BACKOFF_FACTOR = 1.8; -const RETRY_JITTER_MS = 400; -const TRANSCRIPTION_TIMEOUT_MS = 120000; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -const computeRetryDelayMs = (attempt: number): number => { - const exponential = RETRY_BASE_DELAY_MS * RETRY_BACKOFF_FACTOR ** Math.max(0, attempt - 1); - const capped = Math.min(RETRY_MAX_DELAY_MS, exponential); - if (RETRY_JITTER_MS <= 0) { - return capped; - } - return capped + Math.floor(Math.random() * RETRY_JITTER_MS); -}; - -const isTimeoutError = (error: Error): boolean => - /timed out|timeout/i.test(error.message); - -class DictationAttemptError extends Error { - public readonly reason: DictationRetryReason; - - constructor(reason: DictationRetryReason, error: Error) { - super(error.message); - this.name = "DictationAttemptError"; - this.reason = reason; - (this as Error & { cause?: Error }).cause = error; - } -} - -const toError = (error: unknown): Error => { - if (error instanceof Error) { - return error; - } - if (typeof error === "string" && error.trim().length > 0) { - return new Error(error); - } - return new Error("An unexpected error occurred while handling dictation."); -}; - -type CapturedAudioPayload = { - blob: Blob; - format: string; - sizeBytes: number; - durationSeconds: number; - recordedAt: number; -}; - -type TranscriptionPayload = TranscriptionResultMessage["payload"]; - -const blobToBase64 = async (blob: Blob): Promise => { - const arrayBuffer = await blob.arrayBuffer(); - const bytes = new Uint8Array(arrayBuffer); - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -}; - -const deriveFormatFromMime = (mimeType?: string): string => { - if (!mimeType || mimeType.length === 0) { - return "webm"; - } - const slashIndex = mimeType.indexOf("/"); - let formatPart = slashIndex >= 0 ? mimeType.slice(slashIndex + 1) : mimeType; - const semicolonIndex = formatPart.indexOf(";"); - if (semicolonIndex >= 0) { - formatPart = formatPart.slice(0, semicolonIndex); - } - return formatPart.trim().length > 0 ? formatPart.trim() : "webm"; -}; - -const buildCapturedAudioPayload = (blob: Blob, durationSeconds: number): CapturedAudioPayload => ({ - blob, - format: deriveFormatFromMime(blob.type), - sizeBytes: typeof blob.size === "number" ? blob.size : 0, - durationSeconds, - recordedAt: Date.now(), -}); +import { + DURATION_TICK_MS, + PCM_DICTATION_FORMAT, + toError, + type DictationStatus, + type UseDictationOptions, + type UseDictationResult, +} from "./use-dictation.shared"; export function useDictation(options: UseDictationOptions): UseDictationResult { const { client, onTranscript, onError, - onRetryAttempt, onPermanentFailure, canStart, canConfirm, @@ -164,127 +27,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { const [isRecording, setIsRecording] = useState(false); const [isProcessing, setIsProcessing] = useState(false); - const [volume, setVolume] = useState(0); const [duration, setDuration] = useState(0); - const [pendingRequestId, setPendingRequestId] = useState(null); const [error, setError] = useState(null); const [status, setStatus] = useState("idle"); - const [retryAttempt, setRetryAttempt] = useState(0); - const [retryInfo, setRetryInfo] = useState(null); - const [failedRecording, setFailedRecording] = useState(null); - const [lastOutcome, setLastOutcome] = useState(null); - const maxRetryAttempts = MAX_AUTO_RETRY_ATTEMPTS; - - const transcriptionMutation = useMutation({ - mutationFn: async (): Promise => { - const capturedAudio = pendingAudioRef.current; - if (!capturedAudio) { - throw new Error("No recorded audio available for transcription"); - } - - setRetryAttempt(1); - setRetryInfo(null); - - let attempt = 1; - - while (attempt <= MAX_AUTO_RETRY_ATTEMPTS) { - try { - if (!client) { - throw new DictationAttemptError( - "disconnected", - new Error("Daemon client unavailable") - ); - } - if (!client.isConnected) { - throw new DictationAttemptError( - "disconnected", - new Error("Daemon client is disconnected") - ); - } - - console.info("[useDictation] sending transcription request", { - attempt, - size: capturedAudio.sizeBytes, - durationSeconds: capturedAudio.durationSeconds, - }); - - setStatus("uploading"); - setRetryAttempt(attempt); - - try { - const base64Audio = await blobToBase64(capturedAudio.blob); - return await client.transcribeAudio({ - audio: base64Audio, - format: capturedAudio.format, - timeout: TRANSCRIPTION_TIMEOUT_MS, - }); - } catch (error) { - const normalized = toError(error); - const reason: DictationRetryReason = isTimeoutError(normalized) - ? "timeout" - : "response"; - throw new DictationAttemptError(reason, normalized); - } - } catch (error) { - const attemptError = - error instanceof DictationAttemptError - ? error - : new DictationAttemptError("response", toError(error)); - - if (attempt >= MAX_AUTO_RETRY_ATTEMPTS) { - throw attemptError; - } - - const nextAttempt = attempt + 1; - const delayMs = computeRetryDelayMs(nextAttempt); - const info: DictationRetryInfo = { - attempt: nextAttempt, - maxAttempts: MAX_AUTO_RETRY_ATTEMPTS, - reason: attemptError.reason, - errorMessage: attemptError.message, - nextRetryMs: delayMs, - }; - - setStatus("retrying"); - setRetryAttempt(nextAttempt); - setRetryInfo(info); - onRetryAttemptRef.current?.(info); - console.warn("[useDictation] retry scheduled", { - attempt: nextAttempt, - maxAttempts: MAX_AUTO_RETRY_ATTEMPTS, - reason: attemptError.reason, - error: attemptError.message, - nextRetryMs: delayMs, - }); - - await sleep(delayMs); - attempt = nextAttempt; - } - } - - throw new Error("Failed to complete transcription"); - }, - }); - const { - mutateAsync: runTranscription, - reset: resetTranscriptionMutation, - } = transcriptionMutation; - - const pendingAudioRef = useRef(null); - const handleAudioLevel = useCallback((level: number) => { - setVolume(level); - }, []); - - const durationRef = useRef(0); - useEffect(() => { - durationRef.current = duration; - }, [duration]); - - const recorder = useAudioRecorder({ onAudioLevel: handleAudioLevel }); - const recorderRef = useRef(recorder); - useEffect(() => { - recorderRef.current = recorder; - }, [recorder]); const onTranscriptRef = useRef(onTranscript); useEffect(() => { @@ -296,11 +41,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { onErrorRef.current = onError; }, [onError]); - const onRetryAttemptRef = useRef(onRetryAttempt); - useEffect(() => { - onRetryAttemptRef.current = onRetryAttempt; - }, [onRetryAttempt]); - const onPermanentFailureRef = useRef(onPermanentFailure); useEffect(() => { onPermanentFailureRef.current = onPermanentFailure; @@ -316,10 +56,27 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { isProcessingRef.current = isProcessing; }, [isProcessing]); - const pendingRequestIdRef = useRef(null); - const activeStopPromiseRef = useRef | null>(null); + // duration is used for UI only; no need to mirror into a ref. + const durationIntervalRef = useRef | null>(null); const attemptGuardRef = useRef(new AttemptGuard()); + const actionGateRef = useRef<{ starting: boolean; confirming: boolean; cancelling: boolean }>({ + starting: false, + confirming: false, + cancelling: false, + }); + + const senderRef = useRef(null); + if (!senderRef.current) { + senderRef.current = new DictationStreamSender({ + client, + format: PCM_DICTATION_FORMAT, + createDictationId: generateMessageId, + }); + } + useEffect(() => { + senderRef.current?.setClient(client); + }, [client]); const stopDurationTracking = useCallback(() => { if (durationIntervalRef.current) { @@ -330,7 +87,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { const startDurationTracking = useCallback(() => { if (!enableDuration) { - console.log("[useDictation] startDictation blocked by canStart()"); return; } if (durationIntervalRef.current) { @@ -351,6 +107,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { const reportError = useCallback( (err: unknown, context?: string) => { const normalized = toError(err); + if (normalized.name === "AttemptCancelledError") { + return; + } if (context) { console.error(`[useDictation] ${context}`, normalized); } else { @@ -362,136 +121,114 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { [setError] ); - const stopRecorder = useCallback(async (): Promise => { - if (activeStopPromiseRef.current) { - return activeStopPromiseRef.current; - } - const recorderInstance = recorderRef.current; - if (!recorderInstance) { - throw new Error("Recorder unavailable"); - } - const stopPromise = (async () => { - try { - return await recorderInstance.stop(); - } finally { - activeStopPromiseRef.current = null; - } - })(); - activeStopPromiseRef.current = stopPromise; - return stopPromise; + const clearStreamingState = useCallback(() => { + senderRef.current?.clearAll(); }, []); - const transmitDictation = useCallback( - async () => runTranscription(), - [runTranscription] + const startNewStream = useCallback( + async (reason: string) => { + await senderRef.current?.restartStream(reason); + }, + [] ); - const handleTranscriptionSuccess = useCallback( - (transcription: TranscriptionPayload) => { - const requestId = transcription.requestId; - pendingRequestIdRef.current = null; - setPendingRequestId(null); + const ensureFinalTranscript = useCallback( + async (finalSeq: number): Promise => { + const result = await senderRef.current!.finish(finalSeq); + return result.text; + }, + [] + ); + + useEffect(() => { + if (!client) { + return; + } + return client.subscribeConnectionStatus((next) => { + if (next.status !== "connected") { + return; + } + if (!isRecordingRef.current) { + return; + } + void startNewStream("reconnect"); + }); + }, [client, startNewStream]); + + const audio = useDictationAudioSource({ + onPcmSegment: (audioData) => { + senderRef.current?.enqueueSegment(audioData); + }, + onError: (err) => { + onErrorRef.current?.(err); + }, + }); + const audioStopRef = useRef(audio.stop); + useEffect(() => { + audioStopRef.current = audio.stop; + }, [audio.stop]); + + const handleStreamingTranscriptionSuccess = useCallback( + (text: string, requestId: string) => { setIsProcessing(false); setDuration(0); setStatus("idle"); - setRetryAttempt(0); - setRetryInfo(null); - setFailedRecording(null); - pendingAudioRef.current = null; - setLastOutcome({ type: "success", requestId, timestamp: Date.now() }); - const transcriptText = transcription.text?.trim(); + clearStreamingState(); + + const transcriptText = text.trim(); if (!transcriptText) { return; } - - console.log("[useDictation] transcription_result received", { - requestId, - textLength: transcriptText.length, - }); onTranscriptRef.current?.(transcriptText, { requestId }); }, - [onTranscriptRef] + [clearStreamingState] ); const handleDictationFailure = useCallback( (failure: unknown) => { const normalized = toError(failure); const failureId = generateMessageId(); - pendingRequestIdRef.current = null; - setPendingRequestId(null); setIsProcessing(false); isRecordingRef.current = false; setIsRecording(false); - setVolume(0); - setRetryInfo(null); - const capturedAudio = pendingAudioRef.current; - if (capturedAudio) { + if (senderRef.current?.hasSegments()) { setStatus("failed"); - setFailedRecording({ - requestId: failureId, - durationSeconds: capturedAudio.durationSeconds, - sizeBytes: capturedAudio.sizeBytes, - format: capturedAudio.format, - recordedAt: capturedAudio.recordedAt, - errorMessage: normalized.message, - }); onPermanentFailureRef.current?.(normalized, { requestId: failureId }); } else { setStatus("idle"); } - setRetryAttempt(0); - setLastOutcome({ - type: "failure", - requestId: failureId, - errorMessage: normalized.message, - timestamp: Date.now(), - }); reportError(normalized, "Failed to complete dictation"); }, - [onPermanentFailureRef, reportError] + [reportError] ); const startDictation = useCallback(async () => { - console.log("[useDictation] startDictation requested", { - isRecording: isRecordingRef.current, - isProcessing, - }); - if (isRecordingRef.current || isProcessing) { - console.log("[useDictation] startDictation aborted: already recording/processing", { - isRecording: isRecordingRef.current, - isProcessing, - }); + if (actionGateRef.current.starting || actionGateRef.current.confirming || actionGateRef.current.cancelling) { + return; + } + if (isRecordingRef.current || isProcessingRef.current) { return; } const startAllowed = canStart ? canStart() : true; if (!startAllowed) { - console.log("[useDictation] startDictation blocked by canStart()"); return; } + actionGateRef.current.starting = true; setError(null); - setVolume(0); setDuration(0); setIsProcessing(false); setStatus("recording"); - setRetryAttempt(0); - setRetryInfo(null); - setFailedRecording(null); - pendingAudioRef.current = null; - setLastOutcome(null); - pendingRequestIdRef.current = null; - setPendingRequestId(null); + clearStreamingState(); try { - const recorderInstance = recorderRef.current; - if (!recorderInstance) { - throw new Error("Recorder unavailable"); + await audio.start(); + if (client?.isConnected) { + void startNewStream("start"); } - await recorderInstance.start(); - console.log("[useDictation] recorder.start succeeded"); isRecordingRef.current = true; setIsRecording(true); if (enableDuration) { @@ -502,160 +239,155 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { isRecordingRef.current = false; setIsRecording(false); reportError(err, "Failed to start dictation"); + } finally { + actionGateRef.current.starting = false; } }, [ + audio, canStart, + clearStreamingState, + client, enableDuration, isProcessing, reportError, startDurationTracking, + startNewStream, stopDurationTracking, ]); const cancelDictation = useCallback(async () => { - console.log("[useDictation] cancelDictation requested", { - isRecording: isRecordingRef.current, - hasActiveStop: Boolean(activeStopPromiseRef.current), - }); attemptGuardRef.current.cancel(); - if (!isRecordingRef.current && !activeStopPromiseRef.current) { - console.log("[useDictation] cancelDictation ignored: nothing to cancel"); + if (actionGateRef.current.cancelling) { return; } + if (!isRecordingRef.current && !isProcessingRef.current) { + return; + } + actionGateRef.current.cancelling = true; stopDurationTracking(); setDuration(0); setError(null); try { - await stopRecorder(); + try { + senderRef.current?.cancel(); + } catch { + // no-op + } + await audio.stop(); } catch (err) { reportError(err, "Failed to cancel dictation"); } finally { isRecordingRef.current = false; setIsRecording(false); setIsProcessing(false); - setVolume(0); + isProcessingRef.current = false; setStatus("idle"); - setRetryAttempt(0); - setRetryInfo(null); - pendingAudioRef.current = null; - setFailedRecording(null); - setLastOutcome(null); + clearStreamingState(); + actionGateRef.current.cancelling = false; } - }, [reportError, stopDurationTracking, stopRecorder]); + }, [audio, clearStreamingState, client, reportError, stopDurationTracking]); const confirmDictation = useCallback(async () => { - console.log("[useDictation] confirmDictation requested", { - isRecording: isRecordingRef.current, - isProcessing, - }); - if (!isRecordingRef.current || isProcessing) { - console.log("[useDictation] confirmDictation ignored: recording flag", { - isRecording: isRecordingRef.current, - isProcessing, - }); + if (actionGateRef.current.confirming) { + return; + } + if (!isRecordingRef.current || isProcessingRef.current) { return; } const confirmAllowed = canConfirm ? canConfirm() : true; if (!confirmAllowed) { - console.log("[useDictation] confirmDictation blocked by canConfirm()"); return; } + actionGateRef.current.confirming = true; setError(null); stopDurationTracking(); setIsProcessing(true); - setRetryInfo(null); - setRetryAttempt(0); - setLastOutcome(null); + isProcessingRef.current = true; const attemptId = attemptGuardRef.current.next(); try { - const audioData = await stopRecorder(); + await audio.stop(); attemptGuardRef.current.assertCurrent(attemptId); - const recordedDurationSeconds = durationRef.current; - pendingAudioRef.current = buildCapturedAudioPayload(audioData, recordedDurationSeconds); + setStatus("uploading"); isRecordingRef.current = false; setIsRecording(false); - setVolume(0); - const transcription = await transmitDictation(); + const finalSeq = senderRef.current?.getFinalSeq() ?? -1; + if (finalSeq < 0) { + handleStreamingTranscriptionSuccess("", generateMessageId()); + return; + } + + const transcriptText = await ensureFinalTranscript(finalSeq); attemptGuardRef.current.assertCurrent(attemptId); - handleTranscriptionSuccess(transcription); + handleStreamingTranscriptionSuccess(transcriptText, generateMessageId()); } catch (err) { - resetTranscriptionMutation(); + if (err instanceof Error && err.name === "AttemptCancelledError") { + return; + } handleDictationFailure(err); + } finally { + actionGateRef.current.confirming = false; } }, [ + audio, canConfirm, isProcessing, handleDictationFailure, - handleTranscriptionSuccess, - resetTranscriptionMutation, + handleStreamingTranscriptionSuccess, stopDurationTracking, - stopRecorder, - transmitDictation, + ensureFinalTranscript, ]); const retryFailedDictation = useCallback(async () => { - if (!pendingAudioRef.current) { + if (!senderRef.current?.hasSegments()) { return; } setError(null); - setRetryInfo(null); - setRetryAttempt(0); setStatus("uploading"); setIsProcessing(true); - setLastOutcome(null); + isProcessingRef.current = true; try { - const transcription = await transmitDictation(); - handleTranscriptionSuccess(transcription); + if (!client?.isConnected) { + throw new Error("Daemon client is disconnected"); + } + senderRef.current.resetStreamForReplay(); + const finalSeq = senderRef.current.getFinalSeq(); + const text = await ensureFinalTranscript(finalSeq); + handleStreamingTranscriptionSuccess(text, generateMessageId()); } catch (err) { - resetTranscriptionMutation(); + if (err instanceof Error && err.name === "AttemptCancelledError") { + return; + } handleDictationFailure(err); } - }, [ - handleDictationFailure, - handleTranscriptionSuccess, - resetTranscriptionMutation, - transmitDictation, - ]); + }, [client, ensureFinalTranscript, handleDictationFailure, handleStreamingTranscriptionSuccess]); const discardFailedDictation = useCallback(() => { - pendingAudioRef.current = null; - pendingRequestIdRef.current = null; - setPendingRequestId(null); setIsProcessing(false); + isProcessingRef.current = false; setDuration(0); - setFailedRecording(null); setStatus("idle"); - setRetryAttempt(0); - setRetryInfo(null); setError(null); - setLastOutcome(null); - }, []); + clearStreamingState(); + }, [clearStreamingState]); const reset = useCallback(() => { - pendingRequestIdRef.current = null; - setPendingRequestId(null); setIsRecording(false); isRecordingRef.current = false; setIsProcessing(false); + isProcessingRef.current = false; stopDurationTracking(); setDuration(0); - setVolume(0); setError(null); setStatus("idle"); - setRetryAttempt(0); - setRetryInfo(null); - setFailedRecording(null); - pendingAudioRef.current = null; - setLastOutcome(null); - resetTranscriptionMutation(); - }, [resetTranscriptionMutation, stopDurationTracking]); + clearStreamingState(); + }, [clearStreamingState, stopDurationTracking]); const cancelRef = useRef<(() => void) | null>(null); useEffect(() => { @@ -668,8 +400,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null ); useEffect(() => { - const nextVisible = - typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null; + const nextVisible = typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null; const prevVisible = visibilityRef.current; visibilityRef.current = nextVisible; @@ -685,48 +416,28 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { stopDurationTracking(); setDuration(0); setIsProcessing(false); - setVolume(0); setError(null); setStatus("idle"); - setRetryAttempt(0); - setRetryInfo(null); - pendingAudioRef.current = null; - setFailedRecording(null); - setLastOutcome(null); + clearStreamingState(); } } - }, [autoStopWhenHidden?.isVisible, stopDurationTracking]); - + }, [autoStopWhenHidden?.isVisible, clearStreamingState, stopDurationTracking]); useEffect(() => { return () => { attemptGuardRef.current.cancel(); stopDurationTracking(); - const activeStop = activeStopPromiseRef.current; - if (activeStop) { - void activeStop.catch(() => undefined); - return; - } - const recorderInstance = recorderRef.current; - if (recorderInstance?.isRecording?.()) { - void recorderInstance.stop().catch(() => undefined); - } + void audioStopRef.current().catch(() => undefined); }; }, [stopDurationTracking]); return { isRecording, isProcessing, - volume, + volume: audio.volume, duration, - pendingRequestId, error, status, - retryAttempt, - maxRetryAttempts, - retryInfo, - failedRecording, - lastOutcome, startDictation, cancelDictation, confirmDictation, @@ -735,3 +446,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { reset, }; } + +export type { + DictationStatus, + UseDictationOptions, + UseDictationResult, +} from "./use-dictation.shared"; diff --git a/packages/app/src/hooks/use-speechmatics-audio.native.ts b/packages/app/src/hooks/use-speechmatics-audio.native.ts index 57afe3589..6bdc9fad6 100644 --- a/packages/app/src/hooks/use-speechmatics-audio.native.ts +++ b/packages/app/src/hooks/use-speechmatics-audio.native.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { Buffer } from "buffer"; import { initialize, useMicrophonePermissions, @@ -14,6 +15,8 @@ export interface SpeechmaticsAudioConfig { onSpeechStart?: () => void; onSpeechEnd?: () => void; onError?: (error: Error) => void; + /** When true, stream microphone PCM continuously without VAD gating. */ + enableContinuousStreaming?: boolean; volumeThreshold: number; // Volume threshold for speech detection (0-1) silenceDuration: number; // ms of silence before ending segment speechConfirmationDuration: number; // ms of sustained speech before confirming @@ -33,11 +36,17 @@ export interface SpeechmaticsAudio { } function uint8ArrayToBase64(bytes: Uint8Array): string { - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); + // NOTE: This is performance-sensitive during continuous streaming. + // Buffer-backed base64 is significantly faster than manual string building. + try { + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64"); + } catch { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); } - return btoa(binary); } function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array { @@ -67,6 +76,8 @@ export function useSpeechmaticsAudio( const [isMuted, setIsMuted] = useState(false); const [segmentDuration, setSegmentDuration] = useState(0); + const enableContinuousStreaming = config.enableContinuousStreaming === true; + const audioBufferRef = useRef([]); const silenceStartRef = useRef(null); const isSpeakingRef = useRef(false); @@ -136,6 +147,15 @@ export function useSpeechmaticsAudio( const pcmData: Uint8Array = event.data; + if (enableContinuousStreaming) { + audioBufferRef.current.push(pcmData); + bufferedBytesRef.current += pcmData.length; + if (bufferedBytesRef.current >= MIN_CHUNK_BYTES) { + flushBufferedAudio(false); + } + return; + } + // Buffer the audio chunk if we're detecting or speaking // Start buffering from first spike to capture beginning of speech if (speechDetectionStartRef.current !== null || isSpeakingRef.current) { @@ -150,7 +170,7 @@ export function useSpeechmaticsAudio( } } }, - [isActive, isMuted, flushBufferedAudio, MIN_CHUNK_BYTES] + [enableContinuousStreaming, isActive, isMuted, flushBufferedAudio, MIN_CHUNK_BYTES] ) ); @@ -165,6 +185,7 @@ export function useSpeechmaticsAudio( setVolume(volumeLevel); if (isMuted) return; + if (enableContinuousStreaming) return; const speechDetected = volumeLevel > VOLUME_THRESHOLD; @@ -270,6 +291,7 @@ export function useSpeechmaticsAudio( } }, [ + enableContinuousStreaming, isActive, isMuted, VOLUME_THRESHOLD, @@ -341,6 +363,10 @@ export function useSpeechmaticsAudio( toggleRecording(false); } + if (enableContinuousStreaming) { + flushBufferedAudio(true); + } + // Tear down audio session if (audioInitialized) { tearDown(); diff --git a/packages/app/src/hooks/use-speechmatics-audio.web.ts b/packages/app/src/hooks/use-speechmatics-audio.web.ts index 2d62884b7..320e83b66 100644 --- a/packages/app/src/hooks/use-speechmatics-audio.web.ts +++ b/packages/app/src/hooks/use-speechmatics-audio.web.ts @@ -5,6 +5,7 @@ export interface SpeechmaticsAudioConfig { onSpeechStart?: () => void; onSpeechEnd?: () => void; onError?: (error: Error) => void; + enableContinuousStreaming?: boolean; volumeThreshold: number; silenceDuration: number; speechConfirmationDuration: number; diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 81916a34e..a4ac5588d 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -42,7 +42,6 @@ import type { SendAgentMessage, SessionInboundMessage, SessionOutboundMessage, - TranscriptionResultMessage, } from "../shared/messages.js"; import type { AgentPermissionRequest, @@ -150,12 +149,6 @@ export type DaemonClientV2Config = { export type SendMessageOptions = Pick; -export type TranscribeAudioOptions = { - audio: string; // base64 encoded - format: string; - timeout?: number; -}; - type AgentConfigOverrides = Partial>; export type CreateAgentRequestOptions = { @@ -188,7 +181,6 @@ type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ExecuteCommandPayload = ExecuteCommandResponse["payload"]; -type TranscriptionResultPayload = TranscriptionResultMessage["payload"]; type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; @@ -472,7 +464,26 @@ export class DaemonClientV2 { throw new Error("Transport not connected"); } const payload = SessionInboundMessageSchema.parse(message); - this.transport.send(JSON.stringify({ type: "session", message: payload })); + try { + this.transport.send(JSON.stringify({ type: "session", message: payload })); + } catch (error) { + if (this.config.suppressSendErrors) { + return; + } + throw error instanceof Error ? error : new Error(String(error)); + } + } + + private sendSessionMessageStrict(message: SessionInboundMessage): void { + if (!this.transport || this.connectionState.status !== "connected") { + throw new Error("Transport not connected"); + } + const payload = SessionInboundMessageSchema.parse(message); + try { + this.transport.send(JSON.stringify({ type: "session", message: payload })); + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)); + } } sendUserMessage(text: string): void { @@ -839,37 +850,6 @@ export class DaemonClientV2 { await this.sendAgentMessage(agentId, text, options); } - async transcribeAudio( - options: TranscribeAudioOptions - ): Promise { - const requestId = this.createRequestId(); - const timeout = options.timeout ?? 120000; - - const responsePromise = this.waitFor( - (msg) => { - if (msg.type !== "transcription_result") { - return null; - } - if (msg.payload.requestId !== requestId) { - return null; - } - return msg.payload; - }, - timeout, - { skipQueue: true } - ); - - const message = SessionInboundMessageSchema.parse({ - type: "transcribe_audio_request", - audio: options.audio, - format: options.format, - requestId, - }); - this.sendSessionMessage(message); - - return responsePromise; - } - async cancelAgent(agentId: string): Promise { this.sendSessionMessage({ type: "cancel_agent_request", agentId }); } @@ -929,6 +909,87 @@ export class DaemonClientV2 { this.sendSessionMessage({ type: "realtime_audio_chunk", audio, format, isLast }); } + startDictationStream(dictationId: string, format: string): Promise { + const ackPromise = this.waitFor( + (msg) => { + if (msg.type !== "dictation_stream_ack") { + return null; + } + if (msg.payload.dictationId !== dictationId) { + return null; + } + if (msg.payload.ackSeq !== -1) { + return null; + } + return msg.payload; + }, + 30000, + { skipQueue: true } + ).then(() => undefined); + + const errorPromise = this.waitFor( + (msg) => { + if (msg.type !== "dictation_stream_error") { + return null; + } + if (msg.payload.dictationId !== dictationId) { + return null; + } + return msg.payload; + }, + 30000, + { skipQueue: true } + ).then((payload) => { + throw new Error(payload.error); + }); + + this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format }); + return Promise.race([ackPromise, errorPromise]); + } + + sendDictationStreamChunk(dictationId: string, seq: number, audio: string, format: string): void { + this.sendSessionMessageStrict({ type: "dictation_stream_chunk", dictationId, seq, audio, format }); + } + + finishDictationStream(dictationId: string, finalSeq: number): Promise<{ dictationId: string; text: string }> { + const finalPromise = this.waitFor( + (msg) => { + if (msg.type !== "dictation_stream_final") { + return null; + } + if (msg.payload.dictationId !== dictationId) { + return null; + } + return msg.payload; + }, + 120000, + { skipQueue: true } + ); + + const errorPromise = this.waitFor( + (msg) => { + if (msg.type !== "dictation_stream_error") { + return null; + } + if (msg.payload.dictationId !== dictationId) { + return null; + } + return msg.payload; + }, + 120000, + { skipQueue: true } + ).then((payload) => { + throw new Error(payload.error); + }); + + this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq }); + return Promise.race([finalPromise, errorPromise]); + } + + cancelDictationStream(dictationId: string): void { + this.sendSessionMessageStrict({ type: "dictation_stream_cancel", dictationId }); + } + async abortRequest(): Promise { this.sendSessionMessage({ type: "abort_request" }); } @@ -2097,7 +2158,12 @@ function createWebSocketTransportFactory( return ({ url, headers }) => { const ws = factory(url, { headers }); return { - send: (data) => ws.send(data), + send: (data) => { + if (typeof ws.readyState === "number" && ws.readyState !== 1) { + throw new Error(`WebSocket not open (readyState=${ws.readyState})`); + } + ws.send(data); + }, close: (code?: number, reason?: string) => ws.close(code, reason), onOpen: (handler) => bindWsHandler(ws, "open", handler), onClose: (handler) => bindWsHandler(ws, "close", handler), diff --git a/packages/server/src/server/agent/openai-realtime-transcription.ts b/packages/server/src/server/agent/openai-realtime-transcription.ts new file mode 100644 index 000000000..9de64ed80 --- /dev/null +++ b/packages/server/src/server/agent/openai-realtime-transcription.ts @@ -0,0 +1,207 @@ +import type pino from "pino"; +import WebSocket from "ws"; +import { EventEmitter } from "node:events"; + +type OpenAIClientEvent = + | { + type: "session.update"; + session: { + type: "transcription"; + audio: { + input: { + format: { type: "audio/pcm"; rate: 24000 }; + transcription: { + model: string; + language?: string; + prompt?: string; + }; + turn_detection: null; + }; + }; + }; + } + | { type: "input_audio_buffer.append"; audio: string } + | { type: "input_audio_buffer.commit" } + | { type: "input_audio_buffer.clear" }; + +type OpenAIServerEvent = + | { type: "session.created" | "session.updated" } + | { + type: "input_audio_buffer.committed"; + item_id: string; + previous_item_id: string | null; + } + | { + type: "conversation.item.input_audio_transcription.completed"; + item_id: string; + transcript: string; + } + | { type: "error"; error?: { message?: string } }; + +export class OpenAIRealtimeTranscriptionSession extends EventEmitter { + private readonly apiKey: string; + private readonly logger: pino.Logger; + private readonly transcriptionModel: string; + private readonly language?: string; + + private ws: WebSocket | null = null; + private ready: Promise | null = null; + private closing = false; + + constructor(params: { + apiKey: string; + logger: pino.Logger; + transcriptionModel: string; + language?: string; + }) { + super(); + this.apiKey = params.apiKey; + this.logger = params.logger.child({ provider: "openai", component: "realtime-transcription" }); + this.transcriptionModel = params.transcriptionModel; + this.language = params.language; + } + + public async connect(): Promise { + if (this.ready) { + return this.ready; + } + + this.closing = false; + this.ready = new Promise((resolve, reject) => { + const url = "wss://api.openai.com/v1/realtime?intent=transcription"; + const ws = new WebSocket(url, { + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + }); + this.ws = ws; + + let resolved = false; + + const fail = (error: Error) => { + if (resolved) { + this.emit("error", error); + return; + } + resolved = true; + reject(error); + }; + + ws.on("open", () => { + this.logger.debug("OpenAI realtime transcription websocket connected"); + const update: OpenAIClientEvent = { + type: "session.update", + session: { + type: "transcription", + audio: { + input: { + format: { type: "audio/pcm", rate: 24000 }, + transcription: { + model: this.transcriptionModel, + ...(this.language ? { language: this.language } : {}), + }, + // We commit periodically ourselves; no server-side VAD for dictation. + turn_detection: null, + }, + }, + }, + }; + ws.send(JSON.stringify(update)); + }); + + ws.on("message", (data) => { + const text = typeof data === "string" ? data : data.toString("utf-8"); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return; + } + + const event = parsed as OpenAIServerEvent; + if ( + event.type === "session.created" || + event.type === "session.updated" + ) { + if (!resolved) { + resolved = true; + resolve(); + } + return; + } + + if (event.type === "input_audio_buffer.committed") { + this.emit("committed", { + itemId: event.item_id, + previousItemId: event.previous_item_id, + }); + return; + } + + if (event.type === "conversation.item.input_audio_transcription.completed") { + this.emit("transcript", { itemId: event.item_id, transcript: event.transcript }); + return; + } + + if (event.type === "error") { + const message = event.error?.message ?? "OpenAI realtime error"; + fail(new Error(message)); + } + }); + + ws.on("error", (err) => { + fail(err instanceof Error ? err : new Error(String(err))); + }); + + ws.on("close", () => { + this.logger.debug("OpenAI realtime websocket closed"); + if (this.closing) { + return; + } + if (!resolved) { + fail(new Error("OpenAI realtime websocket closed before ready")); + return; + } + fail(new Error("OpenAI realtime websocket closed")); + }); + }); + + return this.ready; + } + + public appendPcm16Base64(base64Audio: string): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("OpenAI realtime websocket not connected"); + } + const event: OpenAIClientEvent = { type: "input_audio_buffer.append", audio: base64Audio }; + this.ws.send(JSON.stringify(event)); + } + + public commit(): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("OpenAI realtime websocket not connected"); + } + const event: OpenAIClientEvent = { type: "input_audio_buffer.commit" }; + this.ws.send(JSON.stringify(event)); + } + + public clear(): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return; + } + const event: OpenAIClientEvent = { type: "input_audio_buffer.clear" }; + this.ws.send(JSON.stringify(event)); + } + + public close(): void { + try { + this.closing = true; + this.ws?.close(); + } catch { + // no-op + } finally { + this.ws = null; + this.ready = null; + } + } +} diff --git a/packages/server/src/server/agent/pcm16-resampler.ts b/packages/server/src/server/agent/pcm16-resampler.ts new file mode 100644 index 000000000..24cb10862 --- /dev/null +++ b/packages/server/src/server/agent/pcm16-resampler.ts @@ -0,0 +1,83 @@ +export class Pcm16MonoResampler { + private readonly inputRate: number; + private readonly outputRate: number; + private readonly step: number; + private pos: number; + private carrySample: number | null; + + constructor(params: { inputRate: number; outputRate: number }) { + this.inputRate = params.inputRate; + this.outputRate = params.outputRate; + this.step = this.inputRate / this.outputRate; + this.pos = 0; + this.carrySample = null; + } + + public reset(): void { + this.pos = 0; + this.carrySample = null; + } + + public processChunk(pcm16le: Buffer): Buffer { + if (pcm16le.length === 0) { + return Buffer.alloc(0); + } + if (pcm16le.length % 2 !== 0) { + throw new Error(`PCM16 chunk byteLength must be even, got ${pcm16le.length}`); + } + + const srcChunk = new Int16Array( + pcm16le.buffer, + pcm16le.byteOffset, + pcm16le.byteLength / 2 + ); + + const hasCarry = this.carrySample !== null; + const srcLen = srcChunk.length + (hasCarry ? 1 : 0); + if (srcLen < 2) { + this.carrySample = srcChunk.length ? srcChunk[srcChunk.length - 1] : this.carrySample; + return Buffer.alloc(0); + } + + const src = new Float32Array(srcLen); + let offset = 0; + if (hasCarry) { + src[0] = (this.carrySample as number) / 32768; + offset = 1; + } + for (let i = 0; i < srcChunk.length; i += 1) { + src[offset + i] = srcChunk[i] / 32768; + } + + const out: number[] = []; + const maxPos = src.length - 1; + + while (this.pos < maxPos) { + const i = Math.floor(this.pos); + const frac = this.pos - i; + const s0 = src[i]!; + const s1 = src[i + 1]!; + const sample = s0 + (s1 - s0) * frac; + const clamped = Math.max(-1, Math.min(1, sample)); + const int16 = Math.round(clamped * 32767); + out.push(int16); + this.pos += this.step; + } + + // Keep the last input sample as carry for the next chunk. + const lastInput = srcChunk[srcChunk.length - 1]!; + this.carrySample = lastInput; + + // Shift position so next chunk (which will include carry sample) continues smoothly. + const shift = src.length - 1; + this.pos = this.pos - shift; + if (this.pos < 0) { + // Guard against floating point drift. + this.pos = 0; + } + + const outArr = Int16Array.from(out); + return Buffer.from(outArr.buffer, outArr.byteOffset, outArr.byteLength); + } +} + diff --git a/packages/server/src/server/daemon-client-v2.e2e.test.ts b/packages/server/src/server/daemon-client-v2.e2e.test.ts index 1545a935c..4dc135f4c 100644 --- a/packages/server/src/server/daemon-client-v2.e2e.test.ts +++ b/packages/server/src/server/daemon-client-v2.e2e.test.ts @@ -23,6 +23,9 @@ if (envPath) { dotenv.config({ path: envPath }); } +// Make dictation streaming commit frequently in tests so we exercise multi-commit assembly logic. +process.env.OPENAI_REALTIME_DICTATION_COMMIT_MS ??= "1000"; + function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-")); } @@ -630,6 +633,216 @@ describe("daemon client v2 E2E", () => { 180000 ); + test( + "streams dictation PCM and returns final transcript via OpenAI Realtime transcription", + async () => { + if (process.env.PASEO_E2E_DICTATION_REALTIME !== "1") { + // Requires OpenAI realtime transcription and is inherently network-flaky. + return; + } + if (!process.env.OPENAI_API_KEY) { + return; + } + + const fixturePath = path.resolve( + process.cwd(), + "..", + "app", + "e2e", + "fixtures", + "recording.wav" + ); + const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath)); + + const parsePcm16MonoWav = (buffer: Buffer): Buffer => { + if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") { + throw new Error("Invalid WAV header"); + } + let offset = 12; + let fmt: { audioFormat: number; channels: number; sampleRate: number; bitsPerSample: number } | null = null; + let dataChunk: Buffer | null = null; + + while (offset + 8 <= buffer.length) { + const id = buffer.toString("ascii", offset, offset + 4); + const size = buffer.readUInt32LE(offset + 4); + const payloadStart = offset + 8; + const payloadEnd = payloadStart + size; + if (payloadEnd > buffer.length) { + break; + } + + if (id === "fmt ") { + const audioFormat = buffer.readUInt16LE(payloadStart); + const channels = buffer.readUInt16LE(payloadStart + 2); + const sampleRate = buffer.readUInt32LE(payloadStart + 4); + const bitsPerSample = buffer.readUInt16LE(payloadStart + 14); + fmt = { audioFormat, channels, sampleRate, bitsPerSample }; + } else if (id === "data") { + dataChunk = buffer.subarray(payloadStart, payloadEnd); + } + + offset = payloadEnd + (size % 2); + } + + if (!fmt || !dataChunk) { + throw new Error("Missing WAV fmt/data chunks"); + } + if (fmt.audioFormat !== 1) { + throw new Error(`Unsupported WAV encoding (audioFormat=${fmt.audioFormat})`); + } + if (fmt.channels !== 1 || fmt.sampleRate !== 16000 || fmt.bitsPerSample !== 16) { + throw new Error( + `Unexpected WAV format: channels=${fmt.channels} rate=${fmt.sampleRate} bits=${fmt.bitsPerSample}` + ); + } + if (dataChunk.length % 2 !== 0) { + throw new Error("WAV PCM16 data length must be even"); + } + return dataChunk; + }; + + const pcm16 = parsePcm16MonoWav(wav); + const dictationId = `dict-${Date.now()}`; + const format = "audio/pcm;rate=16000;bits=16"; + + await ctx.client.startDictationStream(dictationId, format); + + const chunkBytes = 3200; // ~100ms @ 16kHz mono PCM16 (1600 samples * 2 bytes) + let seq = 0; + for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { + const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); + ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format); + seq += 1; + } + + const finalSeq = seq - 1; + const result = await ctx.client.finishDictationStream(dictationId, finalSeq); + + expect(result.dictationId).toBe(dictationId); + expect(result.text.toLowerCase()).toContain("voice note"); + }, + 180000 + ); + + test( + "does not commit an empty OpenAI realtime audio buffer on finish", + async () => { + if (process.env.PASEO_E2E_DICTATION_REALTIME !== "1") { + return; + } + if (!process.env.OPENAI_API_KEY) { + return; + } + + const fixturePath = path.resolve( + process.cwd(), + "..", + "app", + "e2e", + "fixtures", + "recording.wav" + ); + const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath)); + + const parsePcm16MonoWav = (buffer: Buffer): Buffer => { + if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") { + throw new Error("Invalid WAV header"); + } + let offset = 12; + let fmt: { audioFormat: number; channels: number; sampleRate: number; bitsPerSample: number } | null = null; + let dataChunk: Buffer | null = null; + + while (offset + 8 <= buffer.length) { + const id = buffer.toString("ascii", offset, offset + 4); + const size = buffer.readUInt32LE(offset + 4); + const payloadStart = offset + 8; + const payloadEnd = payloadStart + size; + if (payloadEnd > buffer.length) { + break; + } + + if (id === "fmt ") { + const audioFormat = buffer.readUInt16LE(payloadStart); + const channels = buffer.readUInt16LE(payloadStart + 2); + const sampleRate = buffer.readUInt32LE(payloadStart + 4); + const bitsPerSample = buffer.readUInt16LE(payloadStart + 14); + fmt = { audioFormat, channels, sampleRate, bitsPerSample }; + } else if (id === "data") { + dataChunk = buffer.subarray(payloadStart, payloadEnd); + } + + offset = payloadEnd + (size % 2); + } + + if (!fmt || !dataChunk) { + throw new Error("Missing WAV fmt/data chunks"); + } + if (fmt.audioFormat !== 1) { + throw new Error(`Unsupported WAV encoding (audioFormat=${fmt.audioFormat})`); + } + if (fmt.channels !== 1 || fmt.sampleRate !== 16000 || fmt.bitsPerSample !== 16) { + throw new Error( + `Unexpected WAV format: channels=${fmt.channels} rate=${fmt.sampleRate} bits=${fmt.bitsPerSample}` + ); + } + if (dataChunk.length % 2 !== 0) { + throw new Error("WAV PCM16 data length must be even"); + } + return dataChunk; + }; + + const pcm16 = parsePcm16MonoWav(wav); + const dictationId = `dict-empty-commit-${Date.now()}`; + const format = "audio/pcm;rate=16000;bits=16"; + + await ctx.client.startDictationStream(dictationId, format); + + // Send exactly 10x 100ms chunks. With OPENAI_REALTIME_DICTATION_COMMIT_MS=1000 in this test file, + // the server will auto-commit at the 1s boundary. Finishing immediately after that should not + // attempt an additional empty commit. + const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16 + const targetChunks = 10; + let seq = 0; + for (let i = 0; i < targetChunks; i += 1) { + const start = i * chunkBytes; + const end = Math.min(pcm16.length, start + chunkBytes); + const chunk = pcm16.subarray(start, end); + ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format); + seq += 1; + } + + const finalSeq = seq - 1; + const result = await ctx.client.finishDictationStream(dictationId, finalSeq); + + expect(result.dictationId).toBe(dictationId); + expect(typeof result.text).toBe("string"); + }, + 180000 + ); + + test( + "fails fast if dictation finishes without sending required chunks", + async () => { + if (process.env.PASEO_E2E_DICTATION_REALTIME !== "1") { + return; + } + if (!process.env.OPENAI_API_KEY) { + return; + } + + const dictationId = `dict-missing-chunks-${Date.now()}`; + const format = "audio/pcm;rate=16000;bits=16"; + + await ctx.client.startDictationStream(dictationId, format); + + // Claim that we sent chunk 0, but actually send no chunks. + await expect(ctx.client.finishDictationStream(dictationId, 0)).rejects.toThrow( + /no audio chunks were received/i + ); + }, + 180000 + ); + test( "supports git and file operations", async () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ea7587612..200ef6d9a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -35,6 +35,8 @@ import { TTSManager } from "./agent/tts-manager.js"; import { STTManager } from "./agent/stt-manager.js"; import type { OpenAISTT } from "./agent/stt-openai.js"; import type { OpenAITTS } from "./agent/tts-openai.js"; +import { OpenAIRealtimeTranscriptionSession } from "./agent/openai-realtime-transcription.js"; +import { Pcm16MonoResampler } from "./agent/pcm16-resampler.js"; import type { VoiceConversationStore } from "./voice-conversation-store.js"; import { buildConfigOverrides, @@ -135,6 +137,21 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000; const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS ); + +const DICTATION_PCM_INPUT_RATE = 16000; +const DICTATION_PCM_OUTPUT_RATE = 24000; +const DICTATION_COMMIT_INTERVAL_MS = Number.parseInt( + process.env.OPENAI_REALTIME_DICTATION_COMMIT_MS ?? "5000", + 10 +); +const DICTATION_COMMIT_TARGET_BYTES = Math.round( + (DICTATION_PCM_OUTPUT_RATE * 2 * DICTATION_COMMIT_INTERVAL_MS) / 1000 +); +const DICTATION_MIN_COMMIT_MS = 100; +const DICTATION_MIN_COMMIT_BYTES = Math.round( + (DICTATION_PCM_OUTPUT_RATE * 2 * DICTATION_MIN_COMMIT_MS) / 1000 +); +const DICTATION_FINAL_TIMEOUT_MS = 120000; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; /** @@ -249,6 +266,27 @@ export class Session { private speechInProgress = false; private voiceConversationId: string | null = null; + private dictationStreams = new Map< + string, + { + dictationId: string; + inputFormat: string; + openai: OpenAIRealtimeTranscriptionSession; + resampler: Pcm16MonoResampler; + receivedChunks: Map; + nextSeqToForward: number; + ackSeq: number; + bytesSinceCommit: number; + expectedCommits: number; + committedCount: number; + committedItemIds: string[]; + transcriptsByItemId: Map; + finishRequested: boolean; + finalSeq: number | null; + finalTimeout: ReturnType | null; + } + >(); + // Audio buffering for interruption handling private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []; private bufferTimeout: NodeJS.Timeout | null = null; @@ -735,8 +773,20 @@ export class Session { ); break; - case "transcribe_audio_request": - await this.handleTranscribeAudio(msg); + case "dictation_stream_start": + await this.handleDictationStreamStart(msg); + break; + + case "dictation_stream_chunk": + await this.handleDictationStreamChunk(msg); + break; + + case "dictation_stream_finish": + await this.handleDictationStreamFinish(msg); + break; + + case "dictation_stream_cancel": + await this.handleDictationStreamCancel(msg); break; case "create_agent_request": @@ -1177,56 +1227,279 @@ export class Session { this.startAgentStream(agentId, prompt); } - /** - * Handle audio transcription request - */ - private async handleTranscribeAudio( - msg: Extract - ): Promise { - const { audio, format, requestId } = msg; + private emitDictationAck(dictationId: string, ackSeq: number): void { + this.emit({ + type: "dictation_stream_ack", + payload: { dictationId, ackSeq }, + }); + } - const audioBuffer = Buffer.from(audio, "base64"); + private failDictationStream(dictationId: string, error: string, retryable: boolean): void { + this.emit({ + type: "dictation_stream_error", + payload: { dictationId, error, retryable }, + }); + } - this.sessionLogger.debug({ requestId }, "Transcribing audio"); - - try { - const result = await this.sttManager.transcribe(audioBuffer, format, { - requestId, - label: "dictation", - }); - - this.sessionLogger.info( - { requestId, textLength: result.text.length }, - "Transcription complete" - ); - - this.emit({ - type: "transcription_result", - payload: { - text: result.text, - language: result.language, - duration: result.duration, - requestId, - avgLogprob: result.avgLogprob, - isLowConfidence: result.isLowConfidence, - byteLength: result.byteLength, - format: result.format, - debugRecordingPath: result.debugRecordingPath, - }, - }); - } catch (error: any) { - this.sessionLogger.error({ err: error, requestId }, "Transcription failed"); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Transcription failed: ${error.message}`, - }, - }); - throw error; + private cleanupDictationStream(dictationId: string): void { + const state = this.dictationStreams.get(dictationId) ?? null; + if (!state) { + return; } + if (state.finalTimeout) { + clearTimeout(state.finalTimeout); + } + try { + state.openai.close(); + } catch { + // no-op + } + this.dictationStreams.delete(dictationId); + } + + private maybeFinalizeDictationStream(dictationId: string): void { + const state = this.dictationStreams.get(dictationId); + if (!state) { + return; + } + + if (!state.finishRequested || state.finalSeq === null) { + return; + } + if (state.ackSeq < state.finalSeq) { + return; + } + if (state.committedCount < state.expectedCommits) { + return; + } + + const allTranscriptsReady = state.committedItemIds.every((itemId) => + state.transcriptsByItemId.has(itemId) + ); + if (!allTranscriptsReady) { + return; + } + + const orderedText = state.committedItemIds + .map((itemId) => state.transcriptsByItemId.get(itemId) ?? "") + .join(" ") + .trim(); + + this.emit({ + type: "dictation_stream_final", + payload: { dictationId, text: orderedText }, + }); + + this.cleanupDictationStream(dictationId); + } + + private async handleDictationStreamStart( + msg: Extract + ): Promise { + const dictationId = msg.dictationId; + this.cleanupDictationStream(dictationId); + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + this.failDictationStream(dictationId, "OPENAI_API_KEY not set", false); + return; + } + + const transcriptionModel = + process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe"; + + const openai = new OpenAIRealtimeTranscriptionSession({ + apiKey, + logger: this.sessionLogger.child({ dictationId }), + transcriptionModel, + language: "en", + }); + + openai.on("committed", ({ itemId }: { itemId: string }) => { + const state = this.dictationStreams.get(dictationId); + if (!state) { + return; + } + state.committedCount += 1; + state.committedItemIds.push(itemId); + this.maybeFinalizeDictationStream(dictationId); + }); + + openai.on("transcript", ({ itemId, transcript }: { itemId: string; transcript: string }) => { + const state = this.dictationStreams.get(dictationId); + if (!state) { + return; + } + state.transcriptsByItemId.set(itemId, transcript); + this.maybeFinalizeDictationStream(dictationId); + }); + + openai.on("error", (err) => { + const error = err instanceof Error ? err.message : String(err); + this.failDictationStream(dictationId, error, true); + this.cleanupDictationStream(dictationId); + }); + + await openai.connect(); + + this.dictationStreams.set(dictationId, { + dictationId, + inputFormat: msg.format, + openai, + resampler: new Pcm16MonoResampler({ + inputRate: DICTATION_PCM_INPUT_RATE, + outputRate: DICTATION_PCM_OUTPUT_RATE, + }), + receivedChunks: new Map(), + nextSeqToForward: 0, + ackSeq: -1, + bytesSinceCommit: 0, + expectedCommits: 0, + committedCount: 0, + committedItemIds: [], + transcriptsByItemId: new Map(), + finishRequested: false, + finalSeq: null, + finalTimeout: null, + }); + + this.emitDictationAck(dictationId, -1); + } + + private async handleDictationStreamChunk( + msg: Extract + ): Promise { + const state = this.dictationStreams.get(msg.dictationId); + if (!state) { + this.failDictationStream(msg.dictationId, "Dictation stream not started", true); + return; + } + + if (msg.format !== state.inputFormat) { + this.failDictationStream( + msg.dictationId, + `Mismatched dictation stream format: ${msg.format}`, + false + ); + return; + } + + if (msg.seq < state.nextSeqToForward) { + this.emitDictationAck(msg.dictationId, state.ackSeq); + return; + } + + if (!state.receivedChunks.has(msg.seq)) { + state.receivedChunks.set(msg.seq, Buffer.from(msg.audio, "base64")); + } + + while (state.receivedChunks.has(state.nextSeqToForward)) { + const seq = state.nextSeqToForward; + const pcm16 = state.receivedChunks.get(seq)!; + state.receivedChunks.delete(seq); + + const resampled = state.resampler.processChunk(pcm16); + if (resampled.length > 0) { + state.openai.appendPcm16Base64(resampled.toString("base64")); + state.bytesSinceCommit += resampled.length; + } + + state.nextSeqToForward += 1; + state.ackSeq = state.nextSeqToForward - 1; + + if (state.bytesSinceCommit >= DICTATION_COMMIT_TARGET_BYTES) { + state.expectedCommits += 1; + state.openai.commit(); + state.bytesSinceCommit = 0; + } + } + + this.emitDictationAck(msg.dictationId, state.ackSeq); + this.maybeFinalizeDictationStream(msg.dictationId); + } + + private async handleDictationStreamFinish( + msg: Extract + ): Promise { + const state = this.dictationStreams.get(msg.dictationId); + if (!state) { + this.failDictationStream(msg.dictationId, "Dictation stream not started", true); + return; + } + + state.finishRequested = true; + state.finalSeq = msg.finalSeq; + + // If the client claims it sent audio (finalSeq >= 0) but we haven't received any chunks at all, + // we should fail fast instead of hanging until DICTATION_FINAL_TIMEOUT_MS. + if (msg.finalSeq >= 0 && state.ackSeq < 0 && state.nextSeqToForward === 0 && state.receivedChunks.size === 0) { + this.sessionLogger.debug( + { + dictationId: msg.dictationId, + finalSeq: msg.finalSeq, + ackSeq: state.ackSeq, + nextSeqToForward: state.nextSeqToForward, + receivedChunks: state.receivedChunks.size, + expectedCommits: state.expectedCommits, + committedCount: state.committedCount, + }, + "Dictation finish: no chunks received (failing fast)" + ); + this.failDictationStream( + msg.dictationId, + `Dictation finished (finalSeq=${msg.finalSeq}) but no audio chunks were received`, + true + ); + this.cleanupDictationStream(msg.dictationId); + return; + } + + // Commit any remaining uncommitted audio. + // IMPORTANT: OpenAI requires at least ~100ms of audio in the buffer for a commit. + // Also, committing an empty buffer throws "buffer too small ... 0.00ms". + if (state.bytesSinceCommit > 0) { + this.sessionLogger.debug( + { dictationId: msg.dictationId, bytesSinceCommit: state.bytesSinceCommit }, + "Dictation finish: committing pending audio" + ); + if (state.bytesSinceCommit < DICTATION_MIN_COMMIT_BYTES) { + const padBytes = DICTATION_MIN_COMMIT_BYTES - state.bytesSinceCommit; + this.sessionLogger.debug( + { dictationId: msg.dictationId, padBytes }, + "Dictation finish: padding to minimum commit size" + ); + state.openai.appendPcm16Base64(Buffer.alloc(padBytes).toString("base64")); + state.bytesSinceCommit += padBytes; + } + state.expectedCommits += 1; + state.openai.commit(); + state.bytesSinceCommit = 0; + } else { + this.sessionLogger.debug( + { dictationId: msg.dictationId }, + "Dictation finish: no pending audio to commit" + ); + } + + if (state.finalTimeout) { + clearTimeout(state.finalTimeout); + } + state.finalTimeout = setTimeout(() => { + this.failDictationStream( + msg.dictationId, + "Timed out waiting for final transcription", + true + ); + this.cleanupDictationStream(msg.dictationId); + }, DICTATION_FINAL_TIMEOUT_MS); + + this.maybeFinalizeDictationStream(msg.dictationId); + } + + private async handleDictationStreamCancel( + msg: Extract + ): Promise { + this.cleanupDictationStream(msg.dictationId); } /** @@ -4209,6 +4482,10 @@ export class Session { this.ttsManager.cleanup(); this.sttManager.cleanup(); + for (const dictationId of this.dictationStreams.keys()) { + this.cleanupDictationStream(dictationId); + } + // Close MCP clients if (this.agentMcpClient) { try { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 79646f182..17afef7b0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -57,7 +57,13 @@ export class VoiceAssistantWebSocketServer { path: "/ws", verifyClient: ({ req }, callback) => { const origin = req.headers.origin; - if (!origin || allowedOrigins.has(origin)) { + const requestHost = typeof req.headers.host === "string" ? req.headers.host : null; + const sameOrigin = + !!origin && + !!requestHost && + (origin === `http://${requestHost}` || origin === `https://${requestHost}`); + + if (!origin || allowedOrigins.has(origin) || sameOrigin) { callback(true); } else { this.logger.warn({ origin }, "Rejected connection from origin"); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index ea2f5adbf..ea540d595 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -329,11 +329,33 @@ export const SendAgentMessageSchema = z.object({ })).optional(), }); -export const TranscribeAudioRequestSchema = z.object({ - type: z.literal("transcribe_audio_request"), - audio: z.string(), // base64 encoded - format: z.string(), - requestId: z.string(), +// ============================================================================ +// Dictation Streaming (lossless, resumable) +// ============================================================================ + +export const DictationStreamStartMessageSchema = z.object({ + type: z.literal("dictation_stream_start"), + dictationId: z.string(), + format: z.string(), // e.g. "audio/pcm;rate=16000;bits=16" +}); + +export const DictationStreamChunkMessageSchema = z.object({ + type: z.literal("dictation_stream_chunk"), + dictationId: z.string(), + seq: z.number().int().nonnegative(), + audio: z.string(), // base64 encoded chunk + format: z.string(), // e.g. "audio/pcm;rate=16000;bits=16" +}); + +export const DictationStreamFinishMessageSchema = z.object({ + type: z.literal("dictation_stream_finish"), + dictationId: z.string(), + finalSeq: z.number().int().nonnegative(), +}); + +export const DictationStreamCancelMessageSchema = z.object({ + type: z.literal("dictation_stream_cancel"), + dictationId: z.string(), }); const GitSetupOptionsSchema = z.object({ @@ -683,7 +705,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ DeleteAgentRequestMessageSchema, SetVoiceConversationMessageSchema, SendAgentMessageSchema, - TranscribeAudioRequestSchema, + DictationStreamStartMessageSchema, + DictationStreamChunkMessageSchema, + DictationStreamFinishMessageSchema, + DictationStreamCancelMessageSchema, CreateAgentRequestMessageSchema, ListProviderModelsRequestMessageSchema, ResumeAgentRequestMessageSchema, @@ -780,6 +805,31 @@ export const TranscriptionResultMessageSchema = z.object({ }), }); +export const DictationStreamAckMessageSchema = z.object({ + type: z.literal("dictation_stream_ack"), + payload: z.object({ + dictationId: z.string(), + ackSeq: z.number().int(), + }), +}); + +export const DictationStreamFinalMessageSchema = z.object({ + type: z.literal("dictation_stream_final"), + payload: z.object({ + dictationId: z.string(), + text: z.string(), + }), +}); + +export const DictationStreamErrorMessageSchema = z.object({ + type: z.literal("dictation_stream_error"), + payload: z.object({ + dictationId: z.string(), + error: z.string(), + retryable: z.boolean(), + }), +}); + export const StatusMessageSchema = z.object({ type: z.literal("status"), payload: z @@ -1244,6 +1294,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AssistantChunkMessageSchema, AudioOutputMessageSchema, TranscriptionResultMessageSchema, + DictationStreamAckMessageSchema, + DictationStreamFinalMessageSchema, + DictationStreamErrorMessageSchema, StatusMessageSchema, InitializeAgentResponseMessageSchema, ArtifactMessageSchema, @@ -1323,7 +1376,10 @@ export type ActivityLogPayload = z.infer; export type UserTextMessage = z.infer; export type RealtimeAudioChunkMessage = z.infer; export type SendAgentMessage = z.infer; -export type TranscribeAudioRequest = z.infer; +export type DictationStreamStartMessage = z.infer; +export type DictationStreamChunkMessage = z.infer; +export type DictationStreamFinishMessage = z.infer; +export type DictationStreamCancelMessage = z.infer; export type CreateAgentRequestMessage = z.infer; export type ListProviderModelsRequestMessage = z.infer< typeof ListProviderModelsRequestMessageSchema