Improvements and tests for live transcription.

This commit is contained in:
Lucas Winther committed 2026-07-14 14:14:01 +02:00
1 parent e4be47f707
commit 18acead43c
14 files changed
+1138 -45

No files matched your search

+110
View File
@@ -75,6 +75,116 @@ export function resampleLinear(
return out;
}
/**
* Streaming anti-aliased resampler (windowed-sinc low-pass interpolation).
* Pure — safe to call anywhere.
*
* `resampleLinear` has no low-pass filter, so decimating mic audio
* (48 kHz → 16 kHz) folds everything above 8 kHz back into the speech band —
* audible as aliasing noise and measurably bad for Whisper accuracy. This
* class filters at the correct cutoff while resampling, and it is stateful:
* feed it arbitrary chunk sizes (the mic path delivers ~100 ms blocks) and
* the output is identical to resampling the whole signal at once — no edge
* artifacts at chunk boundaries.
*/
export class StreamingResampler {
/** Input samples advanced per output sample. */
private readonly step: number;
/** Kernel half-width, in input samples. */
private readonly half: number;
/** Normalized low-pass cutoff, cycles per input sample. */
private readonly cutoff: number;
private readonly passthrough: boolean;
/** Unconsumed input tail (kernel needs `half` samples of look-ahead). */
private pending = new Float32Array(0);
/** Fractional index into `pending` of the next output sample's center. */
private position = 0;
constructor(fromRate: number, toRate: number) {
if (!(fromRate > 0) || !(toRate > 0)) {
throw new Error("StreamingResampler: sample rates must be positive");
}
this.passthrough = fromRate === toRate;
this.step = fromRate / toRate;
// 0.45×Nyquist of the slower rate: keeps the whole speech band while the
// transition band stays inside what the window can attenuate.
this.cutoff = (0.45 * Math.min(fromRate, toRate)) / fromRate;
this.half = Math.ceil(2 / this.cutoff);
}
/** Blackman-windowed sinc, low-pass at `cutoff`, support (−half, half). */
private kernel(distance: number): number {
const scaled = 2 * this.cutoff * distance;
const x = Math.PI * scaled;
const sinc = scaled === 0 ? 1 : Math.sin(x) / x;
const t = (distance / this.half + 1) / 2; // 0..1 across the support
const window =
0.42 - 0.5 * Math.cos(2 * Math.PI * t) + 0.08 * Math.cos(4 * Math.PI * t);
return 2 * this.cutoff * sinc * window;
}
/** Emit output samples for every center that has full kernel support. */
private produce(buffer: Float32Array, limit: number): Float32Array {
const count = Math.max(
0,
Math.floor((limit - this.position) / this.step) + 1,
);
const out = new Float32Array(count);
for (let i = 0; i < count; i++) {
const center = this.position + i * this.step;
const from = Math.max(0, Math.ceil(center - this.half));
const to = Math.min(buffer.length - 1, Math.floor(center + this.half));
let acc = 0;
let norm = 0;
for (let j = from; j <= to; j++) {
const weight = this.kernel(j - center);
acc += buffer[j]! * weight;
norm += weight;
}
// Normalizing keeps unity gain exact despite kernel truncation.
out[i] = norm !== 0 ? acc / norm : 0;
}
this.position += count * this.step;
return out;
}
/** Resample the next chunk; may return 0 samples while buffering. */
process(chunk: Float32Array): Float32Array {
if (this.passthrough) return chunk;
let buffer: Float32Array;
if (this.pending.length > 0) {
buffer = new Float32Array(this.pending.length + chunk.length);
buffer.set(this.pending, 0);
buffer.set(chunk, this.pending.length);
} else {
buffer = chunk;
}
// Only centers whose kernel fits entirely in the buffered input.
const out = this.produce(buffer, buffer.length - 1 - this.half);
const keepFrom = Math.max(0, Math.floor(this.position) - this.half);
this.pending = buffer.slice(keepFrom);
this.position -= keepFrom;
return out;
}
/** Drain the buffered tail (end of stream) and reset. */
flush(): Float32Array {
if (this.passthrough || this.pending.length === 0) {
this.pending = new Float32Array(0);
this.position = 0;
return new Float32Array(0);
}
const limit = this.pending.length - 1;
const padded = new Float32Array(this.pending.length + this.half + 1);
padded.set(this.pending, 0);
const out = this.produce(padded, limit);
this.pending = new Float32Array(0);
this.position = 0;
return out;
}
}
/**
* Downmix + resample a decoded `AudioBuffer` to 16 kHz mono using
* `OfflineAudioContext` (D-012). Browser-only.