@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,397 @@
1
+ /**
2
+ * STT evaluation collector: keeps what two STTs wrote for the same call, side
3
+ * by side, and measures how far apart they are per speaker.
4
+ *
5
+ * A sender (the call agent) posts batches of finalized segments, one STT
6
+ * labelled `primary` (shown to people) and the other `shadow` (run only to be
7
+ * compared). Segment ids are the sender's and stable, so a resent batch
8
+ * replaces what it sent before rather than doubling it.
9
+ *
10
+ * With a directory, every accepted batch is appended to `<dir>/<call>.jsonl`
11
+ * and replayed on start, so comparisons survive a restart; without one, calls
12
+ * live in memory only. With `NEUROLINK_STT_EVAL_LANGFUSE_*` set, every batch
13
+ * and the call's comparison are also exported to that Langfuse project (see
14
+ * sttEvalLangfuse.ts); only the environment decides that, never a request.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { logger } from "../../utils/logger.js";
20
+ import { phoneticKey } from "../codeMix.js";
21
+ import { STTEvalLangfuseExporter, sttEvalLangfuseConfigFromEnv, } from "./sttEvalLangfuse.js";
22
+ const DEFAULT_MAX_CALLS = 1000;
23
+ const DEFAULT_MAX_SEGMENTS = 50_000;
24
+ /** Words per side above which a speaker is aligned in time windows instead of whole. */
25
+ const FULL_ALIGN_MAX_WORDS = 4000;
26
+ const WINDOW_S = 300;
27
+ export class STTEvalStore {
28
+ _dir;
29
+ _maxCalls;
30
+ _maxSegments;
31
+ _calls = new Map();
32
+ /** Per call: the tail of its accept queue, so batches apply and append in arrival order. */
33
+ _queues = new Map();
34
+ _ready;
35
+ _langfuse;
36
+ constructor(options = {}) {
37
+ const dir = options.dir ?? process.env.NEUROLINK_STT_EVAL_DIR?.trim();
38
+ this._dir = dir ? dir : undefined;
39
+ this._maxCalls = Math.max(1, options.maxCalls ?? DEFAULT_MAX_CALLS);
40
+ this._maxSegments = Math.max(1, options.maxSegmentsPerCall ?? DEFAULT_MAX_SEGMENTS);
41
+ const langfuse = sttEvalLangfuseConfigFromEnv();
42
+ this._langfuse = langfuse
43
+ ? new STTEvalLangfuseExporter(langfuse)
44
+ : undefined;
45
+ }
46
+ async accept(batch) {
47
+ await this._load();
48
+ return this._serial(batch.call_id, async () => {
49
+ const call = (await this._callFor(batch.call_id)) ?? this._create(batch);
50
+ const counts = this._apply(call, batch);
51
+ if (this._dir) {
52
+ try {
53
+ await appendFile(this._fileFor(batch.call_id), `${JSON.stringify(batch)}\n`, "utf8");
54
+ }
55
+ catch (error) {
56
+ logger.warn("[stt-eval] could not persist batch", {
57
+ callId: batch.call_id,
58
+ error: error instanceof Error ? error.message : String(error),
59
+ });
60
+ }
61
+ }
62
+ if (this._langfuse) {
63
+ // Background: Langfuse never delays or fails the sender's batch.
64
+ void this._langfuse.export(batch, this._reportOf(call, false));
65
+ }
66
+ this._evict();
67
+ return {
68
+ call_id: call.callId,
69
+ batch: batch.batch,
70
+ final: call.final,
71
+ ...counts,
72
+ segments: countBySource(call),
73
+ };
74
+ });
75
+ }
76
+ async list() {
77
+ await this._load();
78
+ return [...this._calls.values()]
79
+ .sort((a, b) => b.updatedAt - a.updatedAt)
80
+ .map(summarize);
81
+ }
82
+ async report(callId, includeSegments = false) {
83
+ await this._load();
84
+ const call = await this._callFor(callId);
85
+ return call ? this._reportOf(call, includeSegments) : undefined;
86
+ }
87
+ _reportOf(call, includeSegments) {
88
+ const ordered = [...call.segments.values()].sort((a, b) => timeOf(a) - timeOf(b));
89
+ const bySpeaker = new Map();
90
+ for (const segment of ordered) {
91
+ const list = bySpeaker.get(segment.participant_identity) ?? [];
92
+ list.push(segment);
93
+ bySpeaker.set(segment.participant_identity, list);
94
+ }
95
+ let distance = 0;
96
+ let refWords = 0;
97
+ let phoneticDistance = 0;
98
+ const speakers = [];
99
+ for (const [identity, segments] of bySpeaker) {
100
+ const compared = compareSpeaker(identity, segments, call.models);
101
+ speakers.push(compared.comparison);
102
+ distance += compared.plain.distance;
103
+ phoneticDistance += compared.phonetic.distance;
104
+ refWords += compared.plain.refWords;
105
+ }
106
+ return {
107
+ ...summarize(call),
108
+ overall: {
109
+ disagreement: refWords ? round(distance / refWords) : null,
110
+ phonetic_disagreement: refWords
111
+ ? round(phoneticDistance / refWords)
112
+ : null,
113
+ },
114
+ speakers_compared: speakers,
115
+ ...(includeSegments ? { segment_list: ordered } : {}),
116
+ };
117
+ }
118
+ /** Runs `task` after every earlier task for the same call has settled. */
119
+ _serial(callId, task) {
120
+ const previous = this._queues.get(callId) ?? Promise.resolve();
121
+ const run = previous.then(task, task);
122
+ const tail = run.catch(() => undefined);
123
+ this._queues.set(callId, tail);
124
+ void tail.then(() => {
125
+ if (this._queues.get(callId) === tail) {
126
+ this._queues.delete(callId);
127
+ }
128
+ });
129
+ return run;
130
+ }
131
+ _create(batch) {
132
+ const call = {
133
+ callId: batch.call_id,
134
+ room: batch.room ?? null,
135
+ models: { ...batch.models },
136
+ final: false,
137
+ batches: new Set(),
138
+ segments: new Map(),
139
+ updatedAt: Date.now(),
140
+ };
141
+ this._calls.set(batch.call_id, call);
142
+ return call;
143
+ }
144
+ _apply(call, batch) {
145
+ let inserted = 0;
146
+ let updated = 0;
147
+ let ignored = 0;
148
+ for (const segment of batch.segments) {
149
+ if (call.segments.has(segment.id)) {
150
+ updated++;
151
+ }
152
+ else if (call.segments.size >= this._maxSegments) {
153
+ ignored++;
154
+ continue;
155
+ }
156
+ else {
157
+ inserted++;
158
+ }
159
+ call.segments.set(segment.id, { ...segment });
160
+ }
161
+ call.batches.add(batch.batch);
162
+ call.final = call.final || batch.final === true;
163
+ call.room = batch.room ?? call.room;
164
+ call.models = { ...batch.models };
165
+ call.updatedAt = Date.now();
166
+ // Re-insert so Map order tracks recency for eviction.
167
+ this._calls.delete(call.callId);
168
+ this._calls.set(call.callId, call);
169
+ return { inserted, updated, ignored };
170
+ }
171
+ async _callFor(callId) {
172
+ const held = this._calls.get(callId);
173
+ if (held || !this._dir) {
174
+ return held;
175
+ }
176
+ // Evicted from memory but still on disk.
177
+ const text = await readFile(this._fileFor(callId), "utf8").catch(() => undefined);
178
+ return text === undefined ? undefined : this._replay(text);
179
+ }
180
+ _evict() {
181
+ while (this._calls.size > this._maxCalls) {
182
+ const oldest = this._calls.keys().next().value;
183
+ if (oldest === undefined) {
184
+ return;
185
+ }
186
+ this._calls.delete(oldest);
187
+ }
188
+ }
189
+ _fileFor(callId) {
190
+ const safe = callId.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 80);
191
+ const hash = createHash("sha256").update(callId).digest("hex").slice(0, 10);
192
+ return join(this._dir ?? "", `${safe}-${hash}.jsonl`);
193
+ }
194
+ /** Replays the directory once; every caller awaits the same load. */
195
+ _load() {
196
+ this._ready ??= this._loadDir();
197
+ return this._ready;
198
+ }
199
+ async _loadDir() {
200
+ const dir = this._dir;
201
+ if (!dir) {
202
+ return;
203
+ }
204
+ try {
205
+ await mkdir(dir, { recursive: true });
206
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl"));
207
+ for (const file of files) {
208
+ this._replay(await readFile(join(dir, file), "utf8"));
209
+ }
210
+ // Replay order is filesystem order; keep the most recent calls.
211
+ const recent = [...this._calls.values()].sort((a, b) => a.updatedAt - b.updatedAt);
212
+ this._calls.clear();
213
+ for (const call of recent) {
214
+ this._calls.set(call.callId, call);
215
+ }
216
+ this._evict();
217
+ }
218
+ catch (error) {
219
+ logger.warn("[stt-eval] could not load stored calls", {
220
+ dir,
221
+ error: error instanceof Error ? error.message : String(error),
222
+ });
223
+ }
224
+ }
225
+ _replay(text) {
226
+ let call;
227
+ let lastSeen = 0;
228
+ for (const line of text.split("\n")) {
229
+ if (!line.trim()) {
230
+ continue;
231
+ }
232
+ try {
233
+ const batch = JSON.parse(line);
234
+ call = call ?? this._create(batch);
235
+ this._apply(call, batch);
236
+ for (const segment of batch.segments) {
237
+ lastSeen = Math.max(lastSeen, segment.received_at * 1000);
238
+ }
239
+ }
240
+ catch {
241
+ // A torn last line from a crash mid-write: skip it.
242
+ }
243
+ }
244
+ if (call && lastSeen > 0) {
245
+ call.updatedAt = lastSeen;
246
+ }
247
+ return call;
248
+ }
249
+ }
250
+ function summarize(call) {
251
+ let first = null;
252
+ let last = null;
253
+ const speakers = new Set();
254
+ for (const segment of call.segments.values()) {
255
+ speakers.add(segment.participant_identity);
256
+ first =
257
+ first === null
258
+ ? segment.received_at
259
+ : Math.min(first, segment.received_at);
260
+ last =
261
+ last === null ? segment.received_at : Math.max(last, segment.received_at);
262
+ }
263
+ return {
264
+ call_id: call.callId,
265
+ room: call.room,
266
+ models: call.models,
267
+ final: call.final,
268
+ batches: call.batches.size,
269
+ segments: countBySource(call),
270
+ speakers: speakers.size,
271
+ first_received_at: first,
272
+ last_received_at: last,
273
+ updated_at: call.updatedAt,
274
+ };
275
+ }
276
+ function countBySource(call) {
277
+ let primary = 0;
278
+ let shadow = 0;
279
+ for (const segment of call.segments.values()) {
280
+ if (segment.source === "primary") {
281
+ primary++;
282
+ }
283
+ else {
284
+ shadow++;
285
+ }
286
+ }
287
+ return { primary, shadow };
288
+ }
289
+ function timeOf(segment) {
290
+ return segment.start_time ?? segment.end_time ?? segment.received_at;
291
+ }
292
+ function compareSpeaker(identity, segments, models) {
293
+ const primary = segments.filter((s) => s.source === "primary");
294
+ const shadow = segments.filter((s) => s.source === "shadow");
295
+ const name = segments.find((s) => s.participant_name)?.participant_name ?? identity;
296
+ const words = (list) => list.flatMap((s) => wordsOf(s.text));
297
+ const primaryWords = words(primary);
298
+ const shadowWords = words(shadow);
299
+ const windowed = primaryWords.length > FULL_ALIGN_MAX_WORDS ||
300
+ shadowWords.length > FULL_ALIGN_MAX_WORDS;
301
+ const plain = windowed
302
+ ? alignWindows(primary, shadow, (t) => t)
303
+ : align(primaryWords, shadowWords);
304
+ const phonetic = windowed
305
+ ? alignWindows(primary, shadow, phoneticKey)
306
+ : align(primaryWords.map(phoneticKey), shadowWords.map(phoneticKey));
307
+ return {
308
+ comparison: {
309
+ participant_identity: identity,
310
+ participant_name: name,
311
+ primary: sideOf(primary, models.primary, primaryWords.length),
312
+ shadow: sideOf(shadow, models.shadow, shadowWords.length),
313
+ disagreement: plain.refWords
314
+ ? round(plain.distance / plain.refWords)
315
+ : null,
316
+ phonetic_disagreement: phonetic.refWords
317
+ ? round(phonetic.distance / phonetic.refWords)
318
+ : null,
319
+ method: windowed ? "windowed" : "full",
320
+ },
321
+ plain,
322
+ phonetic,
323
+ };
324
+ }
325
+ function sideOf(segments, fallbackModel, words) {
326
+ const languages = new Map();
327
+ let latencySum = 0;
328
+ let latencyCount = 0;
329
+ for (const segment of segments) {
330
+ if (segment.language) {
331
+ languages.set(segment.language, (languages.get(segment.language) ?? 0) + 1);
332
+ }
333
+ if (typeof segment.end_time === "number") {
334
+ const latency = segment.received_at - segment.end_time;
335
+ if (latency >= 0 && latency < 600) {
336
+ latencySum += latency;
337
+ latencyCount++;
338
+ }
339
+ }
340
+ }
341
+ return {
342
+ model: segments[0]?.model ?? fallbackModel,
343
+ segments: segments.length,
344
+ words,
345
+ languages: [...languages.entries()]
346
+ .sort((a, b) => b[1] - a[1])
347
+ .map(([language]) => language),
348
+ mean_latency_s: latencyCount ? round(latencySum / latencyCount) : null,
349
+ };
350
+ }
351
+ /** Long speakers: align consecutive 5-minute windows, so cost stays bounded. */
352
+ function alignWindows(primary, shadow, key) {
353
+ const bucket = (list) => {
354
+ const out = new Map();
355
+ for (const segment of list) {
356
+ const w = Math.floor(timeOf(segment) / WINDOW_S);
357
+ const words = out.get(w) ?? [];
358
+ words.push(...wordsOf(segment.text).map(key));
359
+ out.set(w, words);
360
+ }
361
+ return out;
362
+ };
363
+ const p = bucket(primary);
364
+ const s = bucket(shadow);
365
+ let distance = 0;
366
+ let refWords = 0;
367
+ for (const w of new Set([...p.keys(), ...s.keys()])) {
368
+ const result = align(p.get(w) ?? [], s.get(w) ?? []);
369
+ distance += result.distance;
370
+ refWords += result.refWords;
371
+ }
372
+ return { distance, refWords };
373
+ }
374
+ function align(ref, hyp) {
375
+ const row = Array.from({ length: hyp.length + 1 }, (_, j) => j);
376
+ for (let i = 1; i <= ref.length; i++) {
377
+ let previous = row[0];
378
+ row[0] = i;
379
+ for (let j = 1; j <= hyp.length; j++) {
380
+ const above = row[j];
381
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (ref[i - 1] === hyp[j - 1] ? 0 : 1));
382
+ previous = above;
383
+ }
384
+ }
385
+ return { distance: row[hyp.length], refWords: ref.length };
386
+ }
387
+ function wordsOf(text) {
388
+ return text
389
+ .toLowerCase()
390
+ .normalize("NFKC")
391
+ .replace(/[^\p{L}\p{M}\p{N}\s']/gu, " ")
392
+ .split(/\s+/u)
393
+ .filter(Boolean);
394
+ }
395
+ function round(value) {
396
+ return Math.round(value * 1000) / 1000;
397
+ }
@@ -246,6 +246,9 @@ function endpointHandler(endpoint) {
246
246
  baseURL: endpoint.baseURL,
247
247
  timeoutMs: endpoint.timeoutMs,
248
248
  model: endpoint.model,
249
+ priorLanguageForm: endpoint.priorLanguageForm,
250
+ lidModel: endpoint.lidModel,
251
+ vocabularyPrompt: endpoint.vocabularyPrompt,
249
252
  });
250
253
  }
251
254
  /**
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Language codes and writing systems for the transcribe pipeline.
3
+ *
4
+ * Engines report languages in different shapes ("hi", "hin", "Hindi",
5
+ * "en-US"); routing, correction and output rendering need one: a lower-case
6
+ * ISO 639 code. Output targets are BCP-47 tags whose script subtag says how
7
+ * the text is written (`hi-Latn` is Hindi in Latin letters), and the guards
8
+ * on a rendering check the text against that script.
9
+ *
10
+ * @module voice/languages
11
+ */
12
+ import type { ParsedOutputTag, ScriptCode } from "../types/index.js";
13
+ /**
14
+ * The language part of whatever an engine reported, as a lower-case ISO 639
15
+ * code: "en-US" → "en", "Hindi" → "hi", "auto-ta" → "ta". `undefined` for
16
+ * nothing, "auto" and "und".
17
+ */
18
+ export declare function normalizeLanguageCode(value: string | undefined): string | undefined;
19
+ export declare function isIndicLanguage(language: string | undefined): boolean;
20
+ /** Letters of each script in `text`; scripts with none are absent. */
21
+ export declare function scriptCounts(text: string): Map<ScriptCode, number>;
22
+ /** The script most letters of `text` are in, `undefined` for text without letters. */
23
+ export declare function dominantScript(text: string): ScriptCode | undefined;
24
+ /** Whether `text` holds letters of an Indic script (Devanagari through Sinhala). */
25
+ export declare function hasIndicScript(text: string): boolean;
26
+ /**
27
+ * A BCP-47 output tag: language, optional script, optional region
28
+ * (`es`, `fr-CA`, `hi-Latn`, `zh-Hant`). `undefined` for anything else.
29
+ */
30
+ export declare function parseOutputTag(tag: string): ParsedOutputTag | undefined;
31
+ /** Share of `text`'s letters written in `script` (Japanese accepts kanji as well as kana). */
32
+ export declare function scriptShare(text: string, script: ScriptCode): number;
33
+ /** "es" → "Spanish", "hi-Latn" → "Hindi (Latin script)"; the tag itself when the runtime cannot name it. */
34
+ export declare function describeOutputTag(tag: string): string;