Files
au_whisper_web/src/lib/mic.ts
T

170 lines
5.4 KiB
TypeScript

/**
* Microphone capture for live transcription (Decisions.md D-016). Browser-only.
*
* `getUserMedia` → `AudioContext` at the device's **native** rate (Firefox
* cannot connect a MediaStreamAudioSourceNode across sample rates, so the
* context is never forced to 16 kHz) → an AudioWorklet chunker posts ~100 ms
* Float32 blocks to the main thread, which resamples them to 16 kHz with the
* pure `resampleLinear` before handing them to the live segment loop
* (src/lib/live.ts).
*
* The worklet module is created from an inline source string via a Blob URL:
* no extra served file, no build or service-worker changes, and capture keeps
* working offline. Browsers without AudioWorklet (or that reject Blob-URL
* modules) fall back to a ScriptProcessorNode.
*
* Mic audio is processed entirely on-device, like every browser-engine path
* (Architecture.md §5).
*/
import { TARGET_SAMPLE_RATE, resampleLinear } from "./audio";
export interface MicCapture {
/** Native rate of the capture context (chunks are resampled to 16 kHz). */
sampleRate: number;
/** True when the AudioWorklet path failed and ScriptProcessorNode is used. */
usingFallback: boolean;
/** Release the mic (stops the permission indicator) and the audio graph. */
stop(): Promise<void>;
}
const WORKLET_NAME = "live-mic-chunker";
// Runs in the AudioWorklet global scope — plain JS, kept dependency-free.
// Accumulates the 128-sample render quanta into ~100 ms chunks so the main
// thread handles ~10 messages/s instead of ~375.
const WORKLET_SOURCE = `
registerProcessor("${WORKLET_NAME}", class extends AudioWorkletProcessor {
constructor() {
super();
this.blocks = [];
this.length = 0;
this.target = Math.max(128, Math.round(sampleRate / 10));
}
process(inputs) {
const channel = inputs[0] && inputs[0][0];
if (channel && channel.length > 0) {
this.blocks.push(new Float32Array(channel));
this.length += channel.length;
if (this.length >= this.target) {
const out = new Float32Array(this.length);
let offset = 0;
for (const block of this.blocks) {
out.set(block, offset);
offset += block.length;
}
this.blocks = [];
this.length = 0;
this.port.postMessage(out, [out.buffer]);
}
}
return true;
}
});
`;
async function attachWorklet(
context: AudioContext,
source: MediaStreamAudioSourceNode,
deliver: (samples: Float32Array) => void,
): Promise<() => void> {
const url = URL.createObjectURL(
new Blob([WORKLET_SOURCE], { type: "text/javascript" }),
);
try {
await context.audioWorklet.addModule(url);
} finally {
URL.revokeObjectURL(url);
}
const node = new AudioWorkletNode(context, WORKLET_NAME, {
numberOfInputs: 1,
numberOfOutputs: 0,
channelCount: 1,
});
node.port.onmessage = (event: MessageEvent) => deliver(event.data as Float32Array);
source.connect(node);
return () => {
node.port.onmessage = null;
source.disconnect(node);
};
}
function attachScriptProcessor(
context: AudioContext,
source: MediaStreamAudioSourceNode,
deliver: (samples: Float32Array) => void,
): () => void {
const node = context.createScriptProcessor(4096, 1, 1);
node.onaudioprocess = (event) => {
// The input buffer is reused between callbacks — copy before handing off.
deliver(new Float32Array(event.inputBuffer.getChannelData(0)));
};
// ScriptProcessorNode only fires when connected to the destination; route
// it through a muted gain so the mic is never played back.
const mute = context.createGain();
mute.gain.value = 0;
source.connect(node);
node.connect(mute);
mute.connect(context.destination);
return () => {
node.onaudioprocess = null;
source.disconnect(node);
node.disconnect();
mute.disconnect();
};
}
/**
* Open the microphone and stream 16 kHz mono Float32 chunks to `onChunk`
* until `stop()`. Rejects if the user denies mic permission (surface the
* error; do not retry without a user gesture).
*/
export async function startMicCapture(
onChunk: (samples: Float32Array) => void,
): Promise<MicCapture> {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error(
"Microphone capture is not supported in this browser (requires a secure context).",
);
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
const context = new AudioContext();
// Contexts created outside a user gesture start suspended; the start button
// click satisfies the gesture requirement, so this resolves immediately.
await context.resume().catch(() => {});
const source = context.createMediaStreamSource(stream);
const deliver = (samples: Float32Array) => {
onChunk(
context.sampleRate === TARGET_SAMPLE_RATE
? samples
: resampleLinear(samples, context.sampleRate, TARGET_SAMPLE_RATE),
);
};
let detach: () => void;
let usingFallback = false;
try {
detach = await attachWorklet(context, source, deliver);
} catch {
detach = attachScriptProcessor(context, source, deliver);
usingFallback = true;
}
let stopped = false;
return {
sampleRate: context.sampleRate,
usingFallback,
async stop() {
if (stopped) return;
stopped = true;
detach();
for (const track of stream.getTracks()) track.stop();
await context.close().catch(() => {});
},
};
}