Some checks are pending
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
160 lines
6.2 KiB
JavaScript
160 lines
6.2 KiB
JavaScript
// @ts-check
|
|
/**
|
|
* AudioWorkletProcessor that resamples the AudioContext rate (typically 48 kHz)
|
|
* down to 16 kHz, packs the result as little-endian Int16 PCM, and posts it
|
|
* back to the main thread in fixed-size chunks.
|
|
*
|
|
* The Hugging Face speech-to-speech WebSocket route expects the
|
|
* `input_audio_buffer.append` payload at 16 kHz PCM16 mono.
|
|
*
|
|
* Design notes:
|
|
* - 48 -> 16 is an exact 3:1 ratio so we use a 3-tap boxcar average as a
|
|
* cheap low-pass before decimating. Good enough for voice STT; we lose
|
|
* a tiny bit of >8 kHz content which the pipeline discards anyway.
|
|
* - Output frames are emitted at the cadence dictated by `chunkMs`
|
|
* (default 40 ms = 640 samples = 1280 bytes). The OpenAI Realtime
|
|
* server batches incoming audio so the cadence is flexible; 20-100 ms
|
|
* is the sweet spot.
|
|
* - Float -> Int16 saturates to [-1, 1] before scaling.
|
|
* - Optional noise gate: per-chunk RMS decides open/closed against a
|
|
* threshold; the gain ramps (fast attack, hold, slow release) so word
|
|
* onsets aren't clipped and quiet tails don't click. The gate only
|
|
* affects the audio we SEND; the main-thread visualiser taps the raw
|
|
* mic separately. We post the chunk RMS up every frame so the Settings
|
|
* mic meter can show the live level against the threshold.
|
|
*/
|
|
|
|
const TARGET_RATE = 16000;
|
|
const DEFAULT_CHUNK_MS = 40;
|
|
// Gate envelope timing (fixed; only the threshold is user-tunable).
|
|
const GATE_ATTACK_MS = 5; // open almost instantly so word onsets survive
|
|
const GATE_HOLD_MS = 250; // stay open this long after the level drops back under
|
|
const GATE_RELEASE_MS = 80; // then fade closed over this long (no click)
|
|
|
|
class MicCaptureProcessor extends AudioWorkletProcessor {
|
|
constructor(options) {
|
|
super();
|
|
const chunkMs = options?.processorOptions?.chunkMs ?? DEFAULT_CHUNK_MS;
|
|
this._inputRate = sampleRate;
|
|
this._ratio = this._inputRate / TARGET_RATE;
|
|
this._chunkSamples16k = Math.round((TARGET_RATE * chunkMs) / 1000);
|
|
this._scratch = new Float32Array(0);
|
|
this._decimated = new Float32Array(this._chunkSamples16k);
|
|
this._enabled = true;
|
|
|
|
// Noise gate state. Disabled by default (pure passthrough).
|
|
this._gateEnabled = false;
|
|
this._thresholdLin = 0; // linear amplitude; signal RMS must exceed this to open
|
|
this._gateGain = 1; // smoothed gain currently applied
|
|
this._holdRemaining = 0; // samples left before the gate may start closing
|
|
this._attackCoef = Math.exp(-1 / ((GATE_ATTACK_MS / 1000) * TARGET_RATE));
|
|
this._releaseCoef = Math.exp(-1 / ((GATE_RELEASE_MS / 1000) * TARGET_RATE));
|
|
this._holdSamples = Math.round((GATE_HOLD_MS / 1000) * TARGET_RATE);
|
|
|
|
this.port.onmessage = (e) => {
|
|
const data = e.data;
|
|
if (data?.kind === "enable") this._enabled = !!data.value;
|
|
else if (data?.kind === "gate") {
|
|
this._gateEnabled = !!data.enabled;
|
|
// dB -> linear amplitude. When off, threshold 0 keeps the gate open.
|
|
this._thresholdLin = data.enabled ? Math.pow(10, data.thresholdDb / 20) : 0;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Append `incoming` to the internal scratch buffer, then emit as many
|
|
* full output chunks as we have material for.
|
|
* @param {Float32Array} incoming
|
|
*/
|
|
_ingest(incoming) {
|
|
if (incoming.length === 0) return;
|
|
const next = new Float32Array(this._scratch.length + incoming.length);
|
|
next.set(this._scratch, 0);
|
|
next.set(incoming, this._scratch.length);
|
|
this._scratch = next;
|
|
this._maybeEmit();
|
|
}
|
|
|
|
_maybeEmit() {
|
|
const r = this._ratio;
|
|
const n = this._chunkSamples16k;
|
|
const needIn = Math.ceil(n * r);
|
|
const dec = this._decimated;
|
|
while (this._scratch.length >= needIn) {
|
|
// 1. Decimate to 16 kHz floats and accumulate energy for the gate/meter.
|
|
let sumSq = 0;
|
|
if (Math.abs(r - 3) < 1e-6) {
|
|
// 48 kHz -> 16 kHz fast path with boxcar lowpass.
|
|
for (let i = 0; i < n; i++) {
|
|
const idx = i * 3;
|
|
const s = (this._scratch[idx] + this._scratch[idx + 1] + this._scratch[idx + 2]) / 3;
|
|
dec[i] = s;
|
|
sumSq += s * s;
|
|
}
|
|
} else {
|
|
// Generic path: linear interpolation. Slower but works at any rate
|
|
// (e.g. some Windows boxes report sampleRate=44100).
|
|
for (let i = 0; i < n; i++) {
|
|
const srcPos = i * r;
|
|
const idx = Math.floor(srcPos);
|
|
const frac = srcPos - idx;
|
|
const a = this._scratch[idx];
|
|
const b = this._scratch[idx + 1] ?? a;
|
|
const s = a + (b - a) * frac;
|
|
dec[i] = s;
|
|
sumSq += s * s;
|
|
}
|
|
}
|
|
const rms = Math.sqrt(sumSq / n);
|
|
|
|
// 2. Decide the gate target for this chunk, then ramp sample-by-sample.
|
|
let target = 1;
|
|
if (this._gateEnabled) {
|
|
if (rms >= this._thresholdLin) {
|
|
this._holdRemaining = this._holdSamples; // re-arm the hold
|
|
} else if (this._holdRemaining > 0) {
|
|
this._holdRemaining -= n; // coasting through the hold window
|
|
} else {
|
|
target = 0;
|
|
}
|
|
}
|
|
|
|
// 3. Apply the (smoothed) gain and pack to Int16.
|
|
const out = new Int16Array(n);
|
|
let gain = this._gateGain;
|
|
for (let i = 0; i < n; i++) {
|
|
const coef = target > gain ? this._attackCoef : this._releaseCoef;
|
|
gain = target + (gain - target) * coef;
|
|
const s = dec[i] * gain;
|
|
const clamped = s < -1 ? -1 : s > 1 ? 1 : s;
|
|
out[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
|
|
}
|
|
this._gateGain = gain;
|
|
|
|
// Shift the scratch buffer to keep only the trailing unused samples.
|
|
const consumed = Math.floor(n * r);
|
|
this._scratch = this._scratch.slice(consumed);
|
|
|
|
// Live input level for the Settings meter (raw RMS, pre-gate).
|
|
this.port.postMessage({ kind: "level", rms });
|
|
|
|
if (this._enabled) {
|
|
this.port.postMessage(out.buffer, [out.buffer]);
|
|
}
|
|
// When disabled (mic muted) we silently consume input so the worklet
|
|
// stays alive and the buffer never grows unbounded.
|
|
}
|
|
}
|
|
|
|
process(inputs) {
|
|
const input = inputs[0];
|
|
if (!input || input.length === 0 || !input[0]) return true;
|
|
const mono = input[0];
|
|
if (mono.length > 0) this._ingest(mono);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
registerProcessor("mic-capture", MicCaptureProcessor);
|