Improvements and tests for live transcription.
This commit is contained in:
1 parent
e4be47f707
commit
18acead43c
14 files changed
+1138
-45
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user