File size: 11,950 Bytes
79f41f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 | /**
* pcm-segmenter.ts β Continuous PCM16 utterance segmenter for streaming STT.
*
* Turns an endless stream of raw PCM16LE mono audio into discrete utterance
* segments suitable for a request/response STT API (Whisper), without losing
* speech at segment boundaries:
*
* - PRE-ROLL: audio captured just BEFORE speech onset is prepended to the
* utterance so soft word onsets are not clipped.
* - HANGOVER: an utterance only ends after a sustained silence, so trailing
* syllables and short pauses inside a sentence are kept.
* - ADAPTIVE VAD: the speech threshold tracks the noise floor, so it works
* for both close-mic speech and external audio (TV, videos, news).
* - FORCED CUTS: continuous speech longer than `maxUtteranceMs` is split at
* the QUIETEST recent window (least likely to be mid-word) instead of an
* arbitrary time boundary.
*
* The class is pure (no I/O): callers feed PCM buffers in and receive
* segment buffers via the `onSegment` callback.
*/
// βββ WAV encoding ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/** Wrap raw PCM16LE mono samples in a 44-byte WAV header. */
export function pcm16ToWav(pcm: Buffer, sampleRate: number): Buffer {
const header = Buffer.alloc(44);
const byteRate = sampleRate * 2; // mono, 16-bit
header.write("RIFF", 0);
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16); // fmt chunk size
header.writeUInt16LE(1, 20); // PCM format
header.writeUInt16LE(1, 22); // mono
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(2, 32); // block align
header.writeUInt16LE(16, 34); // bits per sample
header.write("data", 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
// βββ Segmenter βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export interface PcmSegmenterOptions {
/** Sample rate of the incoming PCM16LE mono stream. Default: 16000 */
sampleRate?: number;
/** Audio kept from before speech onset. Default: 320ms */
preRollMs?: number;
/** Sustained silence that ends an utterance. Default: 900ms */
hangoverMs?: number;
/** Utterances with less speech than this are discarded as noise. Default: 180ms */
minSpeechMs?: number;
/** Continuous speech is force-cut after this long. Default: 7000ms */
maxUtteranceMs?: number;
/** Absolute minimum level (dBFS) treated as speech. Default: -48 */
speechFloorDb?: number;
/** Speech must exceed the noise floor by this margin (dB). Default: 9 */
noiseMarginDb?: number;
}
export interface SegmentInfo {
/** True when the segment was cut mid-speech (maxUtteranceMs reached). */
forced: boolean;
/** Audio duration of the emitted segment in milliseconds. */
durationMs: number;
/** Milliseconds of the segment classified as speech. */
speechMs: number;
}
interface Window {
buf: Buffer;
db: number;
speech: boolean;
}
/** VAD analysis window; 20ms is fine-grained enough for word boundaries. */
const WINDOW_MS = 20;
/** Trailing silence kept on an emitted segment (helps STT decode the tail). */
const TAIL_SILENCE_KEEP_MS = 240;
/** Forced cuts search for the quietest window within this recent span. */
const FORCED_CUT_SEARCH_MS = 2000;
/** Forced cuts never split inside the newest audio (still mid-word). */
const FORCED_CUT_GUARD_MS = 200;
export class PcmSegmenter {
private readonly sampleRate: number;
private readonly preRollMs: number;
private readonly hangoverMs: number;
private readonly minSpeechMs: number;
private readonly maxUtteranceMs: number;
private readonly speechFloorDb: number;
private readonly noiseMarginDb: number;
private readonly windowBytes: number;
/** Carry-over for incoming buffers that don't align to whole windows. */
private remainder: Buffer = Buffer.alloc(0);
/** Rolling buffer of recent windows while idle (pre-roll source). */
private preRoll: Window[] = [];
/** Windows of the in-progress utterance (empty while idle). */
private active: Window[] = [];
private activeSpeechMs = 0;
private silenceRunMs = 0;
/** EMA noise floor in dBFS; rises slowly, falls quickly. */
private noiseFloorDb = -65;
constructor(
options: PcmSegmenterOptions,
private readonly onSegment: (pcm: Buffer, info: SegmentInfo) => void,
) {
this.sampleRate = options.sampleRate ?? 16000;
this.preRollMs = options.preRollMs ?? 320;
this.hangoverMs = options.hangoverMs ?? 900;
this.minSpeechMs = options.minSpeechMs ?? 180;
this.maxUtteranceMs = options.maxUtteranceMs ?? 7000;
this.speechFloorDb = options.speechFloorDb ?? -48;
this.noiseMarginDb = options.noiseMarginDb ?? 9;
this.windowBytes = Math.round((this.sampleRate * WINDOW_MS) / 1000) * 2;
}
/** Feed the next PCM16LE buffer from the stream. */
feed(pcm: Buffer): void {
const data = this.remainder.length
? Buffer.concat([this.remainder, pcm])
: pcm;
const wholeWindows = Math.floor(data.length / this.windowBytes);
const usedBytes = wholeWindows * this.windowBytes;
this.remainder = data.subarray(usedBytes);
for (let i = 0; i < wholeWindows; i++) {
const buf = data.subarray(i * this.windowBytes, (i + 1) * this.windowBytes);
this.processWindow(buf);
}
}
/**
* Finalize the in-progress utterance immediately (stream stopped).
* Emits the tail segment if it contains enough speech.
*/
flush(): void {
if (this.remainder.length >= 4) {
// Analyse the sub-window remainder too β a stream can stop mid-window.
this.processWindow(this.remainder);
}
this.remainder = Buffer.alloc(0);
this.endUtterance(false);
}
/** Drop all buffered state (new session). Keeps the learned noise floor. */
reset(): void {
this.remainder = Buffer.alloc(0);
this.preRoll = [];
this.active = [];
this.activeSpeechMs = 0;
this.silenceRunMs = 0;
}
/**
* Snapshot of the in-progress utterance, for rolling partial transcription.
* Returns null while idle or when the utterance is still too short.
*/
getActiveSnapshot(): { pcm: Buffer; durationMs: number; speechMs: number } | null {
if (!this.active.length || this.activeSpeechMs < this.minSpeechMs) return null;
const pcm = Buffer.concat(this.active.map((w) => w.buf));
return {
pcm,
durationMs: this.msOf(pcm),
speechMs: this.activeSpeechMs,
};
}
// ββ Internals ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private msOf(buf: Buffer): number {
return (buf.length / 2 / this.sampleRate) * 1000;
}
private speechThresholdDb(): number {
return Math.max(this.speechFloorDb, this.noiseFloorDb + this.noiseMarginDb);
}
private processWindow(buf: Buffer): void {
const db = rmsDb(buf);
const speech = db >= this.speechThresholdDb();
const win: Window = { buf, db, speech };
const winMs = this.msOf(buf);
// Track the noise floor from non-speech windows only, capped so loud
// continuous audio can never drag the floor up into speech territory.
if (!speech) {
const alpha = db < this.noiseFloorDb ? 0.2 : 0.02;
this.noiseFloorDb = Math.min(
-35,
this.noiseFloorDb + alpha * (db - this.noiseFloorDb),
);
}
if (!this.active.length) {
if (speech) {
// Speech onset: start the utterance with the pre-roll prepended.
this.active = [...this.preRoll, win];
this.preRoll = [];
this.activeSpeechMs = winMs;
this.silenceRunMs = 0;
} else {
this.preRoll.push(win);
let total = this.preRoll.reduce((s, w) => s + this.msOf(w.buf), 0);
while (this.preRoll.length > 1 && total > this.preRollMs) {
total -= this.msOf(this.preRoll[0].buf);
this.preRoll.shift();
}
}
return;
}
// Utterance in progress: keep everything (speech AND intra-speech silence).
this.active.push(win);
if (speech) {
this.activeSpeechMs += winMs;
this.silenceRunMs = 0;
} else {
this.silenceRunMs += winMs;
}
if (this.silenceRunMs >= this.hangoverMs) {
this.endUtterance(false);
return;
}
const activeMs = this.active.reduce((s, w) => s + this.msOf(w.buf), 0);
if (activeMs >= this.maxUtteranceMs) {
this.forceCut();
}
}
private endUtterance(forced: boolean): void {
if (!this.active.length) return;
let windows = this.active;
this.active = [];
const speechMs = this.activeSpeechMs;
this.activeSpeechMs = 0;
this.silenceRunMs = 0;
// Trim trailing silence beyond a short tail; the trimmed silence seeds
// the pre-roll so the next onset still has context.
let tailSilence = 0;
while (tailSilence < windows.length && !windows[windows.length - 1 - tailSilence].speech) {
tailSilence++;
}
const keepTail = Math.ceil(TAIL_SILENCE_KEEP_MS / WINDOW_MS);
if (tailSilence > keepTail) {
const cut = windows.length - (tailSilence - keepTail);
this.preRoll = windows.slice(cut);
windows = windows.slice(0, cut);
}
if (speechMs < this.minSpeechMs) {
// Noise blip β don't transcribe it, but keep its tail as pre-roll so a
// real onset right after still gets its leading audio.
this.preRoll = [...windows.slice(-Math.ceil(this.preRollMs / WINDOW_MS)), ...this.preRoll];
return;
}
const pcm = Buffer.concat(windows.map((w) => w.buf));
this.onSegment(pcm, {
forced,
durationMs: this.msOf(pcm),
speechMs,
});
}
/**
* Continuous speech reached maxUtteranceMs: split at the quietest window in
* the recent past (least likely to be mid-word). Audio after the cut point
* becomes the start of the next utterance β nothing is dropped.
*/
private forceCut(): void {
const searchWindows = Math.ceil(FORCED_CUT_SEARCH_MS / WINDOW_MS);
const guardWindows = Math.ceil(FORCED_CUT_GUARD_MS / WINDOW_MS);
const end = Math.max(1, this.active.length - guardWindows);
const start = Math.max(1, end - searchWindows);
let cut = end - 1;
for (let i = start; i < end; i++) {
if (this.active[i].db < this.active[cut].db) cut = i;
}
const emitted = this.active.slice(0, cut);
const carried = this.active.slice(cut);
const emittedSpeechMs = emitted.reduce(
(s, w) => s + (w.speech ? this.msOf(w.buf) : 0),
0,
);
this.active = carried;
this.activeSpeechMs = carried.reduce(
(s, w) => s + (w.speech ? this.msOf(w.buf) : 0),
0,
);
// silenceRunMs continues counting across the cut; recompute from the tail.
let silence = 0;
for (let i = carried.length - 1; i >= 0 && !carried[i].speech; i--) {
silence += this.msOf(carried[i].buf);
}
this.silenceRunMs = silence;
if (emittedSpeechMs < this.minSpeechMs || !emitted.length) return;
const pcm = Buffer.concat(emitted.map((w) => w.buf));
this.onSegment(pcm, {
forced: true,
durationMs: this.msOf(pcm),
speechMs: emittedSpeechMs,
});
}
}
/** RMS level of a PCM16LE buffer in dBFS. */
function rmsDb(buf: Buffer): number {
const samples = buf.length >> 1;
if (!samples) return -Infinity;
let sumSq = 0;
for (let i = 0; i < samples; i++) {
const s = buf.readInt16LE(i * 2) / 32768;
sumSq += s * s;
}
const rms = Math.sqrt(sumSq / samples);
return 20 * Math.log10(rms + 1e-10);
}
|