import { isElectronRuntime } from "@/desktop/host"; import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource, } from "@/voice/audio-engine-types"; interface QueuedAudio { audio: AudioPlaybackSource; resolve: (duration: number) => void; reject: (error: Error) => void; } function getAudioContextCtor(): typeof AudioContext | null { if (typeof window === "undefined") { return null; } const browserWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext; }; return browserWindow.AudioContext ?? browserWindow.webkitAudioContext ?? null; } function floatToInt16(sample: number): number { const clamped = Math.max(-1, Math.min(1, sample)); return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); } function resampleToPcm16(input: Float32Array, inputRate: number, outputRate: number): Uint8Array { if (input.length === 0) { return new Uint8Array(0); } const ratio = inputRate / outputRate; const outputLength = Math.max(1, Math.round(input.length / ratio)); const output = new Int16Array(outputLength); for (let i = 0; i < outputLength; i += 1) { 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; output[i] = floatToInt16(sample); } return new Uint8Array(output.buffer, output.byteOffset, output.byteLength); } function parsePcmSampleRate(mimeType: string): number | null { const match = /rate=(\d+)/i.exec(mimeType); if (!match) { return null; } const rate = Number(match[1]); return Number.isFinite(rate) && rate > 0 ? rate : null; } function pcm16LeToAudioBuffer( context: AudioContext, bytes: Uint8Array, sampleRate: number, ): AudioBuffer { const sampleCount = Math.floor(bytes.length / 2); const audioBuffer = context.createBuffer(1, sampleCount, sampleRate); const channel = audioBuffer.getChannelData(0); for (let i = 0; i < sampleCount; i += 1) { const lo = bytes[i * 2]!; const hi = bytes[i * 2 + 1]!; let value = (hi << 8) | lo; if (value & 0x8000) { value -= 0x10000; } channel[i] = value / 0x8000; } return audioBuffer; } async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Promise { const maybePromise = context.decodeAudioData(buffer.slice(0)); if (maybePromise && typeof (maybePromise as Promise).then === "function") { return maybePromise as Promise; } return await new Promise((resolve, reject) => { context.decodeAudioData(buffer.slice(0), resolve, reject); }); } export function createAudioEngine( callbacks: AudioEngineCallbacks, _options?: { traceLabel?: string }, ): AudioEngine { const refs: { playbackContext: AudioContext | null; captureContext: AudioContext | null; stream: MediaStream | null; source: MediaStreamAudioSourceNode | null; processor: ScriptProcessorNode | null; gain: GainNode | null; started: boolean; muted: boolean; queue: QueuedAudio[]; processingQueue: boolean; activePlayback: { source: AudioBufferSourceNode; resolve: (duration: number) => void; reject: (error: Error) => void; settled: boolean; } | null; } = { playbackContext: null, captureContext: null, stream: null, source: null, processor: null, gain: null, started: false, muted: false, queue: [], processingQueue: false, activePlayback: null, }; async function ensurePlaybackContext(): Promise { if (refs.playbackContext) { if (refs.playbackContext.state === "suspended") { await refs.playbackContext.resume().catch(() => undefined); } return refs.playbackContext; } const AudioContextCtor = getAudioContextCtor(); if (!AudioContextCtor) { throw new Error("AudioContext unavailable"); } const context = new AudioContextCtor(); if (context.state === "suspended") { await context.resume().catch(() => undefined); } refs.playbackContext = context; return context; } async function ensureCaptureContext(): Promise { if (refs.captureContext) { if (refs.captureContext.state === "suspended") { await refs.captureContext.resume().catch(() => undefined); } return refs.captureContext; } const AudioContextCtor = getAudioContextCtor(); if (!AudioContextCtor) { throw new Error("AudioContext unavailable"); } const context = new AudioContextCtor(); if (context.state === "suspended") { await context.resume().catch(() => undefined); } refs.captureContext = context; return context; } async function playAudio(audio: AudioPlaybackSource): Promise { const context = await ensurePlaybackContext(); const arrayBuffer = await audio.arrayBuffer(); const type = (audio.type || "").toLowerCase(); const audioBuffer = type.startsWith("audio/pcm") ? pcm16LeToAudioBuffer( context, new Uint8Array(arrayBuffer), parsePcmSampleRate(type) ?? 24000, ) : await decodeAudioData(context, arrayBuffer); const durationSec = audioBuffer.duration; const source = context.createBufferSource(); source.buffer = audioBuffer; source.connect(context.destination); return await new Promise((resolve, reject) => { refs.activePlayback = { source, resolve, reject, settled: false }; const settle = (fn: () => void) => { const active = refs.activePlayback; if (!active || active.source !== source || active.settled) { return; } active.settled = true; refs.activePlayback = null; fn(); }; source.onended = () => { settle(() => resolve(durationSec)); }; try { source.start(); } catch (error) { settle(() => reject(error instanceof Error ? error : new Error(String(error)))); } }); } async function processQueue(): Promise { if (refs.processingQueue || refs.queue.length === 0) { return; } refs.processingQueue = true; while (refs.queue.length > 0) { const item = refs.queue.shift()!; try { const duration = await playAudio(item.audio); item.resolve(duration); } catch (error) { item.reject(error instanceof Error ? error : new Error(String(error))); } } refs.processingQueue = false; } async function stopCapture(): Promise { refs.started = false; try { refs.processor?.disconnect(); refs.source?.disconnect(); refs.gain?.disconnect(); } catch { // Ignore best-effort teardown errors. } if (refs.stream) { for (const track of refs.stream.getTracks()) { try { track.stop(); } catch { // Ignore best-effort teardown errors. } } } refs.stream = null; refs.source = null; refs.processor = null; refs.gain = null; refs.muted = false; callbacks.onVolumeLevel(0); const captureContext = refs.captureContext; refs.captureContext = null; if (captureContext && captureContext.state !== "closed") { await captureContext.close().catch(() => undefined); } } return { async initialize() { await ensurePlaybackContext(); }, async destroy() { this.stop(); this.clearQueue(); await stopCapture(); const playbackContext = refs.playbackContext; refs.playbackContext = null; if (playbackContext && playbackContext.state !== "closed") { await playbackContext.close().catch(() => undefined); } }, async startCapture() { if (refs.started) { return; } 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}`, ); } try { const context = await ensureCaptureContext(); const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, noiseSuppression: true, echoCancellation: true, autoGainControl: true, }, }); const source = context.createMediaStreamSource(stream); const processor = context.createScriptProcessor(4096, 1, 1); const gain = context.createGain(); gain.gain.value = 0; processor.onaudioprocess = (event) => { if (!refs.started) { return; } const input = event.inputBuffer.getChannelData(0); let sumSquares = 0; for (let i = 0; i < input.length; i += 1) { 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)); callbacks.onVolumeLevel(normalized); if (refs.muted) { return; } callbacks.onCaptureData(resampleToPcm16(input, context.sampleRate, 16000)); }; source.connect(processor); processor.connect(gain); gain.connect(context.destination); refs.started = true; refs.stream = stream; refs.source = source; refs.processor = processor; refs.gain = gain; } catch (error) { await stopCapture(); const wrapped = error instanceof Error ? error : new Error(String(error)); callbacks.onError?.(wrapped); throw wrapped; } }, async stopCapture() { await stopCapture(); }, toggleMute() { refs.muted = !refs.muted; if (refs.muted) { callbacks.onVolumeLevel(0); } return refs.muted; }, isMuted() { return refs.muted; }, async play(audio: AudioPlaybackSource) { return await new Promise((resolve, reject) => { refs.queue.push({ audio, resolve, reject }); if (!refs.processingQueue) { void processQueue(); } }); }, stop() { if (refs.activePlayback) { const active = refs.activePlayback; refs.activePlayback = null; try { active.source.onended = null; active.source.stop(); } catch { // Ignore best-effort stop errors. } if (!active.settled) { active.settled = true; active.reject(new Error("Playback stopped")); } } }, clearQueue() { while (refs.queue.length > 0) { refs.queue.shift()!.reject(new Error("Playback stopped")); } refs.processingQueue = false; }, isPlaying() { return refs.activePlayback !== null; }, }; }