import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parsePcm16Wav } from "@/utils/pcm16-wav"; import { isElectronRuntime } from "@/desktop/host"; 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 int16ToFloat32 = (input: Int16Array): Float32Array => { const out = new Float32Array(input.length); for (let i = 0; i < input.length; i += 1) { out[i] = input[i] / 32768; } 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); }; interface RecorderRefs { recorder: MediaRecorder | null; audioChunks: Blob[]; stoppedPromise: Promise | null; stoppedResolve: ((blob: Blob) => void) | null; stoppedReject: ((error: unknown) => void) | null; } function safeDisconnectNode(node: AudioNode | null): void { if (!node) return; try { node.disconnect(); } catch { // no-op } } function disconnectDictationAudioGraph(graph: { processor: ScriptProcessorNode | null; source: AudioNode | null; gain: AudioNode | null; stream: MediaStream | null; }): void { if (graph.processor) { try { graph.processor.onaudioprocess = null; } catch { // no-op } safeDisconnectNode(graph.processor); } safeDisconnectNode(graph.source); safeDisconnectNode(graph.gain); if (graph.stream) { graph.stream.getTracks().forEach((track) => { try { track.stop(); } catch { // no-op } }); } } function stopMediaRecorderIfActive(recorder: MediaRecorder | null): void { if (!recorder) return; try { if (recorder.state === "recording") { recorder.stop(); } } catch { // ignore stop errors; we still need to stop tracks } } async function finalizeRecorderStoppedPromise(input: { stoppedPromise: Promise; context: AudioContext | null; decodeAudioData: (context: AudioContext, arrayBuffer: ArrayBuffer) => Promise; emitPcmSegments: (pcm: Int16Array) => void; onError: ((err: Error) => void) | undefined; }): Promise { const { stoppedPromise, context, decodeAudioData, emitPcmSegments, onError } = input; try { const blob = await stoppedPromise; const arrayBuffer = await blob.arrayBuffer(); if (arrayBuffer.byteLength === 0) { return; } const parsedWav = parsePcm16Wav(arrayBuffer); if (parsedWav) { const floatPcm = int16ToFloat32(parsedWav.samples); emitPcmSegments(resampleToPcm16(floatPcm, parsedWav.sampleRate, 16000)); return; } if (context) { const decoded = await decodeAudioData(context, arrayBuffer); const floatPcm = decoded.getChannelData(0); emitPcmSegments(resampleToPcm16(floatPcm, decoded.sampleRate, 16000)); } } catch (err) { onError?.(err instanceof Error ? err : new Error(String(err))); } } 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; mode: "pcm" | "recorder"; recorder: RecorderRefs; }>({ stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false, mode: "pcm", recorder: { recorder: null, audioChunks: [], stoppedPromise: null, stoppedResolve: null, stoppedReject: null, }, }); const decodeAudioData = useCallback( async (context: AudioContext, buffer: ArrayBuffer): Promise => { const maybePromise = context.decodeAudioData(buffer); if (maybePromise && typeof maybePromise.then === "function") { return maybePromise; } return await new Promise((resolve, reject) => { context.decodeAudioData(buffer, resolve, reject); }); }, [], ); const emitPcmSegments = useCallback((pcm: Int16Array) => { const outputRate = 16000; const chunkSamples = outputRate; // ~1s let pending = pcm; while (pending.length >= chunkSamples) { const chunk = pending.slice(0, chunkSamples); pending = pending.slice(chunkSamples); onPcmSegmentRef.current(int16ToBase64(chunk)); } if (pending.length > 0) { onPcmSegmentRef.current(int16ToBase64(pending)); } }, []); 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"; const isDesktopApp = isElectronRuntime(); if (missingNavigator) { throw new Error("Microphone capture is not supported in this environment"); } if (!secureContext && !isDesktopApp) { throw new Error( `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`, ); } if (!secureContext && isDesktopApp) { console.warn( "[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway", { currentOrigin }, ); } const AudioContextCtor = getAudioContextCtor(); if (!AudioContextCtor) { throw new Error("AudioContext unavailable"); } const rawStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, noiseSuppression: true, echoCancellation: true, autoGainControl: true, }, }); const stream = rawStream; const context = new AudioContextCtor(); try { 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; refs.current.mode = "pcm"; 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 = { ...refs.current, stream, context, source, processor, gain, pending: new Int16Array(0), started: true, }; return; } catch { // Fall back to MediaRecorder for environments where MediaStreamAudioSourceNode // isn't available (e.g., Playwright tests with a stubbed getUserMedia). } const RecorderCtor = typeof window !== "undefined" ? (window as Window & { MediaRecorder?: typeof MediaRecorder }).MediaRecorder : undefined; if (!RecorderCtor) { throw new Error("MediaRecorder unavailable"); } const recorder = new RecorderCtor(stream, { mimeType: "audio/webm;codecs=opus", } as MediaRecorderOptions); const recorderRefs: RecorderRefs = { recorder, audioChunks: [], stoppedPromise: null, stoppedResolve: null, stoppedReject: null, }; recorder.ondataavailable = (event: BlobEvent) => { const data: Blob | undefined = event?.data; if (data) { recorderRefs.audioChunks.push(data); } }; recorder.addEventListener("error", (event: Event) => { recorderRefs.stoppedReject?.(event); }); recorder.addEventListener("stop", () => { try { const blob = recorderRefs.audioChunks.length > 0 ? new Blob(recorderRefs.audioChunks, { type: recorder.mimeType }) : new Blob([], { type: recorder.mimeType }); recorderRefs.stoppedResolve?.(blob); } catch (err) { recorderRefs.stoppedReject?.(err); } }); recorderRefs.stoppedPromise = new Promise((resolve, reject) => { recorderRefs.stoppedResolve = resolve; recorderRefs.stoppedReject = reject; }); try { recorder.start(); } catch (err) { throw err instanceof Error ? err : new Error(String(err)); } refs.current = { ...refs.current, stream, context, source: null, processor: null, gain: null, pending: new Int16Array(0), started: true, mode: "recorder", recorder: recorderRefs, }; }, []); const stop = useCallback(async () => { refs.current.started = false; setVolume(0); const { processor, source, gain, context, stream, pending, mode, recorder } = refs.current; disconnectDictationAudioGraph({ processor, source, gain, stream }); if (mode === "recorder") { stopMediaRecorderIfActive(recorder.recorder); if (recorder.stoppedPromise) { await finalizeRecorderStoppedPromise({ stoppedPromise: recorder.stoppedPromise, context, decodeAudioData, emitPcmSegments, onError: onErrorRef.current, }); } } 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, mode: "pcm", recorder: { recorder: null, audioChunks: [], stoppedPromise: null, stoppedResolve: null, stoppedReject: null, }, }; }, [decodeAudioData, emitPcmSegments]); 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], ); }