@juspay/neurolink 13.6.5 → 13.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +6 -2
  2. package/dist/auth/sessionManager.js +8 -3
  3. package/dist/browser/neurolink.min.js +480 -460
  4. package/dist/cli/commands/serve.js +11 -1
  5. package/dist/cli/commands/transcribe.js +39 -3
  6. package/dist/constants/langfuse.d.ts +2 -0
  7. package/dist/constants/langfuse.js +2 -0
  8. package/dist/core/redisConversationMemoryManager.js +6 -16
  9. package/dist/factories/sttDescriptors.js +7 -0
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.js +4 -2
  12. package/dist/neurolink.js +33 -1
  13. package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
  14. package/dist/observability/exporters/langfuseExporter.js +29 -8
  15. package/dist/observability/otlpEncoding.js +2 -2
  16. package/dist/server/abstract/baseServerAdapter.js +2 -0
  17. package/dist/server/adapters/expressAdapter.js +1 -1
  18. package/dist/server/adapters/fastifyAdapter.js +4 -1
  19. package/dist/server/adapters/honoAdapter.js +1 -1
  20. package/dist/server/adapters/koaAdapter.js +1 -1
  21. package/dist/server/routes/transcribeRoutes.d.ts +0 -8
  22. package/dist/server/routes/transcribeRoutes.js +130 -3
  23. package/dist/server/utils/validation.d.ts +409 -15
  24. package/dist/server/utils/validation.js +106 -4
  25. package/dist/server/websocket/frameQueue.d.ts +16 -0
  26. package/dist/server/websocket/frameQueue.js +55 -0
  27. package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
  28. package/dist/server/websocket/realtimeTranscription.js +603 -0
  29. package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
  30. package/dist/server/websocket/transcribeWebSocket.js +263 -64
  31. package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
  32. package/dist/telemetry/attributes.d.ts +1 -1
  33. package/dist/telemetry/attributes.js +84 -8
  34. package/dist/types/cli.d.ts +6 -0
  35. package/dist/types/exporter.d.ts +3 -1
  36. package/dist/types/server.d.ts +304 -2
  37. package/dist/types/stt.d.ts +38 -3
  38. package/dist/types/transcribe.d.ts +401 -5
  39. package/dist/utils/redis.d.ts +17 -0
  40. package/dist/utils/redis.js +31 -0
  41. package/dist/voice/audioLevel.d.ts +14 -0
  42. package/dist/voice/audioLevel.js +60 -0
  43. package/dist/voice/codeMix.d.ts +40 -0
  44. package/dist/voice/codeMix.js +282 -0
  45. package/dist/voice/correction/align.d.ts +12 -0
  46. package/dist/voice/correction/align.js +48 -0
  47. package/dist/voice/correction/guards.d.ts +39 -0
  48. package/dist/voice/correction/guards.js +122 -0
  49. package/dist/voice/correction/index.d.ts +25 -3
  50. package/dist/voice/correction/index.js +132 -4
  51. package/dist/voice/correction/rewrite.d.ts +64 -3
  52. package/dist/voice/correction/rewrite.js +295 -25
  53. package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
  54. package/dist/voice/eval/sttEvalLangfuse.js +182 -0
  55. package/dist/voice/eval/sttEvalStore.d.ts +27 -0
  56. package/dist/voice/eval/sttEvalStore.js +397 -0
  57. package/dist/voice/index.js +3 -0
  58. package/dist/voice/languages.d.ts +34 -0
  59. package/dist/voice/languages.js +290 -0
  60. package/dist/voice/modelAssets.d.ts +28 -0
  61. package/dist/voice/modelAssets.js +113 -0
  62. package/dist/voice/output/render.d.ts +32 -0
  63. package/dist/voice/output/render.js +368 -0
  64. package/dist/voice/providers/OpenAISTT.d.ts +11 -1
  65. package/dist/voice/providers/OpenAISTT.js +57 -6
  66. package/dist/voice/session/transcriptionSession.d.ts +55 -0
  67. package/dist/voice/session/transcriptionSession.js +225 -0
  68. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
  69. package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
  70. package/dist/voice/streaming/endpointer.d.ts +50 -17
  71. package/dist/voice/streaming/endpointer.js +115 -19
  72. package/dist/voice/transcribe.d.ts +18 -1
  73. package/dist/voice/transcribe.js +459 -18
  74. package/dist/voice/transcribeStream.d.ts +18 -1
  75. package/dist/voice/transcribeStream.js +413 -16
  76. package/dist/voice/vad/assets.d.ts +36 -0
  77. package/dist/voice/vad/assets.js +142 -0
  78. package/dist/voice/vad/frameScorer.d.ts +28 -0
  79. package/dist/voice/vad/frameScorer.js +90 -0
  80. package/dist/voice/vad/sileroVad.d.ts +43 -0
  81. package/dist/voice/vad/sileroVad.js +178 -0
  82. package/dist/voice/whistle/assets.d.ts +3 -4
  83. package/dist/voice/whistle/assets.js +11 -95
  84. package/docs-site/static/search-index.json +21 -6
  85. package/models/silero-vad/README.md +19 -0
  86. package/models/silero-vad/silero_vad.onnx +0 -0
  87. package/package.json +4 -1
@@ -0,0 +1,603 @@
1
+ /**
2
+ * OpenAI Realtime transcription protocol over `transcribeStream()`.
3
+ *
4
+ * A client connects to `/v1/realtime?intent=transcription` and speaks OpenAI's
5
+ * Realtime transcription protocol, so the stock LiveKit
6
+ * `openai.STT(base_url=…, use_realtime=True)` plugin, Pipecat or the OpenAI
7
+ * SDK stream to NeuroLink with no adapter:
8
+ *
9
+ * - config: `session.update` (`session.type: "transcription"`, audio under
10
+ * `session.audio.input`) or the beta `transcription_session.update`;
11
+ * - audio: `input_audio_buffer.append` with base64 PCM16 at the declared rate
12
+ * (24 kHz for both dialects' default);
13
+ * - `input_audio_buffer.commit` closes the open utterance; so does an audio
14
+ * gap, since a client that stops sending (a muted mic) sends nothing else.
15
+ *
16
+ * Each utterance is one conversation item. Its events go out in this order:
17
+ * - `input_audio_buffer.speech_started`;
18
+ * - `…input_audio_transcription.delta` per newly agreed words. A client
19
+ * appends deltas, so only the prefix two passes agreed on is sent; words
20
+ * still in flux never reach it;
21
+ * - `input_audio_buffer.speech_stopped`, `input_audio_buffer.committed`;
22
+ * - `…input_audio_transcription.completed` with the corrected text (or the
23
+ * raw final; see `ServerSTTRealtimeProfile.completed`).
24
+ *
25
+ * Items complete strictly in order: a client keeps one running text, so a
26
+ * later utterance's deltas wait until the earlier one has completed.
27
+ * NeuroLink's own detail goes out as extra event types a stock client ignores:
28
+ * `neurolink.final`, `neurolink.corrected` and `neurolink.output`.
29
+ *
30
+ * Everything NeuroLink-specific (engine, fallback, dictionary, correction,
31
+ * output languages) comes from the profile the client's `model` names; a
32
+ * realtime client cannot send it.
33
+ *
34
+ * @module server/websocket/realtimeTranscription
35
+ */
36
+ import { randomUUID } from "node:crypto";
37
+ import { listConfiguredSTTProviders, resolveSTTEndpoints, } from "../../factories/sttDescriptors.js";
38
+ import { STTProcessor } from "../../utils/sttProcessor.js";
39
+ import { logger } from "../../utils/logger.js";
40
+ import { isCorrectionEnabled } from "../../voice/correction/index.js";
41
+ import { STTRealtimeProfilesSchema } from "../utils/validation.js";
42
+ import { FrameQueue } from "./frameQueue.js";
43
+ const OPEN = 1;
44
+ const DEFAULT_RATE = 24_000;
45
+ const DEFAULT_CORRECTION_WAIT_MS = 3000;
46
+ /** Audio waiting for the engine beyond this (about 5.8 min at 24 kHz) closes the socket. */
47
+ const MAX_QUEUED_BYTES = 16 * 1024 * 1024;
48
+ /** No audio for this long while speech may be open: the client stopped sending. */
49
+ const IDLE_FLUSH_MS = 700;
50
+ /** Silence pushed to close an utterance (more than the endpointer's 750 ms closing pause). */
51
+ const FLUSH_SILENCE_MS = 1200;
52
+ /** Shortest closing pause a client's `silence_duration_ms` may set. */
53
+ const MIN_CLIENT_SILENCE_MS = 300;
54
+ /**
55
+ * Operator profiles: `NEUROLINK_STT_REALTIME_PROFILES` (JSON), overlaid by
56
+ * the attach option. An invalid env value is logged and ignored.
57
+ */
58
+ export function resolveRealtimeProfiles(fromOptions = {}, env = process.env) {
59
+ const raw = env.NEUROLINK_STT_REALTIME_PROFILES?.trim();
60
+ let fromEnv = {};
61
+ if (raw) {
62
+ try {
63
+ const parsed = STTRealtimeProfilesSchema.safeParse(JSON.parse(raw));
64
+ if (parsed.success) {
65
+ fromEnv = parsed.data;
66
+ }
67
+ else {
68
+ logger.warn("[RealtimeTranscription] NEUROLINK_STT_REALTIME_PROFILES is invalid and was ignored", { issues: parsed.error.issues.map((i) => i.path.join(".")) });
69
+ }
70
+ }
71
+ catch {
72
+ logger.warn("[RealtimeTranscription] NEUROLINK_STT_REALTIME_PROFILES is not JSON and was ignored");
73
+ }
74
+ }
75
+ return { ...fromEnv, ...fromOptions };
76
+ }
77
+ /** The profile a client's `model` selects. */
78
+ function profileFor(model, profiles) {
79
+ if (model && Object.hasOwn(profiles, model)) {
80
+ return profiles[model];
81
+ }
82
+ if (model) {
83
+ const providers = new Set([
84
+ ...listConfiguredSTTProviders(),
85
+ ...STTProcessor.listProviders(),
86
+ ...Object.keys(resolveSTTEndpoints()),
87
+ ]);
88
+ if (providers.has(model)) {
89
+ return { ...(profiles.default ?? {}), provider: model };
90
+ }
91
+ }
92
+ return profiles.default ?? {};
93
+ }
94
+ function asRecord(value) {
95
+ return value !== null && typeof value === "object" && !Array.isArray(value)
96
+ ? value
97
+ : undefined;
98
+ }
99
+ function asString(value) {
100
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
101
+ }
102
+ /**
103
+ * The session a client asked for, in either dialect, or the reason it cannot
104
+ * be served. Only PCM16 is accepted.
105
+ */
106
+ function readSession(type, session) {
107
+ if (type === "session.update") {
108
+ if (session.type !== undefined && session.type !== "transcription") {
109
+ return { error: 'Only session.type "transcription" is served here' };
110
+ }
111
+ const input = asRecord(asRecord(session.audio)?.input) ?? {};
112
+ const format = asRecord(input.format) ?? {};
113
+ if (format.type !== undefined && format.type !== "audio/pcm") {
114
+ return { error: 'Only audio format "audio/pcm" (PCM16) is supported' };
115
+ }
116
+ const rate = typeof format.rate === "number" ? format.rate : DEFAULT_RATE;
117
+ const transcription = asRecord(input.transcription) ?? {};
118
+ const turn = asRecord(input.turn_detection) ?? {};
119
+ return {
120
+ dialect: "ga",
121
+ rate,
122
+ model: asString(transcription.model),
123
+ language: asString(transcription.language),
124
+ prompt: asString(transcription.prompt),
125
+ silenceMs: typeof turn.silence_duration_ms === "number"
126
+ ? turn.silence_duration_ms
127
+ : undefined,
128
+ };
129
+ }
130
+ if (session.input_audio_format !== undefined &&
131
+ session.input_audio_format !== "pcm16") {
132
+ return { error: 'Only input_audio_format "pcm16" is supported' };
133
+ }
134
+ const transcription = asRecord(session.input_audio_transcription) ?? {};
135
+ const turn = asRecord(session.turn_detection) ?? {};
136
+ return {
137
+ dialect: "beta",
138
+ rate: DEFAULT_RATE,
139
+ model: asString(transcription.model),
140
+ language: asString(transcription.language),
141
+ prompt: asString(transcription.prompt),
142
+ silenceMs: typeof turn.silence_duration_ms === "number"
143
+ ? turn.silence_duration_ms
144
+ : undefined,
145
+ };
146
+ }
147
+ /** The session object echoed in `session.created` / `…updated`. */
148
+ function sessionObject(id, config) {
149
+ const transcription = {
150
+ model: config?.model ?? null,
151
+ language: config?.language ?? null,
152
+ prompt: config?.prompt ?? null,
153
+ };
154
+ if (config?.dialect === "beta") {
155
+ return {
156
+ id,
157
+ object: "realtime.transcription_session",
158
+ input_audio_format: "pcm16",
159
+ input_audio_transcription: transcription,
160
+ };
161
+ }
162
+ return {
163
+ id,
164
+ object: "realtime.transcription_session",
165
+ type: "transcription",
166
+ audio: {
167
+ input: {
168
+ format: { type: "audio/pcm", rate: config?.rate ?? DEFAULT_RATE },
169
+ transcription,
170
+ },
171
+ },
172
+ };
173
+ }
174
+ /**
175
+ * Serve one realtime transcription connection. The upgrade has already
176
+ * passed the origin and token checks.
177
+ */
178
+ export function serveRealtimeTranscription(client, req, neurolink, profiles) {
179
+ const connection = randomUUID().replace(/-/gu, "").slice(0, 16);
180
+ const sessionId = `sess_${connection}`;
181
+ let eventSeq = 0;
182
+ const send = (type, body = {}) => {
183
+ if (client.readyState !== OPEN) {
184
+ return;
185
+ }
186
+ eventSeq++;
187
+ client.send(JSON.stringify({
188
+ event_id: `event_${connection}_${eventSeq}`,
189
+ type,
190
+ ...body,
191
+ }));
192
+ };
193
+ const fail = (message, code, close = false) => {
194
+ send("error", {
195
+ error: { type: "invalid_request_error", code, message, param: null },
196
+ });
197
+ if (close && client.readyState === OPEN) {
198
+ client.close(1008, code);
199
+ }
200
+ };
201
+ const intent = (() => {
202
+ try {
203
+ return new URL(req.url ?? "/", "http://localhost").searchParams.get("intent");
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ })();
209
+ if (intent !== "transcription") {
210
+ fail("Only ?intent=transcription is served on this path", "unsupported_intent", true);
211
+ return;
212
+ }
213
+ const queue = new FrameQueue();
214
+ let config;
215
+ let started = false;
216
+ let receivedSamples = 0;
217
+ let idleTimer;
218
+ let flushedSinceAudio = true;
219
+ const items = new Map();
220
+ /** Utterances in order; the first is the only one whose events go out. */
221
+ const order = [];
222
+ let previousItemId = null;
223
+ /** Utterances already completed; a late event for one must not reopen it. */
224
+ const finished = new Set();
225
+ const itemFor = (utterance) => {
226
+ const held = items.get(utterance);
227
+ if (held) {
228
+ return held;
229
+ }
230
+ const item = {
231
+ utterance,
232
+ itemId: `item_${connection}_${utterance}`,
233
+ started: false,
234
+ sent: "",
235
+ pending: [],
236
+ corrected: false,
237
+ done: finished.has(utterance),
238
+ };
239
+ if (item.done) {
240
+ return item;
241
+ }
242
+ items.set(utterance, item);
243
+ order.push(utterance);
244
+ order.sort((a, b) => a - b);
245
+ return item;
246
+ };
247
+ const isHead = (item) => order[0] === item.utterance;
248
+ const emit = (item, type, body) => {
249
+ if (item.done || isHead(item)) {
250
+ send(type, body);
251
+ }
252
+ else {
253
+ item.pending.push({ type, body });
254
+ }
255
+ };
256
+ /** Completes the head while it is ready, then lets the next one speak. */
257
+ const drain = () => {
258
+ for (;;) {
259
+ const head = order[0] === undefined ? undefined : items.get(order[0]);
260
+ if (!head || head.ready === undefined) {
261
+ return;
262
+ }
263
+ if (head.timer) {
264
+ clearTimeout(head.timer);
265
+ }
266
+ if (head.started) {
267
+ send("conversation.item.input_audio_transcription.completed", {
268
+ item_id: head.itemId,
269
+ content_index: 0,
270
+ transcript: head.ready,
271
+ neurolink: {
272
+ engine: head.engine ?? null,
273
+ language: head.language ?? null,
274
+ language_detected: head.languageDetected ?? null,
275
+ corrected: head.corrected,
276
+ },
277
+ });
278
+ }
279
+ head.done = true;
280
+ finished.add(head.utterance);
281
+ order.shift();
282
+ items.delete(head.utterance);
283
+ const next = order[0] === undefined ? undefined : items.get(order[0]);
284
+ if (next) {
285
+ for (const event of next.pending.splice(0)) {
286
+ send(event.type, event.body);
287
+ }
288
+ }
289
+ }
290
+ };
291
+ const settle = (item, transcript) => {
292
+ if (item.ready !== undefined) {
293
+ return;
294
+ }
295
+ item.ready = transcript;
296
+ drain();
297
+ };
298
+ const startItem = (item, startSeconds) => {
299
+ if (item.started) {
300
+ return;
301
+ }
302
+ item.started = true;
303
+ emit(item, "input_audio_buffer.speech_started", {
304
+ audio_start_ms: Math.max(0, Math.round(startSeconds * 1000)),
305
+ item_id: item.itemId,
306
+ });
307
+ };
308
+ const receivedSeconds = () => receivedSamples / (config?.rate ?? DEFAULT_RATE);
309
+ const flushUtterance = () => {
310
+ if (flushedSinceAudio || !started) {
311
+ return;
312
+ }
313
+ flushedSinceAudio = true;
314
+ const rate = config?.rate ?? DEFAULT_RATE;
315
+ queue.push(Buffer.alloc(Math.round((rate * FLUSH_SILENCE_MS) / 1000) * 2));
316
+ };
317
+ const run = async (options, profile) => {
318
+ const completedMode = profile.completed ?? "corrected";
319
+ const waitMs = profile.correctionWaitMs ?? DEFAULT_CORRECTION_WAIT_MS;
320
+ // The stream's own rule: without it no `corrected` event ever comes.
321
+ const correcting = completedMode === "corrected" &&
322
+ isCorrectionEnabled(options.dictionary, options.correction);
323
+ try {
324
+ for await (const event of neurolink.transcribeStream(options)) {
325
+ handle(event, correcting, waitMs);
326
+ }
327
+ // The stream is over: whatever still waits for a correction goes now.
328
+ for (const utterance of [...order]) {
329
+ const item = items.get(utterance);
330
+ if (item) {
331
+ settle(item, item.finalText ?? "");
332
+ }
333
+ }
334
+ if (client.readyState === OPEN) {
335
+ client.close(1000, "done");
336
+ }
337
+ }
338
+ catch (error) {
339
+ const message = error instanceof Error ? error.message : String(error);
340
+ logger.warn("[RealtimeTranscription] stream failed", { error: message });
341
+ send("error", {
342
+ error: {
343
+ type: "server_error",
344
+ code: "stream_failed",
345
+ message,
346
+ param: null,
347
+ },
348
+ });
349
+ if (client.readyState === OPEN) {
350
+ client.close(1011, "transcription failed");
351
+ }
352
+ }
353
+ };
354
+ const handle = (event, correcting, waitMs) => {
355
+ switch (event.type) {
356
+ case "interim": {
357
+ const item = itemFor(event.utterance);
358
+ if (item.done) {
359
+ return;
360
+ }
361
+ startItem(item, receivedSeconds() - event.seconds);
362
+ const committed = event.committed;
363
+ if (committed.length > item.sent.length &&
364
+ committed.startsWith(item.sent)) {
365
+ let delta = committed.slice(item.sent.length);
366
+ if (item.sent && !/\s$/u.test(item.sent) && !/^\s/u.test(delta)) {
367
+ delta = ` ${delta}`;
368
+ }
369
+ item.sent = committed;
370
+ emit(item, "conversation.item.input_audio_transcription.delta", {
371
+ item_id: item.itemId,
372
+ content_index: 0,
373
+ delta,
374
+ });
375
+ }
376
+ return;
377
+ }
378
+ case "final": {
379
+ const item = itemFor(event.utterance);
380
+ if (item.done) {
381
+ return;
382
+ }
383
+ const start = event.segment.start ?? event.segment.startTime;
384
+ const end = event.segment.end ?? event.segment.endTime;
385
+ startItem(item, start ?? receivedSeconds());
386
+ item.finalText = event.text;
387
+ item.engine = event.engine;
388
+ item.language = event.language;
389
+ item.languageDetected = event.languageDetected;
390
+ emit(item, "input_audio_buffer.speech_stopped", {
391
+ audio_end_ms: Math.round((end ?? receivedSeconds()) * 1000),
392
+ item_id: item.itemId,
393
+ });
394
+ emit(item, "input_audio_buffer.committed", {
395
+ item_id: item.itemId,
396
+ previous_item_id: previousItemId,
397
+ });
398
+ previousItemId = item.itemId;
399
+ emit(item, "neurolink.final", {
400
+ item_id: item.itemId,
401
+ text: event.text,
402
+ engine: event.engine,
403
+ language: event.language ?? null,
404
+ language_detected: event.languageDetected ?? null,
405
+ });
406
+ if (!correcting) {
407
+ settle(item, event.text);
408
+ return;
409
+ }
410
+ item.timer = setTimeout(() => settle(item, item.finalText ?? ""), waitMs);
411
+ return;
412
+ }
413
+ case "corrected": {
414
+ const item = items.get(event.utterance);
415
+ if (!item) {
416
+ send("neurolink.corrected", {
417
+ item_id: `item_${connection}_${event.utterance}`,
418
+ text: event.text,
419
+ });
420
+ return;
421
+ }
422
+ emit(item, "neurolink.corrected", {
423
+ item_id: item.itemId,
424
+ text: event.text,
425
+ });
426
+ item.corrected = event.text.trim().length > 0;
427
+ settle(item, item.corrected ? event.text : (item.finalText ?? ""));
428
+ return;
429
+ }
430
+ case "silence": {
431
+ const item = items.get(event.utterance);
432
+ if (!item) {
433
+ return;
434
+ }
435
+ if (item.started) {
436
+ emit(item, "input_audio_buffer.speech_stopped", {
437
+ audio_end_ms: Math.round(receivedSeconds() * 1000),
438
+ item_id: item.itemId,
439
+ });
440
+ }
441
+ // An empty completed clears the client's interim text.
442
+ settle(item, "");
443
+ return;
444
+ }
445
+ case "output": {
446
+ const body = {
447
+ item_id: `item_${connection}_${event.utterance}`,
448
+ language: event.language,
449
+ text: event.text,
450
+ status: event.status,
451
+ };
452
+ const item = items.get(event.utterance);
453
+ if (item) {
454
+ emit(item, "neurolink.output", body);
455
+ }
456
+ else {
457
+ send("neurolink.output", body);
458
+ }
459
+ return;
460
+ }
461
+ case "error": {
462
+ send("error", {
463
+ error: {
464
+ type: "server_error",
465
+ code: event.recoverable ? "utterance_failed" : "stream_failed",
466
+ message: event.message,
467
+ param: null,
468
+ },
469
+ });
470
+ return;
471
+ }
472
+ default:
473
+ return;
474
+ }
475
+ };
476
+ const start = () => {
477
+ if (started) {
478
+ return;
479
+ }
480
+ started = true;
481
+ const session = config ?? { dialect: "ga", rate: DEFAULT_RATE };
482
+ const profile = profileFor(session.model, profiles);
483
+ const { completed: _completed, correctionWaitMs: _wait, clientLanguage, clientTurnDetection, ...stream } = profile;
484
+ const options = {
485
+ ...stream,
486
+ audio: queue,
487
+ streaming: {
488
+ ...stream.streaming,
489
+ sampleRate: session.rate,
490
+ ...(clientTurnDetection && session.silenceMs !== undefined
491
+ ? {
492
+ endSilenceMs: Math.max(MIN_CLIENT_SILENCE_MS, session.silenceMs),
493
+ }
494
+ : {}),
495
+ },
496
+ ...(clientLanguage !== false && session.language && !stream.language
497
+ ? { language: session.language }
498
+ : {}),
499
+ // The client's prompt is context for the correction, not text for the
500
+ // engine: an engine prompted with it can echo it into the transcript.
501
+ // Only where the profile corrects at all: a prompt must not switch
502
+ // correction on by itself.
503
+ ...(session.prompt &&
504
+ stream.correction &&
505
+ !stream.correction.context &&
506
+ isCorrectionEnabled(stream.dictionary, stream.correction)
507
+ ? { correction: { ...stream.correction, context: session.prompt } }
508
+ : {}),
509
+ };
510
+ void run(options, profile);
511
+ };
512
+ client.on("message", (data, isBinary) => {
513
+ if (isBinary) {
514
+ fail("Send audio as input_audio_buffer.append events with base64 PCM16", "binary_not_supported");
515
+ return;
516
+ }
517
+ const frame = Buffer.isBuffer(data)
518
+ ? data
519
+ : Array.isArray(data)
520
+ ? Buffer.concat(data)
521
+ : Buffer.from(data);
522
+ let message;
523
+ try {
524
+ message = asRecord(JSON.parse(frame.toString("utf-8")));
525
+ }
526
+ catch {
527
+ message = undefined;
528
+ }
529
+ const type = typeof message?.type === "string" ? message.type : undefined;
530
+ if (!message || !type) {
531
+ fail("Each frame must be a JSON event with a type", "invalid_event");
532
+ return;
533
+ }
534
+ switch (type) {
535
+ case "session.update":
536
+ case "transcription_session.update": {
537
+ const read = readSession(type, asRecord(message.session) ?? {});
538
+ if ("error" in read) {
539
+ fail(read.error, "unsupported_session", !started);
540
+ return;
541
+ }
542
+ if (!started) {
543
+ config = read;
544
+ }
545
+ // A running stream keeps the settings it started with.
546
+ send(type === "session.update"
547
+ ? "session.updated"
548
+ : "transcription_session.updated", { session: sessionObject(sessionId, config ?? read) });
549
+ return;
550
+ }
551
+ case "input_audio_buffer.append": {
552
+ const audio = typeof message.audio === "string" ? message.audio : "";
553
+ const pcm = Buffer.from(audio, "base64");
554
+ if (pcm.length === 0) {
555
+ return;
556
+ }
557
+ start();
558
+ queue.push(pcm);
559
+ receivedSamples += Math.floor(pcm.length / 2);
560
+ flushedSinceAudio = false;
561
+ if (idleTimer) {
562
+ clearTimeout(idleTimer);
563
+ }
564
+ idleTimer = setTimeout(flushUtterance, IDLE_FLUSH_MS);
565
+ if (queue.queuedBytes > MAX_QUEUED_BYTES) {
566
+ send("error", {
567
+ error: {
568
+ type: "server_error",
569
+ code: "backlog",
570
+ message: "Audio is arriving faster than it can be transcribed",
571
+ param: null,
572
+ },
573
+ });
574
+ queue.end();
575
+ client.close(1009, "backlog");
576
+ }
577
+ return;
578
+ }
579
+ case "input_audio_buffer.commit":
580
+ flushUtterance();
581
+ return;
582
+ case "input_audio_buffer.clear":
583
+ send("input_audio_buffer.cleared");
584
+ return;
585
+ default:
586
+ fail(`Unsupported event type "${type}"`, "unsupported_event");
587
+ }
588
+ });
589
+ const end = () => {
590
+ if (idleTimer) {
591
+ clearTimeout(idleTimer);
592
+ }
593
+ for (const item of items.values()) {
594
+ if (item.timer) {
595
+ clearTimeout(item.timer);
596
+ }
597
+ }
598
+ queue.end();
599
+ };
600
+ client.on("close", end);
601
+ client.on("error", end);
602
+ send("session.created", { session: sessionObject(sessionId, undefined) });
603
+ }
@@ -8,12 +8,28 @@
8
8
  * 2. Then BINARY frames of PCM16LE mono audio at `streaming.sampleRate`
9
9
  * (default 16000).
10
10
  * 3. A TEXT frame `{"type":"end"}` (or closing the socket) ends the audio; the
11
- * server flushes the last utterance.
11
+ * server flushes the last utterance. `{"type":"output","languages":[…]}`
12
+ * changes the output languages from the next utterance on; any other text
13
+ * frame ends the audio, as before.
12
14
  *
13
15
  * The server sends one TEXT frame per `TranscribeStreamEvent` (JSON), then
14
16
  * closes with 1000. A bad config is answered with an `error` event and close
15
17
  * code 1008.
16
18
  *
19
+ * Sessions (one call, several speakers, listeners in their own languages):
20
+ * a speaker connects to the stream path with `?session=<id>&speaker=<name>`
21
+ * and uses the protocol above; a listener connects to the subscribe path
22
+ * (default `<path>/subscribe`) with `?session=<id>` and sends
23
+ * `{"type":"subscribe","languages":["es"],"speakers":[…]}` — again at any time
24
+ * to change them. Listeners receive every event of the speakers they follow,
25
+ * tagged with `speaker`, and only the `output` events of their languages; a
26
+ * session renders each utterance once into the union of its listeners'
27
+ * languages. A session lives while a speaker or a listener is connected.
28
+ *
29
+ * OpenAI Realtime transcription (default path `/v1/realtime`,
30
+ * `?intent=transcription`): the same pipeline behind OpenAI's protocol, for
31
+ * clients that already speak it (see realtimeTranscription.ts).
32
+ *
17
33
  * The route adapters do not handle upgrades, so this attaches to the Node
18
34
  * `http.Server` the caller owns, next to whatever else is listening there; it
19
35
  * answers only upgrades on its own path.