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);
}