@juspay/neurolink 13.7.0 → 13.8.1

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 (84) hide show
  1. package/CHANGELOG.md +3 -6
  2. package/dist/browser/neurolink.min.js +480 -460
  3. package/dist/cli/commands/serve.js +11 -1
  4. package/dist/cli/commands/transcribe.js +39 -3
  5. package/dist/constants/langfuse.d.ts +2 -0
  6. package/dist/constants/langfuse.js +2 -0
  7. package/dist/factories/sttDescriptors.js +7 -0
  8. package/dist/index.d.ts +3 -2
  9. package/dist/index.js +4 -2
  10. package/dist/mcp/externalServerManager.js +4 -1
  11. package/dist/mcp/mcpClientFactory.d.ts +6 -0
  12. package/dist/mcp/mcpClientFactory.js +44 -0
  13. package/dist/neurolink.js +42 -3
  14. package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
  15. package/dist/observability/exporters/langfuseExporter.js +29 -8
  16. package/dist/observability/otlpEncoding.js +2 -2
  17. package/dist/server/abstract/baseServerAdapter.js +2 -0
  18. package/dist/server/adapters/expressAdapter.js +1 -1
  19. package/dist/server/adapters/fastifyAdapter.js +4 -1
  20. package/dist/server/adapters/honoAdapter.js +1 -1
  21. package/dist/server/adapters/koaAdapter.js +1 -1
  22. package/dist/server/routes/transcribeRoutes.d.ts +0 -8
  23. package/dist/server/routes/transcribeRoutes.js +130 -3
  24. package/dist/server/utils/validation.d.ts +409 -15
  25. package/dist/server/utils/validation.js +106 -4
  26. package/dist/server/websocket/frameQueue.d.ts +16 -0
  27. package/dist/server/websocket/frameQueue.js +55 -0
  28. package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
  29. package/dist/server/websocket/realtimeTranscription.js +603 -0
  30. package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
  31. package/dist/server/websocket/transcribeWebSocket.js +263 -64
  32. package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
  33. package/dist/types/cli.d.ts +6 -0
  34. package/dist/types/exporter.d.ts +3 -1
  35. package/dist/types/server.d.ts +304 -2
  36. package/dist/types/stt.d.ts +38 -3
  37. package/dist/types/transcribe.d.ts +401 -5
  38. package/dist/voice/audioLevel.d.ts +14 -0
  39. package/dist/voice/audioLevel.js +60 -0
  40. package/dist/voice/codeMix.d.ts +40 -0
  41. package/dist/voice/codeMix.js +282 -0
  42. package/dist/voice/correction/align.d.ts +12 -0
  43. package/dist/voice/correction/align.js +48 -0
  44. package/dist/voice/correction/guards.d.ts +39 -0
  45. package/dist/voice/correction/guards.js +122 -0
  46. package/dist/voice/correction/index.d.ts +25 -3
  47. package/dist/voice/correction/index.js +132 -4
  48. package/dist/voice/correction/rewrite.d.ts +64 -3
  49. package/dist/voice/correction/rewrite.js +295 -25
  50. package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
  51. package/dist/voice/eval/sttEvalLangfuse.js +182 -0
  52. package/dist/voice/eval/sttEvalStore.d.ts +27 -0
  53. package/dist/voice/eval/sttEvalStore.js +397 -0
  54. package/dist/voice/index.js +3 -0
  55. package/dist/voice/languages.d.ts +34 -0
  56. package/dist/voice/languages.js +290 -0
  57. package/dist/voice/modelAssets.d.ts +28 -0
  58. package/dist/voice/modelAssets.js +113 -0
  59. package/dist/voice/output/render.d.ts +32 -0
  60. package/dist/voice/output/render.js +368 -0
  61. package/dist/voice/providers/OpenAISTT.d.ts +11 -1
  62. package/dist/voice/providers/OpenAISTT.js +57 -6
  63. package/dist/voice/session/transcriptionSession.d.ts +55 -0
  64. package/dist/voice/session/transcriptionSession.js +225 -0
  65. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
  66. package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
  67. package/dist/voice/streaming/endpointer.d.ts +50 -17
  68. package/dist/voice/streaming/endpointer.js +115 -19
  69. package/dist/voice/transcribe.d.ts +18 -1
  70. package/dist/voice/transcribe.js +459 -18
  71. package/dist/voice/transcribeStream.d.ts +18 -1
  72. package/dist/voice/transcribeStream.js +413 -16
  73. package/dist/voice/vad/assets.d.ts +36 -0
  74. package/dist/voice/vad/assets.js +142 -0
  75. package/dist/voice/vad/frameScorer.d.ts +28 -0
  76. package/dist/voice/vad/frameScorer.js +90 -0
  77. package/dist/voice/vad/sileroVad.d.ts +43 -0
  78. package/dist/voice/vad/sileroVad.js +178 -0
  79. package/dist/voice/whistle/assets.d.ts +3 -4
  80. package/dist/voice/whistle/assets.js +11 -95
  81. package/docs-site/static/search-index.json +21 -6
  82. package/models/silero-vad/README.md +19 -0
  83. package/models/silero-vad/silero_vad.onnx +0 -0
  84. package/package.json +3 -1
@@ -1,36 +1,48 @@
1
1
  /**
2
- * Energy-gated endpointer for client-side streaming of batch STT engines.
2
+ * Endpointer for client-side streaming of batch STT engines.
3
3
  *
4
4
  * Pure and synchronous: feed it float samples at any input rate, get back
5
5
  * utterance start / end / drop events. It knows nothing about engines; the
6
6
  * chunked adapter decides what to transcribe and when.
7
7
  *
8
- * The rules and their constants were measured on live microphones and on a
9
- * 45 s three-speaker meeting slice, where they recover 155 of the 156 words a
10
- * batch transcription of the whole file finds (a plain fixed-threshold gate
11
- * recovered 125):
8
+ * Two ways to tell speech from not-speech, same utterance rules either way:
9
+ *
10
+ * - **A speech detector** (`attachSpeech`): the adapter scores the stream
11
+ * with a model (Silero VAD by default) and the endpointer reads the
12
+ * probability of each 20 ms block. An utterance opens after `startMs` of
13
+ * consecutive blocks at or above `onset` (the pre-roll keeps that audio,
14
+ * so nothing is clipped) and a block counts as speech while at or above
15
+ * `sustain`. A chair, a keyboard or a far-away TV scores near zero and
16
+ * never opens one.
17
+ * - **The energy gate**, the fallback when no detector is attached. Its
18
+ * rules and constants were measured on live microphones and on a 45 s
19
+ * three-speaker meeting slice, where they recover 155 of the 156 words a
20
+ * batch transcription of the whole file finds (a plain fixed-threshold
21
+ * gate recovered 125):
22
+ * - The noise floor is a minimum statistic — the quietest block of the
23
+ * last 1.5 s — allowed to rise by at most ~2× per second and never below
24
+ * 0.0005. Because it follows the quietest block, a loud talker can never
25
+ * raise it enough to push a quieter second speaker below the gate.
26
+ * - Hysteresis: speech starts above `max(onsetRms, floor × 3)` and is
27
+ * sustained above `max(onsetRms × 0.58, floor × 1.8)`, so soft word
28
+ * endings are kept inside the utterance instead of counting as silence.
29
+ *
30
+ * Shared rules:
12
31
  *
13
32
  * - Work on 20 ms blocks.
14
- * - The noise floor is a minimum statistic — the quietest block of the last
15
- * 1.5 s — allowed to rise by at most ~2× per second and never below 0.0005.
16
- * Because it follows the quietest block, a loud talker can never raise it
17
- * enough to push a quieter second speaker below the gate.
18
- * - Hysteresis: speech starts above `max(onsetRms, floor × 3)` and is
19
- * sustained above `max(onsetRms × 0.58, floor × 1.8)`, so soft word endings
20
- * are kept inside the utterance instead of counting as silence.
21
33
  * - `prerollMs` of audio before the onset is kept, so the first consonant is
22
34
  * not clipped.
23
35
  * - An utterance ends after `endSilenceMs` without sustained speech. One with
24
36
  * less than `minSpeechMs` of speech is a cough and is dropped — unless a
25
37
  * pass already heard words in it, which must never be lost.
26
38
  * - A long turn (`softCutSeconds`) ends at the next ≥ 320 ms gap; a turn with
27
- * no gap at all is hard-cut at `maxUtteranceSeconds` at the quietest block
28
- * of the last 3 s, and the remainder becomes the start of the next
39
+ * no gap at all is hard-cut at `maxUtteranceSeconds` at the least speech-like
40
+ * block of the last 3 s, and the remainder becomes the start of the next
29
41
  * utterance, so no audio falls between the two.
30
42
  *
31
43
  * @module voice/streaming/endpointer
32
44
  */
33
- import type { STTEndpointerConfig, STTEndpointerEvent, STTEndpointerUtterance, STTStreamingOptions } from "../../types/index.js";
45
+ import type { STTEndpointerConfig, STTEndpointerEvent, STTEndpointerSpeechConfig, STTEndpointerUtterance, STTStreamingOptions } from "../../types/index.js";
34
46
  /** Sample rate every engine pass receives. */
35
47
  export declare const ENGINE_SAMPLE_RATE = 16000;
36
48
  /** Defaults of `STTStreamingOptions` that the endpointer reads. */
@@ -49,6 +61,9 @@ export declare function rmsOf(samples: Float32Array): number;
49
61
  export declare function resampleTo16k(samples: Float32Array, inputRate: number): Float32Array;
50
62
  export declare class Endpointer {
51
63
  readonly config: STTEndpointerConfig;
64
+ private speech;
65
+ /** Milliseconds of consecutive blocks at or above the detector's onset threshold. */
66
+ private onsetRun;
52
67
  private readonly blockLen;
53
68
  private block;
54
69
  private blockFill;
@@ -65,6 +80,13 @@ export declare class Endpointer {
65
80
  get current(): STTEndpointerUtterance | null;
66
81
  /** Seconds of input consumed so far. */
67
82
  get audioSeconds(): number;
83
+ /** Whether a speech detector decides, rather than the energy gate. */
84
+ get speechAttached(): boolean;
85
+ /**
86
+ * Reads a speech detector's verdicts from here on (`null` goes back to the
87
+ * energy gate, which an open utterance then finishes under).
88
+ */
89
+ attachSpeech(speech: STTEndpointerSpeechConfig | null): void;
68
90
  /** Feeds samples at the input rate; returns the events they caused, in order. */
69
91
  push(samples: Float32Array): STTEndpointerEvent[];
70
92
  /**
@@ -72,8 +94,19 @@ export declare class Endpointer {
72
94
  * is then ended (`flush`) — or dropped when it is a cough nobody heard.
73
95
  */
74
96
  flush(): STTEndpointerEvent[];
75
- /** The utterance's audio at 16 kHz. */
76
- pcm16k(u: STTEndpointerUtterance): Float32Array;
97
+ /**
98
+ * The utterance's audio at 16 kHz. With `trimTailMs` and a detector
99
+ * attached, silence after the last speech block beyond that margin is left
100
+ * out, so an engine never decodes the closing pause (where a batch model
101
+ * invents its endings); the utterance itself keeps its full span. `padMs`
102
+ * of digital silence is appended after everything else: a chunk cut off
103
+ * right after speech can send a batch model into a decoding loop until its
104
+ * timeout (measured on Qwen3-ASR: 200 ms of zeros was enough to stop it).
105
+ */
106
+ pcm16k(u: STTEndpointerUtterance, options?: {
107
+ trimTailMs?: number;
108
+ padMs?: number;
109
+ }): Float32Array;
77
110
  private sustainThreshold;
78
111
  private newUtterance;
79
112
  private onBlock;
@@ -14,7 +14,7 @@ const SUSTAIN_RATIO = 1.8;
14
14
  const ABS_SUSTAIN_RATIO = 0.58;
15
15
  /** A pause this long ends a turn that is already past `softCutSeconds`. */
16
16
  const SOFT_GAP_MS = 320;
17
- /** The hard cut looks for the quietest block in this many trailing seconds. */
17
+ /** The hard cut looks for the least speech-like block in this many trailing seconds. */
18
18
  const HARD_CUT_SEARCH_SECONDS = 3;
19
19
  /** Defaults of `STTStreamingOptions` that the endpointer reads. */
20
20
  export const ENDPOINTER_DEFAULTS = {
@@ -68,6 +68,9 @@ export function resampleTo16k(samples, inputRate) {
68
68
  }
69
69
  export class Endpointer {
70
70
  config;
71
+ speech = null;
72
+ /** Milliseconds of consecutive blocks at or above the detector's onset threshold. */
73
+ onsetRun = 0;
71
74
  blockLen;
72
75
  block;
73
76
  blockFill = 0;
@@ -92,6 +95,21 @@ export class Endpointer {
92
95
  get audioSeconds() {
93
96
  return this.consumed / this.config.inputRate;
94
97
  }
98
+ /** Whether a speech detector decides, rather than the energy gate. */
99
+ get speechAttached() {
100
+ return this.speech !== null;
101
+ }
102
+ /**
103
+ * Reads a speech detector's verdicts from here on (`null` goes back to the
104
+ * energy gate, which an open utterance then finishes under).
105
+ */
106
+ attachSpeech(speech) {
107
+ this.speech = speech;
108
+ this.onsetRun = 0;
109
+ if (!speech && this.utt) {
110
+ this.utt.speech = undefined;
111
+ }
112
+ }
95
113
  /** Feeds samples at the input rate; returns the events they caused, in order. */
96
114
  push(samples) {
97
115
  const events = [];
@@ -120,6 +138,7 @@ export class Endpointer {
120
138
  const tail = this.block.slice(0, this.blockFill);
121
139
  u.blocks.push(tail);
122
140
  u.rms.push(rmsOf(tail));
141
+ u.speech?.push(u.speech[u.speech.length - 1] ?? 0);
123
142
  u.length += tail.length;
124
143
  }
125
144
  this.consumed += this.blockFill;
@@ -134,27 +153,62 @@ export class Endpointer {
134
153
  this.preLen = 0;
135
154
  return events;
136
155
  }
137
- /** The utterance's audio at 16 kHz. */
138
- pcm16k(u) {
139
- const out = new Float32Array(u.length);
156
+ /**
157
+ * The utterance's audio at 16 kHz. With `trimTailMs` and a detector
158
+ * attached, silence after the last speech block beyond that margin is left
159
+ * out, so an engine never decodes the closing pause (where a batch model
160
+ * invents its endings); the utterance itself keeps its full span. `padMs`
161
+ * of digital silence is appended after everything else: a chunk cut off
162
+ * right after speech can send a batch model into a decoding loop until its
163
+ * timeout (measured on Qwen3-ASR: 200 ms of zeros was enough to stop it).
164
+ */
165
+ pcm16k(u, options) {
166
+ let blocks = u.blocks;
167
+ const sp = this.speech;
168
+ if (options?.trimTailMs !== undefined && u.speech && sp) {
169
+ let last = -1;
170
+ for (let k = u.speech.length - 1; k >= 0; k--) {
171
+ if (u.speech[k] >= sp.sustain) {
172
+ last = k;
173
+ break;
174
+ }
175
+ }
176
+ if (last >= 0) {
177
+ const keep = Math.min(blocks.length, last + 1 + Math.ceil(options.trimTailMs / BLOCK_MS));
178
+ blocks = blocks.slice(0, keep);
179
+ }
180
+ }
181
+ const length = blocks.reduce((a, b) => a + b.length, 0);
182
+ const out = new Float32Array(length);
140
183
  let o = 0;
141
- for (const b of u.blocks) {
184
+ for (const b of blocks) {
142
185
  out.set(b, o);
143
186
  o += b.length;
144
187
  }
145
- return resampleTo16k(out, this.config.inputRate);
188
+ const pcm = resampleTo16k(out, this.config.inputRate);
189
+ const pad = options?.padMs
190
+ ? Math.round((options.padMs / 1000) * ENGINE_SAMPLE_RATE)
191
+ : 0;
192
+ if (pad <= 0) {
193
+ return pcm;
194
+ }
195
+ const padded = new Float32Array(pcm.length + pad);
196
+ padded.set(pcm, 0);
197
+ return padded;
146
198
  }
147
199
  sustainThreshold() {
148
200
  return Math.max(this.config.onsetRms * ABS_SUSTAIN_RATIO, this.noise * SUSTAIN_RATIO);
149
201
  }
150
- newUtterance(blocks, rms) {
202
+ newUtterance(pre) {
203
+ const blocks = pre.map((p) => p.block);
151
204
  const length = blocks.reduce((a, b) => a + b.length, 0);
152
205
  return {
153
206
  id: ++this.seq,
154
207
  // Called once the newest block is counted, so the utterance ends at the clock.
155
208
  startSeconds: (this.consumed - length) / this.config.inputRate,
156
209
  blocks,
157
- rms,
210
+ rms: pre.map((p) => p.rms),
211
+ ...(this.speech ? { speech: pre.map((p) => p.speech) } : {}),
158
212
  length,
159
213
  speechMs: 0,
160
214
  silenceMs: 0,
@@ -179,20 +233,41 @@ export class Endpointer {
179
233
  this.noise = Math.max(NOISE_FLOOR_MIN, winMin > this.noise
180
234
  ? Math.min(winMin, this.noise * NOISE_RISE_PER_BLOCK)
181
235
  : winMin);
182
- const onset = rms > Math.max(cfg.onsetRms, this.noise * ONSET_RATIO);
183
- const sustain = rms > this.sustainThreshold();
236
+ const sp = this.speech;
237
+ // The detector's verdict for the middle of this block; before the first
238
+ // scored frame nothing is speech, so a stray block cannot open anything.
239
+ const p = sp
240
+ ? (sp.probabilityAt((this.consumed - block.length / 2) / cfg.inputRate) ??
241
+ 0)
242
+ : 0;
243
+ let onset;
244
+ let sustain;
245
+ if (sp) {
246
+ sustain = p >= sp.sustain;
247
+ // The confirmation run only matters while nothing is open, and it is
248
+ // reset at every onset, so a long turn never inflates the next utterance.
249
+ this.onsetRun = !this.utt && p >= sp.onset ? this.onsetRun + BLOCK_MS : 0;
250
+ onset = p >= sp.onset && this.onsetRun >= sp.startMs;
251
+ }
252
+ else {
253
+ onset = rms > Math.max(cfg.onsetRms, this.noise * ONSET_RATIO);
254
+ sustain = rms > this.sustainThreshold();
255
+ }
184
256
  const u = this.utt;
185
257
  if (!u) {
186
- this.pre.push({ block, rms });
258
+ this.pre.push({ block, rms, speech: p });
187
259
  this.preLen += block.length;
188
- const maxPre = (cfg.prerollMs / 1000) * cfg.inputRate;
260
+ // The pre-roll must hold the onset confirmation, or it would be clipped.
261
+ const maxPre = (Math.max(cfg.prerollMs, sp ? sp.startMs + 2 * BLOCK_MS : 0) / 1000) *
262
+ cfg.inputRate;
189
263
  while (this.preLen > maxPre && this.pre.length > 1) {
190
264
  const dropped = this.pre.shift();
191
265
  this.preLen -= dropped ? dropped.block.length : 0;
192
266
  }
193
267
  if (onset) {
194
- const started = this.newUtterance(this.pre.map((p) => p.block), this.pre.map((p) => p.rms));
195
- started.speechMs = BLOCK_MS;
268
+ const started = this.newUtterance(this.pre);
269
+ started.speechMs = sp ? Math.max(BLOCK_MS, this.onsetRun) : BLOCK_MS;
270
+ this.onsetRun = 0;
196
271
  this.utt = started;
197
272
  this.pre = [];
198
273
  this.preLen = 0;
@@ -202,6 +277,7 @@ export class Endpointer {
202
277
  }
203
278
  u.blocks.push(block);
204
279
  u.rms.push(rms);
280
+ u.speech?.push(p);
205
281
  u.length += block.length;
206
282
  if (sustain) {
207
283
  u.speechMs += BLOCK_MS;
@@ -228,18 +304,38 @@ export class Endpointer {
228
304
  hardCut(u, events) {
229
305
  const n = u.blocks.length;
230
306
  const from = Math.max(1, n - Math.round(HARD_CUT_SEARCH_SECONDS / BLOCK_SECONDS));
307
+ // The least speech-like block: lowest detector probability (quietest on a tie), else quietest.
308
+ const lessSpeech = (k, best) => {
309
+ const sp = u.speech;
310
+ if (sp) {
311
+ return (sp[k] < sp[best] || (sp[k] === sp[best] && u.rms[k] < u.rms[best]));
312
+ }
313
+ return u.rms[k] < u.rms[best];
314
+ };
231
315
  let best = from;
232
316
  for (let k = from; k < n; k++) {
233
- if (u.rms[k] < u.rms[best]) {
317
+ if (lessSpeech(k, best)) {
234
318
  best = k;
235
319
  }
236
320
  }
237
- const restBlocks = u.blocks.splice(best);
238
- const restRms = u.rms.splice(best);
321
+ const rest = u.blocks
322
+ .splice(best)
323
+ .map((block, i) => ({
324
+ block,
325
+ rms: u.rms[best + i],
326
+ speech: u.speech?.[best + i] ?? 0,
327
+ }));
328
+ u.rms.splice(best);
329
+ u.speech?.splice(best);
239
330
  u.length = u.blocks.reduce((a, b) => a + b.length, 0);
240
- const next = this.newUtterance(restBlocks, restRms);
331
+ const next = this.newUtterance(rest);
332
+ const sp = this.speech;
241
333
  const threshold = this.sustainThreshold();
242
- next.speechMs = restRms.filter((r) => r > threshold).length * BLOCK_MS;
334
+ next.speechMs =
335
+ rest.filter((r) => (sp ? r.speech >= sp.sustain : r.rms > threshold))
336
+ .length * BLOCK_MS;
337
+ // The moved blocks' speech now belongs to the next utterance, not this one.
338
+ u.speechMs = Math.max(0, u.speechMs - next.speechMs);
243
339
  this.utt = next;
244
340
  events.push({ type: "end", utterance: u, reason: "hardCut" });
245
341
  events.push({ type: "start", utterance: next });
@@ -12,7 +12,13 @@
12
12
  *
13
13
  * @module voice/transcribe
14
14
  */
15
- import type { STTOptions, TranscribeAudioInput, TranscribeDeps, TranscribeOptions, TranscribeResult, TTSAudioFormat } from "../types/index.js";
15
+ import type { STTOptions, STTStreamingOptions, TranscribeAudioInput, TranscribeDeps, TranscribeOptions, TranscribeResult, TTSAudioFormat } from "../types/index.js";
16
+ /**
17
+ * The first `seconds` of a PCM WAV as a smaller WAV, so a language probe
18
+ * uploads kilobytes instead of the whole recording. Anything that is not a
19
+ * plain RIFF/WAVE with a readable `fmt ` and `data` chunk is returned as is.
20
+ */
21
+ export declare function wavHead(buffer: Buffer, seconds: number): Buffer;
16
22
  /** The container a path or URL names by its extension, if it is a known one. */
17
23
  export declare function audioFormatFromName(name: string): TTSAudioFormat | undefined;
18
24
  /**
@@ -45,3 +51,14 @@ export declare function buildEngineOptions(options: Omit<TranscribeOptions, "aud
45
51
  * or the primary engine fails with no fallback that covers errors
46
52
  */
47
53
  export declare function runTranscribe(options: TranscribeOptions, deps: TranscribeDeps): Promise<TranscribeResult>;
54
+ /**
55
+ * The stream options a recording gets when it goes through the live
56
+ * pipeline, whether by `chunking: "utterances"` or over the WebSocket with
57
+ * `mode: "file"`: no interim passes, several utterances at a time, one retry
58
+ * of a failed final, file-sized engine timeouts and shorter cuts. Anything
59
+ * the caller set explicitly is kept.
60
+ */
61
+ export declare function fileModeStreamOptions<T extends {
62
+ streaming?: STTStreamingOptions;
63
+ timeoutMs?: number;
64
+ }>(options: T): T;