taqarub commited on
Commit
79f41f5
·
1 Parent(s): 2dc74fc

update persistent PCM voice bridge

Browse files
server/pcm-segmenter.ts ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * pcm-segmenter.ts — Continuous PCM16 utterance segmenter for streaming STT.
3
+ *
4
+ * Turns an endless stream of raw PCM16LE mono audio into discrete utterance
5
+ * segments suitable for a request/response STT API (Whisper), without losing
6
+ * speech at segment boundaries:
7
+ *
8
+ * - PRE-ROLL: audio captured just BEFORE speech onset is prepended to the
9
+ * utterance so soft word onsets are not clipped.
10
+ * - HANGOVER: an utterance only ends after a sustained silence, so trailing
11
+ * syllables and short pauses inside a sentence are kept.
12
+ * - ADAPTIVE VAD: the speech threshold tracks the noise floor, so it works
13
+ * for both close-mic speech and external audio (TV, videos, news).
14
+ * - FORCED CUTS: continuous speech longer than `maxUtteranceMs` is split at
15
+ * the QUIETEST recent window (least likely to be mid-word) instead of an
16
+ * arbitrary time boundary.
17
+ *
18
+ * The class is pure (no I/O): callers feed PCM buffers in and receive
19
+ * segment buffers via the `onSegment` callback.
20
+ */
21
+
22
+ // ─── WAV encoding ────────────────────────────────────────────────────────────
23
+
24
+ /** Wrap raw PCM16LE mono samples in a 44-byte WAV header. */
25
+ export function pcm16ToWav(pcm: Buffer, sampleRate: number): Buffer {
26
+ const header = Buffer.alloc(44);
27
+ const byteRate = sampleRate * 2; // mono, 16-bit
28
+ header.write("RIFF", 0);
29
+ header.writeUInt32LE(36 + pcm.length, 4);
30
+ header.write("WAVE", 8);
31
+ header.write("fmt ", 12);
32
+ header.writeUInt32LE(16, 16); // fmt chunk size
33
+ header.writeUInt16LE(1, 20); // PCM format
34
+ header.writeUInt16LE(1, 22); // mono
35
+ header.writeUInt32LE(sampleRate, 24);
36
+ header.writeUInt32LE(byteRate, 28);
37
+ header.writeUInt16LE(2, 32); // block align
38
+ header.writeUInt16LE(16, 34); // bits per sample
39
+ header.write("data", 36);
40
+ header.writeUInt32LE(pcm.length, 40);
41
+ return Buffer.concat([header, pcm]);
42
+ }
43
+
44
+ // ─── Segmenter ───────────────────────────────────────────────────────────────
45
+
46
+ export interface PcmSegmenterOptions {
47
+ /** Sample rate of the incoming PCM16LE mono stream. Default: 16000 */
48
+ sampleRate?: number;
49
+ /** Audio kept from before speech onset. Default: 320ms */
50
+ preRollMs?: number;
51
+ /** Sustained silence that ends an utterance. Default: 900ms */
52
+ hangoverMs?: number;
53
+ /** Utterances with less speech than this are discarded as noise. Default: 180ms */
54
+ minSpeechMs?: number;
55
+ /** Continuous speech is force-cut after this long. Default: 7000ms */
56
+ maxUtteranceMs?: number;
57
+ /** Absolute minimum level (dBFS) treated as speech. Default: -48 */
58
+ speechFloorDb?: number;
59
+ /** Speech must exceed the noise floor by this margin (dB). Default: 9 */
60
+ noiseMarginDb?: number;
61
+ }
62
+
63
+ export interface SegmentInfo {
64
+ /** True when the segment was cut mid-speech (maxUtteranceMs reached). */
65
+ forced: boolean;
66
+ /** Audio duration of the emitted segment in milliseconds. */
67
+ durationMs: number;
68
+ /** Milliseconds of the segment classified as speech. */
69
+ speechMs: number;
70
+ }
71
+
72
+ interface Window {
73
+ buf: Buffer;
74
+ db: number;
75
+ speech: boolean;
76
+ }
77
+
78
+ /** VAD analysis window; 20ms is fine-grained enough for word boundaries. */
79
+ const WINDOW_MS = 20;
80
+ /** Trailing silence kept on an emitted segment (helps STT decode the tail). */
81
+ const TAIL_SILENCE_KEEP_MS = 240;
82
+ /** Forced cuts search for the quietest window within this recent span. */
83
+ const FORCED_CUT_SEARCH_MS = 2000;
84
+ /** Forced cuts never split inside the newest audio (still mid-word). */
85
+ const FORCED_CUT_GUARD_MS = 200;
86
+
87
+ export class PcmSegmenter {
88
+ private readonly sampleRate: number;
89
+ private readonly preRollMs: number;
90
+ private readonly hangoverMs: number;
91
+ private readonly minSpeechMs: number;
92
+ private readonly maxUtteranceMs: number;
93
+ private readonly speechFloorDb: number;
94
+ private readonly noiseMarginDb: number;
95
+ private readonly windowBytes: number;
96
+
97
+ /** Carry-over for incoming buffers that don't align to whole windows. */
98
+ private remainder: Buffer = Buffer.alloc(0);
99
+ /** Rolling buffer of recent windows while idle (pre-roll source). */
100
+ private preRoll: Window[] = [];
101
+ /** Windows of the in-progress utterance (empty while idle). */
102
+ private active: Window[] = [];
103
+ private activeSpeechMs = 0;
104
+ private silenceRunMs = 0;
105
+ /** EMA noise floor in dBFS; rises slowly, falls quickly. */
106
+ private noiseFloorDb = -65;
107
+
108
+ constructor(
109
+ options: PcmSegmenterOptions,
110
+ private readonly onSegment: (pcm: Buffer, info: SegmentInfo) => void,
111
+ ) {
112
+ this.sampleRate = options.sampleRate ?? 16000;
113
+ this.preRollMs = options.preRollMs ?? 320;
114
+ this.hangoverMs = options.hangoverMs ?? 900;
115
+ this.minSpeechMs = options.minSpeechMs ?? 180;
116
+ this.maxUtteranceMs = options.maxUtteranceMs ?? 7000;
117
+ this.speechFloorDb = options.speechFloorDb ?? -48;
118
+ this.noiseMarginDb = options.noiseMarginDb ?? 9;
119
+ this.windowBytes = Math.round((this.sampleRate * WINDOW_MS) / 1000) * 2;
120
+ }
121
+
122
+ /** Feed the next PCM16LE buffer from the stream. */
123
+ feed(pcm: Buffer): void {
124
+ const data = this.remainder.length
125
+ ? Buffer.concat([this.remainder, pcm])
126
+ : pcm;
127
+ const wholeWindows = Math.floor(data.length / this.windowBytes);
128
+ const usedBytes = wholeWindows * this.windowBytes;
129
+ this.remainder = data.subarray(usedBytes);
130
+
131
+ for (let i = 0; i < wholeWindows; i++) {
132
+ const buf = data.subarray(i * this.windowBytes, (i + 1) * this.windowBytes);
133
+ this.processWindow(buf);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Finalize the in-progress utterance immediately (stream stopped).
139
+ * Emits the tail segment if it contains enough speech.
140
+ */
141
+ flush(): void {
142
+ if (this.remainder.length >= 4) {
143
+ // Analyse the sub-window remainder too — a stream can stop mid-window.
144
+ this.processWindow(this.remainder);
145
+ }
146
+ this.remainder = Buffer.alloc(0);
147
+ this.endUtterance(false);
148
+ }
149
+
150
+ /** Drop all buffered state (new session). Keeps the learned noise floor. */
151
+ reset(): void {
152
+ this.remainder = Buffer.alloc(0);
153
+ this.preRoll = [];
154
+ this.active = [];
155
+ this.activeSpeechMs = 0;
156
+ this.silenceRunMs = 0;
157
+ }
158
+
159
+ /**
160
+ * Snapshot of the in-progress utterance, for rolling partial transcription.
161
+ * Returns null while idle or when the utterance is still too short.
162
+ */
163
+ getActiveSnapshot(): { pcm: Buffer; durationMs: number; speechMs: number } | null {
164
+ if (!this.active.length || this.activeSpeechMs < this.minSpeechMs) return null;
165
+ const pcm = Buffer.concat(this.active.map((w) => w.buf));
166
+ return {
167
+ pcm,
168
+ durationMs: this.msOf(pcm),
169
+ speechMs: this.activeSpeechMs,
170
+ };
171
+ }
172
+
173
+ // ── Internals ────────────────────────────────────────────────────────────
174
+
175
+ private msOf(buf: Buffer): number {
176
+ return (buf.length / 2 / this.sampleRate) * 1000;
177
+ }
178
+
179
+ private speechThresholdDb(): number {
180
+ return Math.max(this.speechFloorDb, this.noiseFloorDb + this.noiseMarginDb);
181
+ }
182
+
183
+ private processWindow(buf: Buffer): void {
184
+ const db = rmsDb(buf);
185
+ const speech = db >= this.speechThresholdDb();
186
+ const win: Window = { buf, db, speech };
187
+ const winMs = this.msOf(buf);
188
+
189
+ // Track the noise floor from non-speech windows only, capped so loud
190
+ // continuous audio can never drag the floor up into speech territory.
191
+ if (!speech) {
192
+ const alpha = db < this.noiseFloorDb ? 0.2 : 0.02;
193
+ this.noiseFloorDb = Math.min(
194
+ -35,
195
+ this.noiseFloorDb + alpha * (db - this.noiseFloorDb),
196
+ );
197
+ }
198
+
199
+ if (!this.active.length) {
200
+ if (speech) {
201
+ // Speech onset: start the utterance with the pre-roll prepended.
202
+ this.active = [...this.preRoll, win];
203
+ this.preRoll = [];
204
+ this.activeSpeechMs = winMs;
205
+ this.silenceRunMs = 0;
206
+ } else {
207
+ this.preRoll.push(win);
208
+ let total = this.preRoll.reduce((s, w) => s + this.msOf(w.buf), 0);
209
+ while (this.preRoll.length > 1 && total > this.preRollMs) {
210
+ total -= this.msOf(this.preRoll[0].buf);
211
+ this.preRoll.shift();
212
+ }
213
+ }
214
+ return;
215
+ }
216
+
217
+ // Utterance in progress: keep everything (speech AND intra-speech silence).
218
+ this.active.push(win);
219
+ if (speech) {
220
+ this.activeSpeechMs += winMs;
221
+ this.silenceRunMs = 0;
222
+ } else {
223
+ this.silenceRunMs += winMs;
224
+ }
225
+
226
+ if (this.silenceRunMs >= this.hangoverMs) {
227
+ this.endUtterance(false);
228
+ return;
229
+ }
230
+
231
+ const activeMs = this.active.reduce((s, w) => s + this.msOf(w.buf), 0);
232
+ if (activeMs >= this.maxUtteranceMs) {
233
+ this.forceCut();
234
+ }
235
+ }
236
+
237
+ private endUtterance(forced: boolean): void {
238
+ if (!this.active.length) return;
239
+
240
+ let windows = this.active;
241
+ this.active = [];
242
+ const speechMs = this.activeSpeechMs;
243
+ this.activeSpeechMs = 0;
244
+ this.silenceRunMs = 0;
245
+
246
+ // Trim trailing silence beyond a short tail; the trimmed silence seeds
247
+ // the pre-roll so the next onset still has context.
248
+ let tailSilence = 0;
249
+ while (tailSilence < windows.length && !windows[windows.length - 1 - tailSilence].speech) {
250
+ tailSilence++;
251
+ }
252
+ const keepTail = Math.ceil(TAIL_SILENCE_KEEP_MS / WINDOW_MS);
253
+ if (tailSilence > keepTail) {
254
+ const cut = windows.length - (tailSilence - keepTail);
255
+ this.preRoll = windows.slice(cut);
256
+ windows = windows.slice(0, cut);
257
+ }
258
+
259
+ if (speechMs < this.minSpeechMs) {
260
+ // Noise blip — don't transcribe it, but keep its tail as pre-roll so a
261
+ // real onset right after still gets its leading audio.
262
+ this.preRoll = [...windows.slice(-Math.ceil(this.preRollMs / WINDOW_MS)), ...this.preRoll];
263
+ return;
264
+ }
265
+
266
+ const pcm = Buffer.concat(windows.map((w) => w.buf));
267
+ this.onSegment(pcm, {
268
+ forced,
269
+ durationMs: this.msOf(pcm),
270
+ speechMs,
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Continuous speech reached maxUtteranceMs: split at the quietest window in
276
+ * the recent past (least likely to be mid-word). Audio after the cut point
277
+ * becomes the start of the next utterance — nothing is dropped.
278
+ */
279
+ private forceCut(): void {
280
+ const searchWindows = Math.ceil(FORCED_CUT_SEARCH_MS / WINDOW_MS);
281
+ const guardWindows = Math.ceil(FORCED_CUT_GUARD_MS / WINDOW_MS);
282
+ const end = Math.max(1, this.active.length - guardWindows);
283
+ const start = Math.max(1, end - searchWindows);
284
+
285
+ let cut = end - 1;
286
+ for (let i = start; i < end; i++) {
287
+ if (this.active[i].db < this.active[cut].db) cut = i;
288
+ }
289
+
290
+ const emitted = this.active.slice(0, cut);
291
+ const carried = this.active.slice(cut);
292
+ const emittedSpeechMs = emitted.reduce(
293
+ (s, w) => s + (w.speech ? this.msOf(w.buf) : 0),
294
+ 0,
295
+ );
296
+
297
+ this.active = carried;
298
+ this.activeSpeechMs = carried.reduce(
299
+ (s, w) => s + (w.speech ? this.msOf(w.buf) : 0),
300
+ 0,
301
+ );
302
+ // silenceRunMs continues counting across the cut; recompute from the tail.
303
+ let silence = 0;
304
+ for (let i = carried.length - 1; i >= 0 && !carried[i].speech; i--) {
305
+ silence += this.msOf(carried[i].buf);
306
+ }
307
+ this.silenceRunMs = silence;
308
+
309
+ if (emittedSpeechMs < this.minSpeechMs || !emitted.length) return;
310
+
311
+ const pcm = Buffer.concat(emitted.map((w) => w.buf));
312
+ this.onSegment(pcm, {
313
+ forced: true,
314
+ durationMs: this.msOf(pcm),
315
+ speechMs: emittedSpeechMs,
316
+ });
317
+ }
318
+ }
319
+
320
+ /** RMS level of a PCM16LE buffer in dBFS. */
321
+ function rmsDb(buf: Buffer): number {
322
+ const samples = buf.length >> 1;
323
+ if (!samples) return -Infinity;
324
+ let sumSq = 0;
325
+ for (let i = 0; i < samples; i++) {
326
+ const s = buf.readInt16LE(i * 2) / 32768;
327
+ sumSq += s * s;
328
+ }
329
+ const rms = Math.sqrt(sumSq / samples);
330
+ return 20 * Math.log10(rms + 1e-10);
331
+ }
server/voice-ws.ts CHANGED
@@ -1,810 +1,608 @@
1
  /**
2
- * voice-ws.ts — Real-time voice-to-sign WebSocket server
3
  *
4
- * Protocol (client → server):
5
- * { type: "chunk", audio: "<base64>", mimeType: "audio/m4a", chunkId: N }
6
- * { type: "text", text: "...", chunkId: N } ← legacy: pre-recognized text
7
- * { type: "stop" }
8
- *
9
- * Protocol (server → client):
10
- * { type: "ready" }
11
- * { type: "queued", chunkId, queueSize }
12
- * { type: "partial_transcript", text, chunkId }
13
- * { type: "final_transcript", text, words, chunkId, rawText }
14
- * { type: "words", words, chunkId, rawText } ← legacy compat
15
- * { type: "error", message, chunkId? }
16
- *
17
- * Algorithms:
18
- * ─────────────────────────────────────────────────────────────────────────────
19
- * 1. SLIDING WINDOW CONTEXT (NLP)
20
- * Each chunk sent to Whisper includes the last 5 words as prompt context
21
- * to prevent word splitting across chunk boundaries.
22
- *
23
- * 2. MIN-HEAP ORDERED DELIVERY (Data Structure)
24
- * Results stored in a min-heap sorted by chunkId for ordered delivery
25
- * even when results arrive out of order.
26
- *
27
- * 3. MAXIMUM MUNCH TOKENIZATION (NLP)
28
- * Extract all known words from a sentence using greedy longest-match-first.
29
- *
30
- * 4. PARALLEL PROCESSING + DROP STRATEGY (Performance)
31
- * Process up to 4 chunks in parallel; drop excess to prevent queue buildup.
32
- *
33
- * 5. ARABIC TEXT NORMALIZATION (NLP) — internal only
34
- * Used only for sign/avatar VOCAB matching. Raw dialect preserved in transcripts.
35
- *
36
- * 6. POSITION-BASED DEDUPLICATION
37
- * Track sent words by position to prevent immediate repeats.
38
- * ─────────────────────────────────────────────────────────────────────────────
39
  */
40
-
41
- import { WebSocketServer, WebSocket } from "ws";
42
  import type { Server } from "http";
43
- import { ENV } from "./_core/env";
44
-
45
- // ─── Message types ───────────────────────────────────────────────────────────
46
-
47
- interface ChunkMsg {
48
- type: "chunk";
49
- audio: string; // base64
50
- mimeType?: string;
51
- chunkId: number;
52
- }
53
- interface TextMsg {
54
- type: "text";
55
- text: string;
56
- chunkId: number;
57
- }
58
- interface StopMsg {
59
- type: "stop";
60
  }
61
- type ClientMessage = ChunkMsg | TextMsg | StopMsg;
62
-
63
- // ─── Arabic text normalization (internal only — for sign/avatar matching) ────
64
-
65
- /** Normalize for VOCAB lookup only. Never use on user-facing transcript text. */
66
- function normalizeAr(s: string): string {
67
- return s
68
- .replace(/[\u064B-\u065F\u0670]/g, "") // remove diacritics
69
- .replace(/[أإآ]/g, "ا") // unify alif
70
- .replace(/ة/g, "ه") // taa marbuta → haa
71
- .replace(/ى/g, "ي") // alif maqsura → yaa
72
- .replace(/[^\u0600-\u06FF\s]/g, " ") // remove non-Arabic
73
- .replace(/\s+/g, " ")
74
- .trim();
75
- }
76
-
77
- // ─── Vocabulary (normalized → canonical key) ─────────────────────────────────
78
-
79
- const VOCAB: Record<string, string> = {
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
- * NLP: Extract all known words from a sentence in order.
141
- * Algorithm: Maximum Munch on single-word tokens.
142
- */
143
- function extractAllWords(rawText: string): string[] {
144
- const normalized = normalizeAr(rawText);
145
- const tokens = normalized.split(/\s+/).filter(Boolean);
146
- const result: string[] = [];
147
- for (const token of tokens) {
148
- const canonical = VOCAB[token];
149
- if (canonical) result.push(canonical);
150
- }
151
- return result;
152
  }
153
 
154
- // ─── Min-Heap for ordered delivery ───────────────────────────────────────────
155
-
156
- interface HeapItem {
157
- chunkId: number;
158
- words: string[];
159
- rawText: string;
160
  }
161
 
162
- class MinHeap {
163
- private data: HeapItem[] = [];
164
- push(item: HeapItem) {
165
- this.data.push(item);
166
- this.bubbleUp(this.data.length - 1);
167
- }
168
- peek(): HeapItem | undefined {
169
- return this.data[0];
170
- }
171
- pop(): HeapItem | undefined {
172
- if (!this.data.length) return undefined;
173
- const top = this.data[0];
174
- const last = this.data.pop()!;
175
- if (this.data.length) {
176
- this.data[0] = last;
177
- this.sinkDown(0);
178
- }
179
- return top;
180
- }
181
- get size() {
182
- return this.data.length;
183
- }
184
- private bubbleUp(i: number) {
185
- while (i > 0) {
186
- const p = (i - 1) >> 1;
187
- if (this.data[p].chunkId <= this.data[i].chunkId) break;
188
- [this.data[p], this.data[i]] = [this.data[i], this.data[p]];
189
- i = p;
190
- }
191
- }
192
- private sinkDown(i: number) {
193
- const n = this.data.length;
194
- while (true) {
195
- let min = i;
196
- const l = 2 * i + 1,
197
- r = 2 * i + 2;
198
- if (l < n && this.data[l].chunkId < this.data[min].chunkId) min = l;
199
- if (r < n && this.data[r].chunkId < this.data[min].chunkId) min = r;
200
- if (min === i) break;
201
- [this.data[min], this.data[i]] = [this.data[i], this.data[min]];
202
- i = min;
203
- }
204
  }
205
  }
206
 
207
- // ─── Sliding Window Context ──────────────────────────────────────────────────
208
-
209
- class SlidingContext {
210
- private lastRawWords: string[] = [];
211
-
212
- getPrompt(): string {
213
- // Whisper's initial_prompt is designed for *prior speech context only* — NOT
214
- // instructions. Passing Arabic instructions causes Whisper to echo their
215
- // vocabulary (e.g. "موسيقى") as hallucinations. Only pass the last spoken
216
- // words so Whisper maintains continuity across chunk boundaries.
217
- if (!this.lastRawWords.length) return "";
218
- return this.lastRawWords.slice(-5).join(" ");
219
  }
220
-
221
- /** Feed raw (un-normalized) Whisper output to preserve dialect in context */
222
- update(rawText: string) {
223
- const words = rawText.trim().split(/\s+/).filter(Boolean);
224
- this.lastRawWords = [...this.lastRawWords, ...words].slice(-10);
225
  }
 
 
226
 
227
- reset() {
228
- this.lastRawWords = [];
229
- }
 
 
 
230
  }
231
 
232
- // ─── Voice Session ───────────────────────────────────────────────────────────
233
-
234
- class VoiceSession {
235
- private heap = new MinHeap();
236
- private nextExpected = 0;
237
- private processing = 0;
238
- private readonly MAX_CONCURRENT = 4;
239
- private backlog: {
240
- chunkId: number;
241
- audioBase64: string;
242
- mimeType: string;
243
- ext: string;
244
- }[] = [];
245
- private context = new SlidingContext();
246
- private allSentWords: string[] = [];
247
- private totalEnqueued = 0;
248
- /** How many consecutive chunks came back empty (silence/hallucination filtered) */
249
- private consecutiveSilenceCount = 0;
250
 
251
  constructor(
252
- private ws: WebSocket,
253
- private sendMsg: (msg: Record<string, any>) => void,
 
254
  ) {}
255
 
256
- enqueue(chunk: {
257
- chunkId: number;
258
- audioBase64: string;
259
- mimeType: string;
260
- ext: string;
261
- }) {
262
- this.totalEnqueued++;
263
- this.backlog.push(chunk);
264
- this.sendMsg({
265
- type: "queued",
266
- chunkId: chunk.chunkId,
267
- queueSize: this.processing + this.backlog.length,
268
- });
269
-
270
- this.drainBacklog();
271
  }
272
 
273
- private drainBacklog() {
274
- while (this.processing < this.MAX_CONCURRENT && this.backlog.length > 0) {
275
- const chunk = this.backlog.shift()!;
276
- this.processing++;
277
- const prompt = this.context.getPrompt();
278
- this.processChunk(chunk, prompt)
279
- .catch((err) =>
280
- console.error(`[voice-ws] chunk ${chunk.chunkId}:`, err.message),
281
- )
282
- .finally(() => {
283
- this.processing--;
284
- this.drainBacklog();
285
  });
 
 
 
 
 
 
 
286
  }
287
  }
288
 
289
- private async processChunk(
290
- chunk: {
291
- chunkId: number;
292
- audioBase64: string;
293
- mimeType: string;
294
- ext: string;
295
- },
296
- prompt: string,
297
- ) {
298
- let text: string;
299
- try {
300
- text = await transcribeWithWhisper(
301
- chunk.audioBase64,
302
- chunk.ext,
303
- chunk.mimeType,
304
- prompt,
305
- );
306
- } catch (err: any) {
307
- console.error(
308
- `[voice-ws] ❌ transcription failed for chunk ${chunk.chunkId}:`,
309
- err.message,
310
  );
311
- this.sendMsg({
312
- type: "error",
313
- message: `Transcription failed: ${err.message}`,
314
- chunkId: chunk.chunkId,
315
- });
316
- // Still add to heap with empty text so ordering isn't broken
317
- text = "";
318
  }
 
319
 
320
- // Only update context on real speech.
321
- // On silence/hallucination (text === ""), increment the silence streak.
322
- // After 2+ consecutive silent chunks reset the context so stale speech
323
- // content stops "anchoring" Whisper and pulling it toward hallucinations.
324
- if (text) {
325
- this.consecutiveSilenceCount = 0;
326
- this.context.update(text);
327
- } else {
328
- this.consecutiveSilenceCount++;
329
- if (this.consecutiveSilenceCount >= 1) {
330
- this.context.reset();
331
- console.log("[voice-ws] 🔄 context reset — silence detected");
332
- }
333
- }
334
 
335
- // Send partial transcript immediately (before word extraction + ordering)
336
- if (text.trim()) {
337
- this.sendMsg({
338
- type: "partial_transcript",
339
- text: text.trim(),
340
- chunkId: chunk.chunkId,
341
- });
342
- }
343
 
344
- const words = extractAllWords(text);
345
- this.heap.push({ chunkId: chunk.chunkId, words, rawText: text });
346
- this.flushOrdered();
 
 
 
 
347
  }
348
 
349
- private flushOrdered() {
350
- while (
351
- this.heap.size > 0 &&
352
- this.heap.peek()!.chunkId === this.nextExpected
353
  ) {
354
- const item = this.heap.pop()!;
355
- this.nextExpected++;
356
-
357
- // Deduplicate words by position
358
- const newWords: string[] = [];
359
- if (item.words.length > 0) {
360
- for (const w of item.words) {
361
- if (this.shouldSend(w)) {
362
- newWords.push(w);
363
- this.allSentWords.push(w);
364
- }
365
- }
366
- }
367
-
368
- // Send final_transcript with both text and matched words
369
- this.sendMsg({
370
- type: "final_transcript",
371
- text: item.rawText.trim(),
372
- words: newWords,
373
- chunkId: item.chunkId,
374
- rawText: item.rawText.trim(),
375
- });
376
 
377
- // Also send legacy "words" message for backward compatibility
378
- if (newWords.length > 0) {
379
- this.sendMsg({
380
- type: "words",
381
- words: newWords,
382
- chunkId: item.chunkId,
383
- rawText: item.rawText,
384
- });
385
  }
386
- }
387
- }
388
 
389
- /**
390
- * Should we send this word?
391
- * Yes if: never sent before, or 8+ other words have been sent since.
392
- */
393
- private shouldSend(word: string): boolean {
394
- const lastIdx = this.allSentWords.lastIndexOf(word);
395
- if (lastIdx === -1) return true;
396
- return this.allSentWords.length - lastIdx > 8;
397
- }
 
 
 
 
 
 
 
 
398
 
399
- /** Process pre-recognized text (from Web Speech API) — no Whisper needed */
400
- processText(chunkId: number, rawText: string) {
401
- if (!rawText.trim()) return;
402
- this.totalEnqueued++;
403
- this.sendMsg({
404
- type: "queued",
405
- chunkId,
406
- queueSize: this.processing,
407
- });
 
 
 
 
 
 
 
 
 
 
 
 
408
 
409
- const words = extractAllWords(rawText);
410
- this.context.update(rawText);
411
- this.heap.push({ chunkId, words, rawText });
412
- this.flushOrdered();
413
- }
 
 
 
 
414
 
415
- reset() {
416
- this.heap = new MinHeap();
417
- this.nextExpected = 0;
418
- this.processing = 0;
419
- this.context.reset();
420
- this.allSentWords = [];
421
- this.totalEnqueued = 0;
422
- this.consecutiveSilenceCount = 0;
423
- }
424
- }
 
 
 
 
 
 
425
 
426
- // ─── Whisper API ─────────────────────────────────────────────────────────────
 
 
 
427
 
428
- // ─── Silence / hallucination detection ───────────────────────────────────────
 
 
 
 
 
 
429
 
430
- /** no_speech_prob threshold: above this → treat chunk as silence.
431
- * 0.6 is well-calibrated for Arabic speech; 0.35 was too aggressive and
432
- * caused real speech with low energy to be silently dropped. */
433
- const NO_SPEECH_THRESHOLD = 0.6;
 
 
 
 
434
 
435
- /** avg_logprob threshold: below this → model is guessing, not transcribing.
436
- * -0.8 avoids over-filtering dialect Arabic which naturally scores lower. */
437
- const LOW_CONFIDENCE_LOGPROB = -0.8;
 
 
 
438
 
439
- /** compression_ratio threshold: above this → repetitive/looping hallucination */
440
- const HIGH_COMPRESSION_RATIO = 2.4;
 
 
 
 
 
 
 
 
441
 
442
- /**
443
- * Punctuation-only / symbol-only outputs that carry no speech content.
444
- */
445
- const HALLUCINATION_RE = /^[\s.،؟!…\-–—\u200B-\u200F♪♫♬]+$/;
446
 
447
- /**
448
- * Ghost phrases Whisper hallucinates from YouTube/podcast training data.
449
- * These are STRIPPED from transcripts (not used to drop the whole chunk),
450
- * so real speech appearing in the same chunk is preserved.
451
- *
452
- * Order matters: longer/more-specific phrases must come before their substrings
453
- * so the longer form is stripped first.
454
- */
455
- const GHOST_PHRASES: string[] = [
456
- // ── Arabic — longer phrases first ────────────────────────────────────────
457
- "شكراً على المشاهدة",
458
- "شكرا على المشاهدة",
459
- "شكراً على المتابعة",
460
- "شكرا على المتابعة",
461
- "للمزيد من الفيديوهات",
462
- "للمزيد من المحتوى",
463
- "لا تنسى الاشتراك",
464
- "لا تنسوا الاشتراك",
465
- "اشترك معنا",
466
- "اللهم صل على محمد",
467
- "صلى الله عليه وسلم",
468
- "بسم الله الرحمن الرحيم",
469
- "الحمد لله رب العالمين",
470
- // ── Arabic — single-word ghosts ───────────────────────────────────────────
471
- "اشتركوا", // subscribe (plural) — before "اشترك"
472
- "أشترك", // subscribe (hamza)
473
- "اشترك", // subscribe
474
- "الحمد لله",
475
- "سبحان الله",
476
- "موسيقى", // music — most common Arabic hallucination on silence
477
- "موسيقا",
478
- // ── English (training bleed) ─────────────────────────────────────────────
479
- "thanks for watching",
480
- "thank you for watching",
481
- "please subscribe",
482
- "see you next time",
483
- "background music",
484
- "[applause]",
485
- "[laughter]",
486
- "[silence]",
487
- "[inaudible]",
488
- "[music]",
489
- "subscribe",
490
- "music",
491
- ];
492
 
493
- /**
494
- * Strip every ghost phrase from `text`, normalise whitespace, and return
495
- * the cleaned string. Returns "" if nothing real remains.
496
- *
497
- * Stripping (not dropping) preserves real speech that appeared in the same
498
- * chunk as a hallucination — the previous approach discarded whole chunks.
499
- */
500
- function stripGhostPhrases(text: string): string {
501
- let result = text.trim();
502
- for (const ghost of GHOST_PHRASES) {
503
- // Arabic has no case, but toLowerCase is harmless and handles English ghosts.
504
- const re = new RegExp(ghost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
505
- const before = result;
506
- result = result
507
- .replace(re, " ")
508
- .replace(/\s{2,}/g, " ")
509
- .trim();
510
- if (result !== before) {
511
- console.log(`[voice-ws] ✂️ stripped ghost "${ghost}" → "${result}"`);
 
 
 
 
 
512
  }
513
  }
514
- if (!result || HALLUCINATION_RE.test(result)) return "";
515
- return result;
516
- }
517
 
518
- /**
519
- * Segment-level stats check: returns true only when the ENTIRE chunk has
520
- * no real speech (high no_speech_prob, very low logprob, or looping output).
521
- */
522
- function isChunkSilence(
523
- segments:
524
- | {
525
- no_speech_prob?: number;
526
- avg_logprob?: number;
527
- compression_ratio?: number;
528
- }[]
529
- | undefined,
530
- ): boolean {
531
- if (!segments?.length) return false;
532
- const n = segments.length;
533
-
534
- const avgNoSpeech =
535
- segments.reduce((s, seg) => s + (seg.no_speech_prob ?? 0), 0) / n;
536
- if (avgNoSpeech > NO_SPEECH_THRESHOLD) {
537
- console.log(
538
- `[voice-ws] 🤫 silence (no_speech_prob=${avgNoSpeech.toFixed(2)})`,
539
  );
540
- return true;
541
  }
542
 
543
- const avgLogprob =
544
- segments.reduce((s, seg) => s + (seg.avg_logprob ?? 0), 0) / n;
545
- if (avgLogprob < LOW_CONFIDENCE_LOGPROB) {
546
- console.log(
547
- `[voice-ws] 🎲 low confidence (avg_logprob=${avgLogprob.toFixed(2)})`,
548
- );
549
- return true;
550
  }
551
 
552
- const avgCompression =
553
- segments.reduce((s, seg) => s + (seg.compression_ratio ?? 0), 0) / n;
554
- if (avgCompression > HIGH_COMPRESSION_RATIO) {
555
- console.log(
556
- `[voice-ws] 🔁 repetitive (compression_ratio=${avgCompression.toFixed(2)})`,
557
- );
558
- return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  }
560
 
561
- return false;
562
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
 
564
- /**
565
- * Master transcript cleaner.
566
- * 1. Reject pure punctuation/symbol outputs.
567
- * 2. Strip known ghost phrases (preserves real speech in the same chunk).
568
- * 3. Drop the chunk only if segment stats confirm total silence.
569
- *
570
- * Returns cleaned text, or "" if nothing real remains.
571
- */
572
- function cleanTranscript(
573
- rawText: string,
574
- segments:
575
- | {
576
- no_speech_prob?: number;
577
- avg_logprob?: number;
578
- compression_ratio?: number;
579
- }[]
580
- | undefined,
581
- ): string {
582
- // 1. Pure punctuation / symbols
583
- if (!rawText.trim() || HALLUCINATION_RE.test(rawText.trim())) return "";
584
-
585
- // 2. Strip ghost phrases — may return "" if nothing real is left
586
- const cleaned = stripGhostPhrases(rawText);
587
- if (!cleaned) return "";
588
-
589
- // 3. Segment-level silence check
590
- if (isChunkSilence(segments)) return "";
591
-
592
- return cleaned;
593
  }
594
 
595
- // ─── Whisper API ─────────────────────────────────────────────────────────────
 
 
 
 
596
 
597
- async function transcribeWithWhisper(
598
- audioBase64: string,
599
- ext: string,
600
- mimeType: string,
601
- prompt: string,
602
- ): Promise<string> {
603
- if (!ENV.forgeApiUrl || !ENV.forgeApiKey) {
604
- throw new Error(
605
- "STT API not configured. Set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY in .env " +
606
- "(supports OpenAI, Groq, or any OpenAI-compatible Whisper API)",
 
607
  );
608
  }
609
 
610
- const audioBuffer = Buffer.from(audioBase64, "base64");
611
- if (audioBuffer.length < 500) {
612
- console.log("[voice-ws] ⚠️ chunk too small (silence), skipping");
613
- return "";
 
 
 
 
 
 
 
614
  }
615
 
616
- const mimeMap: Record<string, string> = {
617
- m4a: "audio/m4a",
618
- mp4: "audio/mp4",
619
- webm: "audio/webm",
620
- wav: "audio/wav",
621
- mp3: "audio/mpeg",
622
- ogg: "audio/ogg",
623
- };
624
- const resolvedMime = mimeMap[ext] ?? mimeType ?? "audio/m4a";
625
- const blob = new Blob([audioBuffer], { type: resolvedMime });
626
-
627
- const models = ["whisper-large-v3-turbo", "whisper-large-v3", "whisper-1"];
628
- for (const model of models) {
629
- try {
630
- const fd = new FormData();
631
- fd.append("file", blob, `audio.${ext}`);
632
- fd.append("model", model);
633
- fd.append("language", "ar");
634
- // verbose_json gives us no_speech_prob per segment for silence detection
635
- fd.append("response_format", "verbose_json");
636
- fd.append("prompt", prompt);
637
- fd.append("temperature", "0"); // 0 = deterministic, no guessing
638
-
639
- const baseUrl = ENV.forgeApiUrl.endsWith("/")
640
- ? ENV.forgeApiUrl
641
- : `${ENV.forgeApiUrl}/`;
642
- const endpoint = new URL("v1/audio/transcriptions", baseUrl).toString();
643
-
644
- console.log(
645
- `[voice-ws] 🔊 transcribing with ${model} (${Math.round(audioBuffer.length / 1024)}KB)`,
646
- );
647
-
648
- const res = await fetch(endpoint, {
649
- method: "POST",
650
- headers: {
651
- authorization: `Bearer ${ENV.forgeApiKey}`,
652
- "Accept-Encoding": "identity",
653
- },
654
- body: fd,
655
- signal: AbortSignal.timeout(10000),
656
  });
 
 
 
 
657
 
658
- if (!res.ok) {
659
- const errText = await res.text();
660
- if (
661
- res.status === 404 ||
662
- errText.includes("model") ||
663
- errText.includes("not found")
664
- ) {
665
- console.warn(`[voice-ws] model ${model} not available, trying next`);
666
- continue;
667
- }
668
- throw new Error(`Whisper ${res.status}: ${errText}`);
669
- }
670
 
671
- const data = (await res.json()) as {
672
- text?: string;
673
- segments?: {
674
- no_speech_prob?: number;
675
- avg_logprob?: number;
676
- compression_ratio?: number;
677
- text?: string;
678
- }[];
679
- };
680
- const raw = data.text?.trim() ?? "";
681
- console.log(`[voice-ws] ✅ ${model} → "${raw}"`);
682
-
683
- const text = cleanTranscript(raw, data.segments);
684
- if (text !== raw) {
685
- console.log(`[voice-ws] 🧹 cleaned → "${text}"`);
686
- }
687
- return text;
688
- } catch (err: any) {
689
- const cause = err.cause?.message ?? err.cause?.code ?? "";
690
- if (
691
- err.name === "TimeoutError" ||
692
- err.message?.includes("not available")
693
- ) {
694
- console.warn(`[voice-ws] ${model} timeout/unavailable, trying next`);
695
- continue;
696
- }
697
- // "fetch failed" is a generic network error — log the root cause for debugging
698
- if (err.message === "fetch failed") {
699
- console.error(
700
- `[voice-ws] ❌ fetch failed for ${model} — cause: ${cause || "unknown"}`,
701
- );
702
- throw new Error(`fetch failed (${cause || "network error"})`);
703
- }
704
- throw err;
705
- }
706
  }
707
- throw new Error("All Whisper models unavailable");
708
  }
709
 
710
- // ─── WebSocket Server ────────────────────────────────────────────────────────
711
 
712
- function sendJson(ws: WebSocket, msg: Record<string, any>) {
713
- if (ws.readyState === WebSocket.OPEN) {
714
- ws.send(JSON.stringify(msg));
715
- }
 
 
 
 
 
 
 
 
716
  }
717
 
718
- export function registerVoiceWebSocket(httpServer: Server) {
719
- const wss = new WebSocketServer({
720
  server: httpServer,
721
  path: "/ws/voice",
722
  perMessageDeflate: false,
723
  });
724
 
725
- // Log STT configuration on startup
726
- if (ENV.forgeApiUrl && ENV.forgeApiKey) {
727
- console.log(
728
- `[voice-ws] ✅ STT API configured: ${ENV.forgeApiUrl} (server-side Whisper)`,
729
- );
730
- } else {
731
- console.warn(
732
- "[voice-ws] ⚠️ STT API NOT configured! Set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY in .env",
733
- );
734
- console.warn(
735
- "[voice-ws] Free option: Groq API → https://console.groq.com (whisper-large-v3-turbo)",
736
- );
737
- console.warn(
738
- "[voice-ws] Set BUILT_IN_FORGE_API_URL=https://api.groq.com/openai",
739
- );
740
- console.warn("[voice-ws] Set BUILT_IN_FORGE_API_KEY=gsk_...");
741
- }
742
-
743
- wss.on("connection", (ws) => {
744
- console.log("[voice-ws] 🔌 client connected");
745
-
746
- const send = (msg: Record<string, any>) => sendJson(ws, msg);
747
- const session = new VoiceSession(ws, send);
748
-
749
- send({ type: "ready" });
750
 
751
- ws.on("message", (raw) => {
752
- let msg: ClientMessage;
753
  try {
754
- msg = JSON.parse(raw.toString());
755
  } catch {
756
  return;
757
  }
758
 
759
- console.log(
760
- `[voice-ws] ← ${msg.type}${(msg as any).chunkId != null ? ` #${(msg as any).chunkId}` : ""}`,
761
- );
762
-
763
- if (msg.type === "stop") {
764
- session.reset();
765
  return;
766
  }
767
 
768
- // Pre-recognized text (from Web Speech API or legacy clients)
769
- if (msg.type === "text" && msg.text) {
770
- session.processText(msg.chunkId, msg.text);
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  return;
772
  }
773
 
774
- // Raw audio chunk — transcribe with Whisper
775
- if (msg.type === "chunk" && msg.audio) {
776
- // Check STT config before accepting chunks
777
- if (!ENV.forgeApiUrl || !ENV.forgeApiKey) {
778
  send({
779
  type: "error",
780
- message:
781
- "Server STT not configured. Set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY.",
782
- chunkId: msg.chunkId,
783
  });
784
  return;
785
  }
 
 
 
786
 
787
- const mimeType = msg.mimeType ?? "audio/m4a";
788
- const ext = mimeType.split("/")[1]?.split(";")[0] ?? "m4a";
789
- session.enqueue({
790
- chunkId: msg.chunkId,
791
- audioBase64: msg.audio,
792
- mimeType,
793
- ext,
 
 
 
 
 
 
794
  });
795
  }
796
  });
797
 
798
- ws.on("close", () => {
799
- console.log("[voice-ws] 🔌 client disconnected");
800
- session.reset();
801
- });
802
- ws.on("error", (err) => {
803
- console.error("[voice-ws] ❌ socket error:", err);
804
  });
805
  });
806
 
807
  console.log(
808
- "[voice-ws] ✅ WebSocket registered at /ws/voice (server-side STT pipeline)",
809
  );
810
  }
 
1
  /**
2
+ * Persistent real-time Arabic speech bridge.
3
  *
4
+ * The mobile/web client streams sequenced PCM16 frames here. One persistent
5
+ * recognizer WebSocket is retained for each logical session, including across
6
+ * short client reconnects. No live audio is sent through Whisper HTTP.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  */
8
+ import { WebSocket, WebSocketServer } from "ws";
 
9
  import type { Server } from "http";
10
+ import {
11
+ OrderedAudioFrames,
12
+ type BufferedAudioFrame,
13
+ } from "../shared/realtime-voice-protocol";
14
+
15
+ const SESSION_RETENTION_MS = 30000;
16
+ const UPSTREAM_RECONNECT_INITIAL_MS = 250;
17
+ const UPSTREAM_RECONNECT_MAX_MS = 8000;
18
+ const MAX_UPSTREAM_REPLAY_FRAMES = 600;
19
+
20
+ type SendMessage = (message: Record<string, unknown>) => void;
21
+
22
+ interface PcmStartMessage {
23
+ type: "pcm_start";
24
+ sessionId: string;
25
+ sampleRate?: number;
26
+ firstAudioSequence?: number;
27
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
+ interface PcmMessage {
30
+ type: "pcm";
31
+ sessionId: string;
32
+ audioSequence: number;
33
+ audio: string;
 
 
 
 
 
 
 
 
34
  }
35
 
36
+ interface PcmStopMessage {
37
+ type: "pcm_stop";
38
+ sessionId: string;
39
+ lastAudioSequence?: number;
 
 
40
  }
41
 
42
+ function sendJson(socket: WebSocket, message: Record<string, unknown>): void {
43
+ if (socket.readyState === WebSocket.OPEN) {
44
+ socket.send(JSON.stringify(message));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
  }
47
 
48
+ function recognizerWebSocketUrl(): string {
49
+ const configured =
50
+ process.env.WHISPER_VAD_WS_URL ??
51
+ process.env.WHISPER_VAD_URL ??
52
+ process.env.EXPO_PUBLIC_WHISPER_VAD_URL ??
53
+ "https://taqarub-vosk-ar.hf.space";
54
+ const raw = configured.trim().replace(/^['"]|['"]$/g, "").replace(/\/+$/, "");
55
+ if (!raw) {
56
+ throw new Error(
57
+ "Persistent recognizer is not configured. Set WHISPER_VAD_URL.",
58
+ );
 
59
  }
60
+ if (raw.endsWith("/ws/transcribe")) {
61
+ return raw.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
 
 
 
62
  }
63
+ return `${raw.replace(/^https:/, "wss:").replace(/^http:/, "ws:")}/ws/transcribe`;
64
+ }
65
 
66
+ function transcriptWords(text: string): string[] {
67
+ return text
68
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
69
+ .trim()
70
+ .split(/\s+/)
71
+ .filter(Boolean);
72
  }
73
 
74
+ class PersistentArabicRecognizer {
75
+ private socket: WebSocket | null = null;
76
+ private connectPromise: Promise<void> | null = null;
77
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
78
+ private reconnectAttempt = 0;
79
+ private handshakeReady = false;
80
+ private active = true;
81
+ private lastAcknowledgedSequence = -1;
82
+ private replayFrames = new Map<number, BufferedAudioFrame>();
83
+ private latestRevision = new Map<string, number>();
84
+ private finalizedUtterances = new Set<string>();
85
+ private finalEventHistory = new Map<number, Record<string, unknown>>();
86
+ private latestInterimEvent: Record<string, unknown> | null = null;
87
+ private nextUtteranceIndex = 0;
88
+ private upstreamProtocolVersion = 1;
89
+ private legacyRevision = 0;
90
+ private legacyInterim = "";
 
91
 
92
  constructor(
93
+ readonly sessionId: string,
94
+ private readonly sampleRate: number,
95
+ private readonly emit: SendMessage,
96
  ) {}
97
 
98
+ start(): Promise<void> {
99
+ this.active = true;
100
+ return this.ensureConnected();
 
 
 
 
 
 
 
 
 
 
 
 
101
  }
102
 
103
+ accept(frame: BufferedAudioFrame): void {
104
+ if (frame.audioSequence <= this.lastAcknowledgedSequence) {
105
+ this.emitAck();
106
+ return;
107
+ }
108
+ if (!this.replayFrames.has(frame.audioSequence)) {
109
+ if (this.replayFrames.size >= MAX_UPSTREAM_REPLAY_FRAMES) {
110
+ this.emit({
111
+ type: "error",
112
+ sessionId: this.sessionId,
113
+ message: "Recognizer replay buffer is full",
 
114
  });
115
+ return;
116
+ }
117
+ this.replayFrames.set(frame.audioSequence, frame);
118
+ }
119
+ this.sendFrame(frame);
120
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
121
+ void this.ensureConnected().catch(() => this.scheduleReconnect());
122
  }
123
  }
124
 
125
+ stop(): void {
126
+ this.active = false;
127
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
128
+ this.reconnectTimer = null;
129
+ if (this.socket?.readyState === WebSocket.OPEN) {
130
+ this.socket.send(
131
+ JSON.stringify({
132
+ type: "pcm_stop",
133
+ sessionId: this.sessionId,
134
+ }),
 
 
 
 
 
 
 
 
 
 
 
135
  );
 
 
 
 
 
 
 
136
  }
137
+ }
138
 
139
+ close(): void {
140
+ this.active = false;
141
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
142
+ this.reconnectTimer = null;
143
+ this.socket?.close();
144
+ this.socket = null;
145
+ this.connectPromise = null;
146
+ }
 
 
 
 
 
 
147
 
148
+ acknowledgedSequence(): number {
149
+ return this.lastAcknowledgedSequence;
150
+ }
 
 
 
 
 
151
 
152
+ replayTranscriptEvents(): void {
153
+ for (const event of [...this.finalEventHistory.entries()]
154
+ .sort(([left], [right]) => left - right)
155
+ .map(([, value]) => value)) {
156
+ this.emit(event);
157
+ }
158
+ if (this.latestInterimEvent) this.emit(this.latestInterimEvent);
159
  }
160
 
161
+ private ensureConnected(): Promise<void> {
162
+ if (
163
+ this.socket?.readyState === WebSocket.OPEN &&
164
+ this.handshakeReady
165
  ) {
166
+ return Promise.resolve();
167
+ }
168
+ if (this.connectPromise) return this.connectPromise;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
+ const promise = new Promise<void>((resolve, reject) => {
171
+ let url: string;
172
+ try {
173
+ url = recognizerWebSocketUrl();
174
+ } catch (error) {
175
+ reject(error);
176
+ return;
 
177
  }
 
 
178
 
179
+ const socket = new WebSocket(url, { perMessageDeflate: false });
180
+ this.socket = socket;
181
+ this.handshakeReady = false;
182
+ let connected = false;
183
+ const timeout = setTimeout(() => {
184
+ if (connected) return;
185
+ socket.close();
186
+ reject(new Error(`Timed out connecting to Arabic recognizer at ${url}`));
187
+ }, 8000);
188
+
189
+ socket.on("message", (raw) => {
190
+ let message: Record<string, any>;
191
+ try {
192
+ message = JSON.parse(raw.toString());
193
+ } catch {
194
+ return;
195
+ }
196
 
197
+ if (message.type === "ready") {
198
+ this.upstreamProtocolVersion = Number(
199
+ message.protocolVersion ?? 1,
200
+ );
201
+ const bufferedSequences = [...this.replayFrames.keys()];
202
+ const firstAudioSequence =
203
+ bufferedSequences.length > 0
204
+ ? Math.min(...bufferedSequences)
205
+ : this.lastAcknowledgedSequence + 1;
206
+ socket.send(
207
+ JSON.stringify({
208
+ type: "pcm_start",
209
+ sessionId: this.sessionId,
210
+ sampleRate: this.sampleRate,
211
+ firstAudioSequence,
212
+ firstUtteranceIndex: this.nextUtteranceIndex,
213
+ language: "ar",
214
+ }),
215
+ );
216
+ return;
217
+ }
218
 
219
+ if (message.type === "pcm_ready") {
220
+ connected = true;
221
+ clearTimeout(timeout);
222
+ this.handshakeReady = true;
223
+ this.reconnectAttempt = 0;
224
+ this.replay();
225
+ resolve();
226
+ return;
227
+ }
228
 
229
+ if (message.type === "audio_ack") {
230
+ const sequence = Number(message.audioSequence);
231
+ if (Number.isFinite(sequence)) {
232
+ this.lastAcknowledgedSequence = Math.max(
233
+ this.lastAcknowledgedSequence,
234
+ sequence,
235
+ );
236
+ for (const key of this.replayFrames.keys()) {
237
+ if (key <= this.lastAcknowledgedSequence) {
238
+ this.replayFrames.delete(key);
239
+ }
240
+ }
241
+ this.emitAck();
242
+ }
243
+ return;
244
+ }
245
 
246
+ if (message.type === "interim" || message.type === "final") {
247
+ this.forwardTranscript(message);
248
+ return;
249
+ }
250
 
251
+ if (
252
+ message.type === "partial_transcript" ||
253
+ message.type === "final_transcript"
254
+ ) {
255
+ this.forwardLegacyTranscript(message);
256
+ return;
257
+ }
258
 
259
+ if (message.type === "error") {
260
+ this.emit({
261
+ type: "error",
262
+ sessionId: this.sessionId,
263
+ message: String(message.message ?? "Recognizer error"),
264
+ });
265
+ }
266
+ });
267
 
268
+ socket.on("error", (error) => {
269
+ if (!connected) {
270
+ clearTimeout(timeout);
271
+ reject(error);
272
+ }
273
+ });
274
 
275
+ socket.on("close", () => {
276
+ clearTimeout(timeout);
277
+ if (this.socket === socket) this.socket = null;
278
+ this.handshakeReady = false;
279
+ this.connectPromise = null;
280
+ if (this.active) this.scheduleReconnect();
281
+ });
282
+ }).finally(() => {
283
+ this.connectPromise = null;
284
+ });
285
 
286
+ this.connectPromise = promise;
287
+ return promise;
288
+ }
 
289
 
290
+ private scheduleReconnect(): void {
291
+ if (!this.active || this.reconnectTimer) return;
292
+ const delay = Math.min(
293
+ UPSTREAM_RECONNECT_MAX_MS,
294
+ UPSTREAM_RECONNECT_INITIAL_MS * 2 ** this.reconnectAttempt++,
295
+ );
296
+ this.reconnectTimer = setTimeout(() => {
297
+ this.reconnectTimer = null;
298
+ void this.ensureConnected().catch(() => this.scheduleReconnect());
299
+ }, delay);
300
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
+ private sendFrame(frame: BufferedAudioFrame): void {
303
+ if (
304
+ !this.handshakeReady ||
305
+ this.socket?.readyState !== WebSocket.OPEN
306
+ ) {
307
+ return;
308
+ }
309
+ this.socket.send(
310
+ JSON.stringify({
311
+ type: "pcm",
312
+ sessionId: this.sessionId,
313
+ audioSequence: frame.audioSequence,
314
+ chunkId: frame.audioSequence,
315
+ sampleRate: this.sampleRate,
316
+ audio: frame.audio,
317
+ }),
318
+ );
319
+ if (this.upstreamProtocolVersion < 2) {
320
+ this.lastAcknowledgedSequence = Math.max(
321
+ this.lastAcknowledgedSequence,
322
+ frame.audioSequence,
323
+ );
324
+ this.replayFrames.delete(frame.audioSequence);
325
+ this.emitAck();
326
  }
327
  }
 
 
 
328
 
329
+ private replay(): void {
330
+ const frames = [...this.replayFrames.values()].sort(
331
+ (left, right) => left.audioSequence - right.audioSequence,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  );
333
+ for (const frame of frames) this.sendFrame(frame);
334
  }
335
 
336
+ private emitAck(): void {
337
+ this.emit({
338
+ type: "audio_ack",
339
+ sessionId: this.sessionId,
340
+ audioSequence: this.lastAcknowledgedSequence,
341
+ });
 
342
  }
343
 
344
+ private forwardTranscript(message: Record<string, any>): void {
345
+ const utteranceId = String(message.utteranceId ?? "");
346
+ const revision = Number(message.revision);
347
+ const utteranceIndex = Number(message.utteranceIndex);
348
+ const text = String(message.text ?? "").trim();
349
+ if (
350
+ !utteranceId ||
351
+ !Number.isFinite(revision) ||
352
+ !Number.isFinite(utteranceIndex)
353
+ ) {
354
+ return;
355
+ }
356
+
357
+ const previousRevision = this.latestRevision.get(utteranceId) ?? -1;
358
+ if (message.type === "interim") {
359
+ if (
360
+ this.finalizedUtterances.has(utteranceId) ||
361
+ revision <= previousRevision
362
+ ) {
363
+ return;
364
+ }
365
+ } else {
366
+ if (
367
+ this.finalizedUtterances.has(utteranceId) ||
368
+ revision < previousRevision
369
+ ) {
370
+ return;
371
+ }
372
+ this.finalizedUtterances.add(utteranceId);
373
+ }
374
+
375
+ this.latestRevision.set(utteranceId, revision);
376
+ const event: Record<string, unknown> = {
377
+ type: message.type,
378
+ sessionId: this.sessionId,
379
+ utteranceId,
380
+ utteranceIndex,
381
+ revision,
382
+ audioSequence: Number(message.audioSequence),
383
+ text,
384
+ words: message.type === "final" ? transcriptWords(text) : undefined,
385
+ };
386
+ if (message.type === "final") {
387
+ this.nextUtteranceIndex = Math.max(
388
+ this.nextUtteranceIndex,
389
+ utteranceIndex + 1,
390
+ );
391
+ this.finalEventHistory.set(utteranceIndex, event);
392
+ if (this.latestInterimEvent?.utteranceId === utteranceId) {
393
+ this.latestInterimEvent = null;
394
+ }
395
+ } else {
396
+ this.latestInterimEvent = event;
397
+ }
398
+ this.emit(event);
399
  }
400
 
401
+ private forwardLegacyTranscript(message: Record<string, any>): void {
402
+ const text = String(message.text ?? "").trim();
403
+ if (!text) return;
404
+
405
+ if (message.type === "partial_transcript") {
406
+ if (text === this.legacyInterim) return;
407
+ this.legacyInterim = text;
408
+ this.legacyRevision++;
409
+ this.forwardTranscript({
410
+ type: "interim",
411
+ utteranceId: `${this.sessionId}:${this.nextUtteranceIndex}`,
412
+ utteranceIndex: this.nextUtteranceIndex,
413
+ revision: this.legacyRevision,
414
+ audioSequence: Number(message.chunkId ?? -1),
415
+ text,
416
+ });
417
+ return;
418
+ }
419
 
420
+ this.legacyRevision++;
421
+ this.forwardTranscript({
422
+ type: "final",
423
+ utteranceId: `${this.sessionId}:${this.nextUtteranceIndex}`,
424
+ utteranceIndex: this.nextUtteranceIndex,
425
+ revision: this.legacyRevision,
426
+ audioSequence: Number(message.chunkId ?? -1),
427
+ text,
428
+ });
429
+ this.legacyRevision = 0;
430
+ this.legacyInterim = "";
431
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  }
433
 
434
+ class LiveRecognitionSession {
435
+ private sender: SendMessage = () => {};
436
+ private orderedAudio: OrderedAudioFrames;
437
+ private recognizer: PersistentArabicRecognizer;
438
+ private retentionTimer: ReturnType<typeof setTimeout> | null = null;
439
 
440
+ constructor(
441
+ readonly sessionId: string,
442
+ sampleRate: number,
443
+ firstAudioSequence: number,
444
+ private readonly onExpire: () => void,
445
+ ) {
446
+ this.orderedAudio = new OrderedAudioFrames(firstAudioSequence);
447
+ this.recognizer = new PersistentArabicRecognizer(
448
+ sessionId,
449
+ sampleRate,
450
+ (message) => this.sender(message),
451
  );
452
  }
453
 
454
+ async bind(sender: SendMessage): Promise<void> {
455
+ if (this.retentionTimer) clearTimeout(this.retentionTimer);
456
+ this.retentionTimer = null;
457
+ this.sender = sender;
458
+ await this.recognizer.start();
459
+ this.sender({
460
+ type: "pcm_ready",
461
+ sessionId: this.sessionId,
462
+ acknowledgedAudioSequence: this.recognizer.acknowledgedSequence(),
463
+ });
464
+ this.recognizer.replayTranscriptEvents();
465
  }
466
 
467
+ receive(message: PcmMessage): void {
468
+ const result = this.orderedAudio.receive({
469
+ audioSequence: message.audioSequence,
470
+ audio: message.audio,
471
+ });
472
+ if (result.duplicate) {
473
+ this.sender({
474
+ type: "audio_ack",
475
+ sessionId: this.sessionId,
476
+ audioSequence: this.recognizer.acknowledgedSequence(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  });
478
+ return;
479
+ }
480
+ for (const frame of result.ready) this.recognizer.accept(frame);
481
+ }
482
 
483
+ stop(): void {
484
+ this.recognizer.stop();
485
+ }
 
 
 
 
 
 
 
 
 
486
 
487
+ unbind(sender: SendMessage): void {
488
+ if (this.sender !== sender) return;
489
+ this.sender = () => {};
490
+ if (this.retentionTimer) clearTimeout(this.retentionTimer);
491
+ this.retentionTimer = setTimeout(() => {
492
+ this.recognizer.close();
493
+ this.onExpire();
494
+ }, SESSION_RETENTION_MS);
495
+ }
496
+
497
+ closeSoon(): void {
498
+ if (this.retentionTimer) clearTimeout(this.retentionTimer);
499
+ this.retentionTimer = setTimeout(() => {
500
+ this.recognizer.close();
501
+ this.onExpire();
502
+ }, 5000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  }
 
504
  }
505
 
506
+ const sessions = new Map<string, LiveRecognitionSession>();
507
 
508
+ function getOrCreateSession(message: PcmStartMessage): LiveRecognitionSession {
509
+ const existing = sessions.get(message.sessionId);
510
+ if (existing) return existing;
511
+
512
+ const session = new LiveRecognitionSession(
513
+ message.sessionId,
514
+ message.sampleRate ?? 16000,
515
+ message.firstAudioSequence ?? 0,
516
+ () => sessions.delete(message.sessionId),
517
+ );
518
+ sessions.set(message.sessionId, session);
519
+ return session;
520
  }
521
 
522
+ export function registerVoiceWebSocket(httpServer: Server): void {
523
+ const server = new WebSocketServer({
524
  server: httpServer,
525
  path: "/ws/voice",
526
  perMessageDeflate: false,
527
  });
528
 
529
+ server.on("connection", (socket) => {
530
+ const boundSessionIds = new Set<string>();
531
+ const send: SendMessage = (message) => sendJson(socket, message);
532
+ send({ type: "ready", protocolVersion: 2 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
 
534
+ socket.on("message", (raw) => {
535
+ let message: Record<string, any>;
536
  try {
537
+ message = JSON.parse(raw.toString());
538
  } catch {
539
  return;
540
  }
541
 
542
+ if (message.type === "ping") {
543
+ send({ type: "pong" });
 
 
 
 
544
  return;
545
  }
546
 
547
+ if (message.type === "pcm_start") {
548
+ const start = message as PcmStartMessage;
549
+ if (!start.sessionId) {
550
+ send({ type: "error", message: "sessionId is required" });
551
+ return;
552
+ }
553
+ const session = getOrCreateSession(start);
554
+ boundSessionIds.add(start.sessionId);
555
+ void session.bind(send).catch((error) => {
556
+ send({
557
+ type: "error",
558
+ sessionId: start.sessionId,
559
+ message:
560
+ error instanceof Error ? error.message : String(error),
561
+ });
562
+ });
563
  return;
564
  }
565
 
566
+ if (message.type === "pcm") {
567
+ const pcm = message as PcmMessage;
568
+ const session = sessions.get(pcm.sessionId);
569
+ if (!session) {
570
  send({
571
  type: "error",
572
+ sessionId: pcm.sessionId,
573
+ message: "Unknown PCM session; send pcm_start first",
 
574
  });
575
  return;
576
  }
577
+ session.receive(pcm);
578
+ return;
579
+ }
580
 
581
+ if (message.type === "pcm_stop") {
582
+ const stop = message as PcmStopMessage;
583
+ const session = sessions.get(stop.sessionId);
584
+ session?.stop();
585
+ session?.closeSoon();
586
+ return;
587
+ }
588
+
589
+ if (message.type === "chunk") {
590
+ send({
591
+ type: "error",
592
+ message:
593
+ "Recorded audio chunks are no longer supported; stream PCM continuously.",
594
  });
595
  }
596
  });
597
 
598
+ socket.on("close", () => {
599
+ for (const sessionId of boundSessionIds) {
600
+ sessions.get(sessionId)?.unbind(send);
601
+ }
 
 
602
  });
603
  });
604
 
605
  console.log(
606
+ "[voice-ws] persistent PCM WebSocket registered at /ws/voice",
607
  );
608
  }
shared/realtime-voice-protocol.ts ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface BufferedAudioFrame {
2
+ audioSequence: number;
3
+ audio: string;
4
+ }
5
+
6
+ /**
7
+ * Bounded replay buffer. Frames remain here until the server acknowledges
8
+ * them, which makes reconnect replay deterministic and idempotent.
9
+ */
10
+ export class AudioReplayBuffer {
11
+ private frames: BufferedAudioFrame[] = [];
12
+
13
+ constructor(private readonly capacity: number) {
14
+ if (!Number.isInteger(capacity) || capacity < 1) {
15
+ throw new Error("AudioReplayBuffer capacity must be a positive integer");
16
+ }
17
+ }
18
+
19
+ push(frame: BufferedAudioFrame): BufferedAudioFrame | null {
20
+ if (
21
+ this.frames.some(
22
+ (existing) => existing.audioSequence === frame.audioSequence,
23
+ )
24
+ ) {
25
+ return null;
26
+ }
27
+
28
+ this.frames.push(frame);
29
+ this.frames.sort((a, b) => a.audioSequence - b.audioSequence);
30
+ return this.frames.length > this.capacity ? this.frames.shift()! : null;
31
+ }
32
+
33
+ acknowledge(audioSequence: number): void {
34
+ this.frames = this.frames.filter(
35
+ (frame) => frame.audioSequence > audioSequence,
36
+ );
37
+ }
38
+
39
+ unacknowledged(): BufferedAudioFrame[] {
40
+ return [...this.frames];
41
+ }
42
+
43
+ firstSequence(): number | null {
44
+ return this.frames[0]?.audioSequence ?? null;
45
+ }
46
+
47
+ clear(): void {
48
+ this.frames = [];
49
+ }
50
+
51
+ get size(): number {
52
+ return this.frames.length;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Turns potentially duplicated or reordered frames back into one contiguous
58
+ * stream. WebSocket normally preserves order, but reconnect replay can overlap
59
+ * with frames already accepted by the server.
60
+ */
61
+ export class OrderedAudioFrames {
62
+ private pending = new Map<number, BufferedAudioFrame>();
63
+
64
+ constructor(
65
+ private nextSequence: number,
66
+ private readonly capacity = 600,
67
+ ) {}
68
+
69
+ receive(frame: BufferedAudioFrame): {
70
+ duplicate: boolean;
71
+ ready: BufferedAudioFrame[];
72
+ } {
73
+ if (
74
+ frame.audioSequence < this.nextSequence ||
75
+ this.pending.has(frame.audioSequence)
76
+ ) {
77
+ return { duplicate: true, ready: [] };
78
+ }
79
+
80
+ this.pending.set(frame.audioSequence, frame);
81
+ if (this.pending.size > this.capacity) {
82
+ const highest = Math.max(...this.pending.keys());
83
+ this.pending.delete(highest);
84
+ }
85
+
86
+ const ready: BufferedAudioFrame[] = [];
87
+ while (this.pending.has(this.nextSequence)) {
88
+ ready.push(this.pending.get(this.nextSequence)!);
89
+ this.pending.delete(this.nextSequence);
90
+ this.nextSequence++;
91
+ }
92
+ return { duplicate: false, ready };
93
+ }
94
+
95
+ expectedSequence(): number {
96
+ return this.nextSequence;
97
+ }
98
+ }
99
+
100
+ export interface RealtimeTranscriptEvent {
101
+ sessionId: string;
102
+ utteranceId: string;
103
+ utteranceIndex: number;
104
+ revision: number;
105
+ text: string;
106
+ }
107
+
108
+ export interface TranscriptUpdate {
109
+ accepted: boolean;
110
+ confirmedText: string;
111
+ interimText: string;
112
+ appendedFinals: string[];
113
+ }
114
+
115
+ /**
116
+ * Applies recognizer revisions while keeping exactly one replaceable interim
117
+ * hypothesis. Finals are buffered until every earlier utterance is present.
118
+ */
119
+ export class TranscriptAccumulator {
120
+ private latestRevision = new Map<string, number>();
121
+ private finalized = new Set<string>();
122
+ private pendingFinals = new Map<number, RealtimeTranscriptEvent>();
123
+ private nextFinalIndex = 0;
124
+ private activeInterim: RealtimeTranscriptEvent | null = null;
125
+ private confirmedParts: string[] = [];
126
+ private sessionId: string | null = null;
127
+
128
+ reset(sessionId?: string): void {
129
+ this.latestRevision.clear();
130
+ this.finalized.clear();
131
+ this.pendingFinals.clear();
132
+ this.nextFinalIndex = 0;
133
+ this.activeInterim = null;
134
+ this.confirmedParts = [];
135
+ this.sessionId = sessionId ?? null;
136
+ }
137
+
138
+ /** Start a replacement audio session without discarding confirmed text. */
139
+ continueSession(sessionId?: string): void {
140
+ const confirmedParts = [...this.confirmedParts];
141
+ this.reset(sessionId);
142
+ this.confirmedParts = confirmedParts;
143
+ }
144
+
145
+ applyInterim(event: RealtimeTranscriptEvent): TranscriptUpdate {
146
+ if (!this.acceptSession(event.sessionId)) return this.snapshot(false);
147
+ if (this.finalized.has(event.utteranceId)) return this.snapshot(false);
148
+
149
+ const previousRevision = this.latestRevision.get(event.utteranceId) ?? -1;
150
+ if (event.revision <= previousRevision) return this.snapshot(false);
151
+ if (event.utteranceIndex < this.nextFinalIndex) return this.snapshot(false);
152
+
153
+ this.latestRevision.set(event.utteranceId, event.revision);
154
+ this.activeInterim = event;
155
+ return this.snapshot(true);
156
+ }
157
+
158
+ applyFinal(event: RealtimeTranscriptEvent): TranscriptUpdate {
159
+ if (!this.acceptSession(event.sessionId)) return this.snapshot(false);
160
+ if (this.finalized.has(event.utteranceId)) return this.snapshot(false);
161
+
162
+ const previousRevision = this.latestRevision.get(event.utteranceId) ?? -1;
163
+ if (event.revision < previousRevision) return this.snapshot(false);
164
+ if (event.utteranceIndex < this.nextFinalIndex) return this.snapshot(false);
165
+
166
+ const pending = this.pendingFinals.get(event.utteranceIndex);
167
+ if (pending && pending.revision >= event.revision) {
168
+ return this.snapshot(false);
169
+ }
170
+
171
+ this.latestRevision.set(event.utteranceId, event.revision);
172
+ this.pendingFinals.set(event.utteranceIndex, event);
173
+
174
+ const appendedFinals: string[] = [];
175
+ while (this.pendingFinals.has(this.nextFinalIndex)) {
176
+ const next = this.pendingFinals.get(this.nextFinalIndex)!;
177
+ this.pendingFinals.delete(this.nextFinalIndex);
178
+ this.finalized.add(next.utteranceId);
179
+ const text = next.text.replace(/\s+/g, " ").trim();
180
+ if (text) {
181
+ this.confirmedParts.push(text);
182
+ appendedFinals.push(text);
183
+ }
184
+ if (this.activeInterim?.utteranceId === next.utteranceId) {
185
+ this.activeInterim = null;
186
+ }
187
+ this.nextFinalIndex++;
188
+ }
189
+
190
+ return this.snapshot(true, appendedFinals);
191
+ }
192
+
193
+ private acceptSession(sessionId: string): boolean {
194
+ if (this.sessionId === null) {
195
+ this.sessionId = sessionId;
196
+ return true;
197
+ }
198
+ return this.sessionId === sessionId;
199
+ }
200
+
201
+ private snapshot(
202
+ accepted: boolean,
203
+ appendedFinals: string[] = [],
204
+ ): TranscriptUpdate {
205
+ return {
206
+ accepted,
207
+ confirmedText: this.confirmedParts.join(" "),
208
+ interimText: this.activeInterim?.text.trim() ?? "",
209
+ appendedFinals,
210
+ };
211
+ }
212
+ }