Files
VoiceStudio/frontend/src/utils/aec/micCapture.js
T
Palash Debnath 7718a7a10b fix: cross-platform dictation delivery (#1610)
Makes dictation delivery, capture, recovery, model fallback, AEC, and localized status behavior reliable across macOS, Windows, and Linux.
2026-08-21 03:33:50 +00:00

142 lines
5.2 KiB
JavaScript

// Microphone PCM capture for the opt-in AEC path and for WebViews that cannot
// construct MediaRecorder. Routes a getUserMedia stream through an
// AudioWorklet that emits fixed-size Float32 frames; the caller converts,
// tags, streams, or WAV-encodes them.
const WORKLET_URL = '/aec-worklet.js';
// Anti-alias filtering for the decimation below. resampleInterleavedFrame
// picks samples by linear interpolation, which is not a low-pass: taking a
// 48 kHz stream to 16 kHz that way folds everything above 8 kHz back down
// into the speech band as tones that were never spoken, and the ASR is fed
// the result. The browser only hands us 48 kHz when it refuses the requested
// 16 kHz AudioContext — WKWebView does — so this is the normal path there,
// not an edge case.
//
// Three cascaded Butterworth-Q biquads (~36 dB/octave) run in the audio
// graph rather than per frame, so the filter keeps its state across frame
// boundaries instead of restarting 50 times a second. The cutoff sits below
// Nyquist to leave room for the rolloff; speech has little energy up there.
const ANTIALIAS_STAGES = 3;
const ANTIALIAS_CUTOFF_RATIO = 0.4;
export function buildAntiAliasChain(ctx, targetRate) {
if (typeof ctx.createBiquadFilter !== 'function') return [];
const stages = [];
for (let stage = 0; stage < ANTIALIAS_STAGES; stage += 1) {
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = ANTIALIAS_CUTOFF_RATIO * targetRate;
filter.Q.value = Math.SQRT1_2; // Butterworth — flat passband, no resonant peak
stages.push(filter);
}
return stages;
}
export function resampleInterleavedFrame(frame, inputRate, outputRate, channels) {
if (inputRate === outputRate || frame.length === 0) return frame;
const inputFrames = Math.floor(frame.length / channels);
const outputFrames = Math.max(1, Math.round((inputFrames * outputRate) / inputRate));
const output = new Float32Array(outputFrames * channels);
const sourceStep = inputRate / outputRate;
for (let outputIndex = 0; outputIndex < outputFrames; outputIndex += 1) {
const sourcePosition = outputIndex * sourceStep;
const lowerIndex = Math.min(Math.floor(sourcePosition), inputFrames - 1);
const upperIndex = Math.min(lowerIndex + 1, inputFrames - 1);
const mix = sourcePosition - lowerIndex;
for (let channel = 0; channel < channels; channel += 1) {
const lower = frame[lowerIndex * channels + channel];
const upper = frame[upperIndex * channels + channel];
output[outputIndex * channels + channel] = lower + (upper - lower) * mix;
}
}
return output;
}
/**
* Start capturing ``stream`` as Float32 mono frames at ``sampleRate``.
*
* @param {MediaStream} stream mic stream from getUserMedia
* @param {(frame: Float32Array) => void} onFrame called per frame
* @param {{sampleRate?: number, frameSize?: number, channels?: number}} opts
* @returns {Promise<() => Promise<void>>} async stop() that tears down the graph
*/
export async function startMicCapture(
stream,
onFrame,
{ sampleRate = 16000, frameSize = 320, channels = 1 } = {},
) {
const Ctx = window.AudioContext || window.webkitAudioContext;
const ctx = new Ctx({ sampleRate });
if (ctx.state === 'suspended') {
try {
await ctx.resume();
} catch {
/* reported below — a context that never runs emits no frames at all */
}
}
if (ctx.state === 'suspended') {
// Failing loudly beats the alternative: a suspended context runs no
// worklet, so the pill sits on "Listening" forever while not one frame
// is captured. The caller can surface this; silence cannot be surfaced.
try {
await ctx.close();
} catch {
/* ignore */
}
throw new Error('mic-suspended: the audio context could not be resumed');
}
await ctx.audioWorklet.addModule(WORKLET_URL);
const src = ctx.createMediaStreamSource(stream);
const sourceFrameSize = Math.max(1, Math.round((frameSize * ctx.sampleRate) / sampleRate));
const node = new AudioWorkletNode(ctx, 'aec-frame-emitter', {
processorOptions: { frameSize: sourceFrameSize, channels },
});
node.port.onmessage = (e) =>
onFrame(resampleInterleavedFrame(e.data, ctx.sampleRate, sampleRate, channels));
// Mic → [anti-alias] → worklet. Only when the browser refused the requested
// rate; when it honors it there is no decimation and nothing to filter.
const antiAlias = ctx.sampleRate > sampleRate ? buildAntiAliasChain(ctx, sampleRate) : [];
const chain = [src, ...antiAlias, node];
for (let i = 0; i < chain.length - 1; i += 1) chain[i].connect(chain[i + 1]);
const stop = async function stop() {
try {
node.port.onmessage = null;
} catch {
/* ignore */
}
try {
node.disconnect();
} catch {
/* ignore */
}
try {
src.disconnect();
} catch {
/* ignore */
}
for (const filter of antiAlias) {
try {
filter.disconnect();
} catch {
/* ignore */
}
}
try {
await ctx.close();
} catch {
/* ignore */
}
};
// Existing callers use this value as a function. The property lets generic
// PCM/WAV recording encode the delivered frames at their actual rate.
stop.sampleRate = sampleRate;
stop.channels = channels;
return stop;
}