@medalsocial/meda
Version:
Shared Meda UI shell and runtime package.
186 lines (184 loc) • 7.45 kB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react';
const WORKLET_CODE = `
const TARGET_RATE = 16000;
const FRAME_SAMPLES = 320;
class MicCapture extends AudioWorkletProcessor {
constructor() {
super();
this.inputRate = sampleRate;
this.ratio = this.inputRate / TARGET_RATE;
this.buffer = [];
// Persist the fractional resampling offset across process() callbacks so
// non-integer ratios (e.g. 44.1kHz→16kHz) don't accumulate ~1% drift.
this.resampleOffset = 0;
}
resampleAndAppend(input) {
let i = this.resampleOffset;
while (i < input.length) {
this.buffer.push(input[Math.floor(i)]);
i += this.ratio;
}
// Carry the fractional part forward into the next callback.
this.resampleOffset = i - input.length;
}
process(inputs) {
const ch = inputs[0]?.[0];
if (!ch) return true;
this.resampleAndAppend(ch);
while (this.buffer.length >= FRAME_SAMPLES) {
const frame = this.buffer.slice(0, FRAME_SAMPLES);
this.buffer = this.buffer.slice(FRAME_SAMPLES);
const pcm = new Int16Array(FRAME_SAMPLES);
let sumSq = 0;
for (let j = 0; j < FRAME_SAMPLES; j++) {
const s = Math.max(-1, Math.min(1, frame[j]));
pcm[j] = (s * 0x7fff) | 0;
sumSq += s * s;
}
const rms = Math.sqrt(sumSq / FRAME_SAMPLES);
this.port.postMessage({ pcm: pcm.buffer, level: rms }, [pcm.buffer]);
}
return true;
}
}
registerProcessor('meda-mic-capture', MicCapture);
`;
export function useMicCapture(opts = {}) {
const [state, setState] = useState('idle');
const [level, setLevel] = useState(0);
const [error, setError] = useState(null);
const ctxRef = useRef(null);
const streamRef = useRef(null);
const nodeRef = useRef(null);
const onFrameRef = useRef(opts.onFrame);
onFrameRef.current = opts.onFrame;
// Generation token: incremented on every start/stop so an in-flight start()
// can detect that a newer call has superseded it and bail out.
const genRef = useRef(0);
const stop = useCallback(() => {
genRef.current += 1;
nodeRef.current?.disconnect();
nodeRef.current = null;
streamRef.current?.getTracks().forEach((t) => {
t.stop();
});
streamRef.current = null;
void ctxRef.current?.close();
ctxRef.current = null;
setState('idle');
setLevel(0);
}, []);
const start = useCallback(async () => {
// Bug 5 fix: tear down any existing capture before starting a new one so
// we don't orphan the previous stream/context.
if (ctxRef.current ?? streamRef.current) {
stop();
}
// Grab the current generation so we can detect if stop() (or a second
// start()) is called while we are awaiting getUserMedia / addModule.
genRef.current += 1;
const gen = genRef.current;
setState('requesting');
setError(null);
// Helper for the stale-generation early-return paths (after addModule):
// releases resources acquired by this invocation without touching refs
// that may already belong to a superseding start().
const cleanupRefs = (stream, ctx) => {
stream?.getTracks().forEach((t) => {
t.stop();
});
void ctx?.close();
// Clear refs only if this generation still owns them (a superseding
// start() may have already set new refs).
if (genRef.current === gen) {
if (streamRef.current === stream)
streamRef.current = null;
if (ctxRef.current === ctx)
ctxRef.current = null;
nodeRef.current = null;
}
};
// Track resources acquired by THIS invocation so the catch block can
// release exactly what we opened — not whatever a newer start() may have
// placed in the shared refs.
let acquiredStream = null;
let acquiredCtx = null;
try {
acquiredStream = await navigator.mediaDevices.getUserMedia({
audio: opts.audioConstraints ?? {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
// If stop() fired while we were awaiting getUserMedia a newer generation
// is active — release the stream we just acquired and bail out.
if (genRef.current !== gen) {
acquiredStream.getTracks().forEach((t) => {
t.stop();
});
return;
}
streamRef.current = acquiredStream;
acquiredCtx = new AudioContext();
ctxRef.current = acquiredCtx;
const blob = new Blob([WORKLET_CODE], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
try {
await acquiredCtx.audioWorklet.addModule(url);
}
finally {
URL.revokeObjectURL(url);
}
// Check again after the async addModule call.
if (genRef.current !== gen) {
cleanupRefs(acquiredStream, acquiredCtx);
return;
}
const node = new AudioWorkletNode(acquiredCtx, 'meda-mic-capture');
nodeRef.current = node;
node.port.onmessage = (e) => {
const msg = e.data;
setLevel(msg.level);
onFrameRef.current?.({ pcm: msg.pcm, level: msg.level });
};
const src = acquiredCtx.createMediaStreamSource(acquiredStream);
src.connect(node);
// Web Audio graphs only run when connected to a destination. The
// worklet's process() callback is never invoked otherwise — no PCM
// frames, no level updates. Route through a 0-gain node so we don't
// produce audible feedback from the user's own mic.
const silent = acquiredCtx.createGain();
silent.gain.value = 0;
node.connect(silent);
silent.connect(acquiredCtx.destination);
setState('capturing');
}
catch (err) {
// Release only the resources THIS start() acquired. Reading from refs
// here would be unsafe — a superseding start() may have already stored
// its own resources there.
acquiredStream?.getTracks().forEach((t) => {
t.stop();
});
void acquiredCtx?.close();
if (genRef.current === gen) {
if (streamRef.current === acquiredStream)
streamRef.current = null;
if (ctxRef.current === acquiredCtx)
ctxRef.current = null;
nodeRef.current = null;
const e = err;
setError(e);
setState(e.name === 'NotAllowedError' ? 'denied' : 'idle');
}
}
}, [opts.audioConstraints, stop]);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally empty - should only run on mount
useEffect(() => {
if (opts.autoStart)
void start();
return stop;
}, []);
return { state, level, error, start, stop };
}