@juspay/neurolink 13.7.0 → 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 (81) hide show
  1. package/CHANGELOG.md +3 -3
  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/neurolink.js +33 -1
  11. package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
  12. package/dist/observability/exporters/langfuseExporter.js +29 -8
  13. package/dist/observability/otlpEncoding.js +2 -2
  14. package/dist/server/abstract/baseServerAdapter.js +2 -0
  15. package/dist/server/adapters/expressAdapter.js +1 -1
  16. package/dist/server/adapters/fastifyAdapter.js +4 -1
  17. package/dist/server/adapters/honoAdapter.js +1 -1
  18. package/dist/server/adapters/koaAdapter.js +1 -1
  19. package/dist/server/routes/transcribeRoutes.d.ts +0 -8
  20. package/dist/server/routes/transcribeRoutes.js +130 -3
  21. package/dist/server/utils/validation.d.ts +409 -15
  22. package/dist/server/utils/validation.js +106 -4
  23. package/dist/server/websocket/frameQueue.d.ts +16 -0
  24. package/dist/server/websocket/frameQueue.js +55 -0
  25. package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
  26. package/dist/server/websocket/realtimeTranscription.js +603 -0
  27. package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
  28. package/dist/server/websocket/transcribeWebSocket.js +263 -64
  29. package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
  30. package/dist/types/cli.d.ts +6 -0
  31. package/dist/types/exporter.d.ts +3 -1
  32. package/dist/types/server.d.ts +304 -2
  33. package/dist/types/stt.d.ts +38 -3
  34. package/dist/types/transcribe.d.ts +401 -5
  35. package/dist/voice/audioLevel.d.ts +14 -0
  36. package/dist/voice/audioLevel.js +60 -0
  37. package/dist/voice/codeMix.d.ts +40 -0
  38. package/dist/voice/codeMix.js +282 -0
  39. package/dist/voice/correction/align.d.ts +12 -0
  40. package/dist/voice/correction/align.js +48 -0
  41. package/dist/voice/correction/guards.d.ts +39 -0
  42. package/dist/voice/correction/guards.js +122 -0
  43. package/dist/voice/correction/index.d.ts +25 -3
  44. package/dist/voice/correction/index.js +132 -4
  45. package/dist/voice/correction/rewrite.d.ts +64 -3
  46. package/dist/voice/correction/rewrite.js +295 -25
  47. package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
  48. package/dist/voice/eval/sttEvalLangfuse.js +182 -0
  49. package/dist/voice/eval/sttEvalStore.d.ts +27 -0
  50. package/dist/voice/eval/sttEvalStore.js +397 -0
  51. package/dist/voice/index.js +3 -0
  52. package/dist/voice/languages.d.ts +34 -0
  53. package/dist/voice/languages.js +290 -0
  54. package/dist/voice/modelAssets.d.ts +28 -0
  55. package/dist/voice/modelAssets.js +113 -0
  56. package/dist/voice/output/render.d.ts +32 -0
  57. package/dist/voice/output/render.js +368 -0
  58. package/dist/voice/providers/OpenAISTT.d.ts +11 -1
  59. package/dist/voice/providers/OpenAISTT.js +57 -6
  60. package/dist/voice/session/transcriptionSession.d.ts +55 -0
  61. package/dist/voice/session/transcriptionSession.js +225 -0
  62. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
  63. package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
  64. package/dist/voice/streaming/endpointer.d.ts +50 -17
  65. package/dist/voice/streaming/endpointer.js +115 -19
  66. package/dist/voice/transcribe.d.ts +18 -1
  67. package/dist/voice/transcribe.js +459 -18
  68. package/dist/voice/transcribeStream.d.ts +18 -1
  69. package/dist/voice/transcribeStream.js +413 -16
  70. package/dist/voice/vad/assets.d.ts +36 -0
  71. package/dist/voice/vad/assets.js +142 -0
  72. package/dist/voice/vad/frameScorer.d.ts +28 -0
  73. package/dist/voice/vad/frameScorer.js +90 -0
  74. package/dist/voice/vad/sileroVad.d.ts +43 -0
  75. package/dist/voice/vad/sileroVad.js +178 -0
  76. package/dist/voice/whistle/assets.d.ts +3 -4
  77. package/dist/voice/whistle/assets.js +11 -95
  78. package/docs-site/static/search-index.json +21 -6
  79. package/models/silero-vad/README.md +19 -0
  80. package/models/silero-vad/silero_vad.onnx +0 -0
  81. package/package.json +3 -1
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Code-mix analysis between two engines' readings of the same speech.
3
+ *
4
+ * An Indic engine writes English words phonetically in its own script
5
+ * ("मीटिंग" for "meeting"); an English-oriented engine writes Indian-language
6
+ * words as English-sounding guesses ("page dinner" for "भेज देना"). Brought
7
+ * to one rough phonetic form — Indic text romanized, both reduced to a
8
+ * consonant skeleton with voicing and aspiration folded — the two sequences
9
+ * line up word for word wherever the speaker spoke English, and stop lining
10
+ * up where they spoke an Indian language. The share of the Indic engine's
11
+ * words that find a phonetic partner is therefore an estimate of how much of
12
+ * the utterance was English, independent of either engine's own (accent-
13
+ * sensitive) language score; the alignment itself is what a word-level
14
+ * combination of the two readings is built on.
15
+ *
16
+ * @module voice/codeMix
17
+ */
18
+ /** Devanagari (and, by block offset, every other Indic script) to rough Latin. */
19
+ const CONSONANTS = {
20
+ 0x15: "k",
21
+ 0x16: "kh",
22
+ 0x17: "g",
23
+ 0x18: "gh",
24
+ 0x19: "n",
25
+ 0x1a: "ch",
26
+ 0x1b: "chh",
27
+ 0x1c: "j",
28
+ 0x1d: "jh",
29
+ 0x1e: "n",
30
+ 0x1f: "t",
31
+ 0x20: "th",
32
+ 0x21: "d",
33
+ 0x22: "dh",
34
+ 0x23: "n",
35
+ 0x24: "t",
36
+ 0x25: "th",
37
+ 0x26: "d",
38
+ 0x27: "dh",
39
+ 0x28: "n",
40
+ 0x29: "n",
41
+ 0x2a: "p",
42
+ 0x2b: "ph",
43
+ 0x2c: "b",
44
+ 0x2d: "bh",
45
+ 0x2e: "m",
46
+ 0x2f: "y",
47
+ 0x30: "r",
48
+ 0x31: "r",
49
+ 0x32: "l",
50
+ 0x33: "l",
51
+ 0x34: "l",
52
+ 0x35: "v",
53
+ 0x36: "sh",
54
+ 0x37: "sh",
55
+ 0x38: "s",
56
+ 0x39: "h",
57
+ 0x58: "q",
58
+ 0x59: "kh",
59
+ 0x5a: "g",
60
+ 0x5b: "z",
61
+ 0x5c: "r",
62
+ 0x5d: "rh",
63
+ 0x5e: "f",
64
+ 0x5f: "y",
65
+ };
66
+ /** The same consonant with a nukta under it. */
67
+ const NUKTA = {
68
+ 0x15: "q",
69
+ 0x16: "kh",
70
+ 0x17: "g",
71
+ 0x1c: "z",
72
+ 0x21: "r",
73
+ 0x22: "rh",
74
+ 0x2b: "f",
75
+ 0x2f: "y",
76
+ };
77
+ const VOWELS = {
78
+ 0x05: "a",
79
+ 0x06: "aa",
80
+ 0x07: "i",
81
+ 0x08: "ii",
82
+ 0x09: "u",
83
+ 0x0a: "uu",
84
+ 0x0b: "ri",
85
+ 0x0d: "e",
86
+ 0x0e: "e",
87
+ 0x0f: "e",
88
+ 0x10: "ai",
89
+ 0x11: "o",
90
+ 0x12: "o",
91
+ 0x13: "o",
92
+ 0x14: "au",
93
+ };
94
+ const MATRAS = {
95
+ 0x3e: "aa",
96
+ 0x3f: "i",
97
+ 0x40: "ii",
98
+ 0x41: "u",
99
+ 0x42: "uu",
100
+ 0x43: "ri",
101
+ 0x45: "e",
102
+ 0x46: "e",
103
+ 0x47: "e",
104
+ 0x48: "ai",
105
+ 0x49: "o",
106
+ 0x4a: "o",
107
+ 0x4b: "o",
108
+ 0x4c: "au",
109
+ };
110
+ const VIRAMA = 0x4d;
111
+ const NUKTA_SIGN = 0x3c;
112
+ /** Offset of `cp` inside its 128-code-point Indic block, or -1 outside Devanagari…Malayalam. */
113
+ function indicOffset(cp) {
114
+ return cp >= 0x0900 && cp <= 0x0d7f ? (cp - 0x0900) % 0x80 : -1;
115
+ }
116
+ /** Indic-script text in rough Latin letters; anything else passes through. */
117
+ export function romanizeIndic(text) {
118
+ const cps = [...text].map((ch) => ch.codePointAt(0) ?? 0);
119
+ let out = "";
120
+ for (let i = 0; i < cps.length; i++) {
121
+ const off = indicOffset(cps[i]);
122
+ if (off < 0) {
123
+ out += String.fromCodePoint(cps[i]);
124
+ continue;
125
+ }
126
+ const nukta = indicOffset(cps[i + 1] ?? 0) === NUKTA_SIGN;
127
+ const consonant = (nukta ? NUKTA[off] : undefined) ?? CONSONANTS[off];
128
+ if (consonant) {
129
+ out += consonant;
130
+ const nextOff = indicOffset(cps[i + (nukta ? 2 : 1)] ?? 0);
131
+ if (nukta) {
132
+ i++;
133
+ }
134
+ // The inherent vowel, unless a matra or virama follows.
135
+ if (nextOff < 0 ||
136
+ (MATRAS[nextOff] === undefined && nextOff !== VIRAMA)) {
137
+ out += "a";
138
+ }
139
+ continue;
140
+ }
141
+ if (VOWELS[off]) {
142
+ out += VOWELS[off];
143
+ }
144
+ else if (MATRAS[off]) {
145
+ out += MATRAS[off];
146
+ }
147
+ else if (off === 0x02 || off === 0x01) {
148
+ out += "n";
149
+ }
150
+ else if (off === 0x03) {
151
+ out += "h";
152
+ }
153
+ else if (off === 0x64 || off === 0x65) {
154
+ out += ".";
155
+ }
156
+ else if (off >= 0x66 && off <= 0x6f) {
157
+ out += String(off - 0x66);
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ /**
163
+ * A word's consonant skeleton: digraphs folded, voicing pairs merged
164
+ * (t/d, k/g, p/b are where an accent and a script disagree most), vowels and
165
+ * h dropped, repeats collapsed. "meeting" and "मीटिंग" both give "mtnk".
166
+ */
167
+ export function phoneticKey(word) {
168
+ let w = romanizeIndic(word)
169
+ .toLowerCase()
170
+ .normalize("NFKD")
171
+ .replace(/[^a-z]/gu, "");
172
+ w = w
173
+ .replace(/chh|ch/gu, "c")
174
+ .replace(/ck/gu, "k")
175
+ .replace(/qu/gu, "kv")
176
+ .replace(/ph/gu, "f")
177
+ .replace(/sh/gu, "s")
178
+ .replace(/x/gu, "ks")
179
+ .replace(/([kgbtdj])h/gu, "$1")
180
+ .replace(/[dt]/gu, "t")
181
+ .replace(/[gkcq]/gu, "k")
182
+ .replace(/[bp]/gu, "p")
183
+ .replace(/[zj]/gu, "j")
184
+ .replace(/w/gu, "v")
185
+ .replace(/[aeiouyh]/gu, "");
186
+ return w.replace(/(.)\1+/gu, "$1") || "_";
187
+ }
188
+ function editDistance(a, b) {
189
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
190
+ for (let i = 1; i <= a.length; i++) {
191
+ let prev = row[0];
192
+ row[0] = i;
193
+ for (let j = 1; j <= b.length; j++) {
194
+ const tmp = row[j];
195
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
196
+ prev = tmp;
197
+ }
198
+ }
199
+ return row[b.length];
200
+ }
201
+ /** Whether two skeletons sound alike: equal, or one edit apart on three or more consonants (two on six). */
202
+ export function soundsAlike(a, b) {
203
+ if (a === b) {
204
+ return a !== "_";
205
+ }
206
+ const n = Math.max(a.length, b.length);
207
+ if (n < 3) {
208
+ return false;
209
+ }
210
+ return editDistance(a, b) <= (n >= 6 ? 2 : 1);
211
+ }
212
+ function wordsOf(text) {
213
+ return text
214
+ .split(/\s+/u)
215
+ .map((w) => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
216
+ .filter(Boolean);
217
+ }
218
+ /**
219
+ * Align two readings word by word (Needleman–Wunsch on skeletons): a pair
220
+ * for every word of either side, matched where the words sound alike.
221
+ */
222
+ export function alignReadings(indic, latin) {
223
+ const ka = indic.map(phoneticKey);
224
+ const kb = latin.map(phoneticKey);
225
+ const n = ka.length;
226
+ const m = kb.length;
227
+ const MATCH = 2;
228
+ const MISMATCH = -1;
229
+ const GAP = -1;
230
+ const score = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
231
+ for (let i = 1; i <= n; i++) {
232
+ score[i][0] = i * GAP;
233
+ }
234
+ for (let j = 1; j <= m; j++) {
235
+ score[0][j] = j * GAP;
236
+ }
237
+ for (let i = 1; i <= n; i++) {
238
+ for (let j = 1; j <= m; j++) {
239
+ const s = soundsAlike(ka[i - 1], kb[j - 1]) ? MATCH : MISMATCH;
240
+ score[i][j] = Math.max(score[i - 1][j - 1] + s, score[i - 1][j] + GAP, score[i][j - 1] + GAP);
241
+ }
242
+ }
243
+ const pairs = [];
244
+ let i = n;
245
+ let j = m;
246
+ while (i > 0 || j > 0) {
247
+ if (i > 0 && j > 0) {
248
+ const alike = soundsAlike(ka[i - 1], kb[j - 1]);
249
+ if (score[i][j] === score[i - 1][j - 1] + (alike ? MATCH : MISMATCH)) {
250
+ pairs.push({ indic: indic[i - 1], latin: latin[j - 1], alike });
251
+ i--;
252
+ j--;
253
+ continue;
254
+ }
255
+ }
256
+ if (i > 0 && score[i][j] === score[i - 1][j] + GAP) {
257
+ pairs.push({ indic: indic[i - 1], alike: false });
258
+ i--;
259
+ }
260
+ else {
261
+ pairs.push({ latin: latin[j - 1], alike: false });
262
+ j--;
263
+ }
264
+ }
265
+ return pairs.reverse();
266
+ }
267
+ /**
268
+ * How much of an utterance two readings agree is English: the share of the
269
+ * Indic engine's words with a phonetic partner in the English-oriented
270
+ * engine's text. Near 1 for English an Indic engine wrote phonetically; well
271
+ * under it for code-mixed speech, where the Indian-language words find none.
272
+ */
273
+ export function analyzeCodeMix(indicText, latinText) {
274
+ const a = wordsOf(indicText);
275
+ const b = wordsOf(latinText);
276
+ if (a.length === 0 || b.length === 0) {
277
+ return { englishShare: a.length === 0 ? 1 : 0, words: a.length, pairs: [] };
278
+ }
279
+ const pairs = alignReadings(a, b);
280
+ const matched = pairs.filter((p) => p.alike && p.indic !== undefined).length;
281
+ return { englishShare: matched / a.length, words: a.length, pairs };
282
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Word timings for corrected text. The engine timed the words it wrote; a
3
+ * rewrite changes them (spelling, script, punctuation), so the timings are
4
+ * carried over: one for one when the word count is unchanged, else spread
5
+ * over the same span in proportion to each word's length. Speaker labels are
6
+ * kept only where every source word agrees.
7
+ *
8
+ * @module voice/correction/align
9
+ */
10
+ import type { WordTiming } from "../../types/index.js";
11
+ /** `words` re-timed onto the words of `text`; empty when there is nothing to align. */
12
+ export declare function alignWordsToText(words: readonly WordTiming[] | undefined, text: string): WordTiming[];
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Word timings for corrected text. The engine timed the words it wrote; a
3
+ * rewrite changes them (spelling, script, punctuation), so the timings are
4
+ * carried over: one for one when the word count is unchanged, else spread
5
+ * over the same span in proportion to each word's length. Speaker labels are
6
+ * kept only where every source word agrees.
7
+ *
8
+ * @module voice/correction/align
9
+ */
10
+ function startOf(w) {
11
+ return w.startTime ?? w.start;
12
+ }
13
+ function endOf(w) {
14
+ return w.endTime ?? w.end;
15
+ }
16
+ /** `words` re-timed onto the words of `text`; empty when there is nothing to align. */
17
+ export function alignWordsToText(words, text) {
18
+ const tokens = text.split(/\s+/u).filter(Boolean);
19
+ if (!words?.length || tokens.length === 0) {
20
+ return [];
21
+ }
22
+ if (tokens.length === words.length) {
23
+ return words.map((w, i) => ({ ...w, word: tokens[i] }));
24
+ }
25
+ const spanStart = startOf(words[0]);
26
+ const spanEnd = endOf(words[words.length - 1]) ?? startOf(words[words.length - 1]);
27
+ if (spanStart === undefined || spanEnd === undefined || spanEnd < spanStart) {
28
+ return [];
29
+ }
30
+ const speakers = new Set(words.map((w) => w.speaker));
31
+ const speaker = speakers.size === 1 ? words[0].speaker : undefined;
32
+ const total = tokens.reduce((n, t) => n + t.length, 0);
33
+ const span = spanEnd - spanStart;
34
+ let at = spanStart;
35
+ return tokens.map((token) => {
36
+ const start = at;
37
+ const end = start + (span * token.length) / total;
38
+ at = end;
39
+ const timed = {
40
+ word: token,
41
+ start,
42
+ startTime: start,
43
+ end,
44
+ endTime: end,
45
+ };
46
+ return speaker ? { ...timed, speaker } : timed;
47
+ });
48
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Guards on raw engine output, applied before LocalAgreement, the dictionary
3
+ * and the rewrite ever see a text. They catch the two ways a speech engine
4
+ * produces words that were never said:
5
+ *
6
+ * - a decoding loop ("Same one. Same one. Same one. …" for hundreds of
7
+ * words), which Qwen3-ASR falls into on long or noisy chunks, and which the
8
+ * correction layer cannot repair on purpose (removing the repeats would
9
+ * trip the never-lose-words drop guard);
10
+ * - a prompt echo, where an engine answers a near-silent chunk with the
11
+ * vocabulary prompt it was given ("Names and terms used: …").
12
+ *
13
+ * Both are deterministic and cheap, so they run on every pass.
14
+ *
15
+ * @module voice/correction/guards
16
+ */
17
+ /**
18
+ * Collapse runaway repetition: any phrase of 1 to {@link MAX_NGRAM} words that
19
+ * repeats itself {@link LOOP_MIN_REPEATS} or more times in a row is cut down to
20
+ * {@link KEEP_REPEATS} occurrences. Returns the text and how many words were
21
+ * removed; `removed` is 0 when the text was left alone. Spacing between the
22
+ * surviving words is normalised to single spaces.
23
+ */
24
+ export declare function collapseRepetitions(text: string): {
25
+ text: string;
26
+ removed: number;
27
+ };
28
+ /**
29
+ * True when an engine's output is the prompt it was given, or a subset of
30
+ * it: every word of the output occurs in `promptTerms` (the vocabulary
31
+ * terms and the fixed phrasing around them), AND it reads like the prompt
32
+ * rather than speech — it carries the fixed phrasing, or lists two or more
33
+ * distinct terms. A speaker who says only "Juspay." said a term; that is
34
+ * speech and is kept. An output with no words is not an echo; the empty
35
+ * path handles it.
36
+ */
37
+ export declare function isPromptEcho(text: string, promptTerms: readonly string[]): boolean;
38
+ /** Share of a text's words that came from a collapsed loop, for a "mostly loop" decision. */
39
+ export declare function loopShare(removed: number, originalWordCount: number): number;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Guards on raw engine output, applied before LocalAgreement, the dictionary
3
+ * and the rewrite ever see a text. They catch the two ways a speech engine
4
+ * produces words that were never said:
5
+ *
6
+ * - a decoding loop ("Same one. Same one. Same one. …" for hundreds of
7
+ * words), which Qwen3-ASR falls into on long or noisy chunks, and which the
8
+ * correction layer cannot repair on purpose (removing the repeats would
9
+ * trip the never-lose-words drop guard);
10
+ * - a prompt echo, where an engine answers a near-silent chunk with the
11
+ * vocabulary prompt it was given ("Names and terms used: …").
12
+ *
13
+ * Both are deterministic and cheap, so they run on every pass.
14
+ *
15
+ * @module voice/correction/guards
16
+ */
17
+ /** Longest phrase (in words) a loop is looked for; longer repeats are real speech. */
18
+ const MAX_NGRAM = 6;
19
+ /** Repeats of one phrase in a row that count as a loop; "no, no, no" stays. */
20
+ const LOOP_MIN_REPEATS = 4;
21
+ /** Occurrences kept when a loop is collapsed. */
22
+ const KEEP_REPEATS = 2;
23
+ /** A word stripped of punctuation and case, for comparing repeats. */
24
+ function key(word) {
25
+ return word
26
+ .toLowerCase()
27
+ .replace(/^[\s"'“”‘’([{¿¡]+|[\s"'“”‘’)\]}.,!?;:…।॥]+$/gu, "");
28
+ }
29
+ /**
30
+ * Collapse runaway repetition: any phrase of 1 to {@link MAX_NGRAM} words that
31
+ * repeats itself {@link LOOP_MIN_REPEATS} or more times in a row is cut down to
32
+ * {@link KEEP_REPEATS} occurrences. Returns the text and how many words were
33
+ * removed; `removed` is 0 when the text was left alone. Spacing between the
34
+ * surviving words is normalised to single spaces.
35
+ */
36
+ export function collapseRepetitions(text) {
37
+ const words = text.split(/\s+/u).filter(Boolean);
38
+ if (words.length < LOOP_MIN_REPEATS) {
39
+ return { text, removed: 0 };
40
+ }
41
+ const keys = words.map(key);
42
+ const out = [];
43
+ let removed = 0;
44
+ let i = 0;
45
+ while (i < words.length) {
46
+ let collapsed = false;
47
+ for (let n = 1; n <= MAX_NGRAM && i + n * LOOP_MIN_REPEATS <= words.length; n++) {
48
+ // How many times does the n-gram starting at i repeat back to back?
49
+ let repeats = 1;
50
+ while (i + (repeats + 1) * n <= words.length &&
51
+ sameNgram(keys, i, i + repeats * n, n)) {
52
+ repeats++;
53
+ }
54
+ if (repeats >= LOOP_MIN_REPEATS) {
55
+ for (let k = 0; k < KEEP_REPEATS * n; k++) {
56
+ out.push(words[i + k]);
57
+ }
58
+ removed += (repeats - KEEP_REPEATS) * n;
59
+ i += repeats * n;
60
+ collapsed = true;
61
+ break;
62
+ }
63
+ }
64
+ if (!collapsed) {
65
+ out.push(words[i]);
66
+ i++;
67
+ }
68
+ }
69
+ return removed > 0 ? { text: out.join(" "), removed } : { text, removed: 0 };
70
+ }
71
+ function sameNgram(keys, a, b, n) {
72
+ for (let k = 0; k < n; k++) {
73
+ if (keys[a + k] !== keys[b + k] || keys[a + k] === "") {
74
+ return false;
75
+ }
76
+ }
77
+ return true;
78
+ }
79
+ /**
80
+ * True when an engine's output is the prompt it was given, or a subset of
81
+ * it: every word of the output occurs in `promptTerms` (the vocabulary
82
+ * terms and the fixed phrasing around them), AND it reads like the prompt
83
+ * rather than speech — it carries the fixed phrasing, or lists two or more
84
+ * distinct terms. A speaker who says only "Juspay." said a term; that is
85
+ * speech and is kept. An output with no words is not an echo; the empty
86
+ * path handles it.
87
+ */
88
+ export function isPromptEcho(text, promptTerms) {
89
+ const words = text.split(/\s+/u).map(key).filter(Boolean);
90
+ if (words.length === 0 || promptTerms.length === 0) {
91
+ return false;
92
+ }
93
+ const allowed = new Set();
94
+ for (const term of promptTerms) {
95
+ for (const w of term.split(/\s+/u).map(key)) {
96
+ if (w) {
97
+ allowed.add(w);
98
+ }
99
+ }
100
+ }
101
+ if (!words.every((w) => allowed.has(w))) {
102
+ return false;
103
+ }
104
+ const joined = ` ${words.join(" ")} `;
105
+ if (joined.includes(` ${PROMPT_PHRASE} `)) {
106
+ return true;
107
+ }
108
+ const termsSeen = new Set();
109
+ for (const term of promptTerms) {
110
+ const phrase = term.split(/\s+/u).map(key).filter(Boolean).join(" ");
111
+ if (phrase && phrase !== PROMPT_PHRASE && joined.includes(` ${phrase} `)) {
112
+ termsSeen.add(phrase);
113
+ }
114
+ }
115
+ return termsSeen.size >= 2;
116
+ }
117
+ /** The fixed phrasing a vocabulary prompt carries, as keyed words. */
118
+ const PROMPT_PHRASE = "names and terms used";
119
+ /** Share of a text's words that came from a collapsed loop, for a "mostly loop" decision. */
120
+ export function loopShare(removed, originalWordCount) {
121
+ return originalWordCount > 0 ? removed / originalWordCount : 0;
122
+ }
@@ -12,9 +12,9 @@
12
12
  */
13
13
  import { applyDictionary, dictionaryBlock, dictionaryVocabulary, findDictionaryCandidates } from "./dictionary.js";
14
14
  import { decideCandidates } from "./guard.js";
15
- import { buildRewriteSystemPrompt, buildRewriteUserPrompt, rewriteLines, rewriteTranscript } from "./rewrite.js";
15
+ import { buildRewriteSystemPrompt, buildRewriteUserPrompt, rewriteLines, rewriteTranscript, rewriteWanted } from "./rewrite.js";
16
16
  import type { STTCorrectionDeps, STTCorrectionInput, STTCorrectionOptions, STTCorrectionOutput, STTDecisionRecord, STTDictionaryEntry } from "../../types/index.js";
17
- export { applyDictionary, buildRewriteSystemPrompt, buildRewriteUserPrompt, decideCandidates, dictionaryBlock, dictionaryVocabulary, findDictionaryCandidates, rewriteLines, rewriteTranscript, };
17
+ export { applyDictionary, buildRewriteSystemPrompt, buildRewriteUserPrompt, decideCandidates, dictionaryBlock, dictionaryVocabulary, findDictionaryCandidates, rewriteLines, rewriteTranscript, rewriteWanted, };
18
18
  /** Correction is on when asked for, or by default when a dictionary or a rewrite is configured. */
19
19
  export declare function isCorrectionEnabled(dictionary: readonly STTDictionaryEntry[] | undefined, options: STTCorrectionOptions | undefined): boolean;
20
20
  /**
@@ -30,6 +30,28 @@ export declare function isCorrectionEnabled(dictionary: readonly STTDictionaryEn
30
30
  * Indian-language words it did catch are not lost.
31
31
  */
32
32
  export declare function correctTranscript(input: STTCorrectionInput, deps: STTCorrectionDeps): Promise<STTCorrectionOutput>;
33
+ /**
34
+ * Split a transcript into sentences on terminal punctuation (., !, ?, the
35
+ * danda, an ellipsis), keeping the marks. Text without any mark is one
36
+ * sentence; a split that would leave a fragment under three words glues it
37
+ * to its neighbour so a stray "No." never travels alone.
38
+ */
39
+ export declare function splitSentences(text: string): string[];
40
+ /**
41
+ * Correct one final sentence by sentence: each sentence gets its own
42
+ * dictionary pass, its own "does this need the rewrite" decision (an
43
+ * English sentence that is already clean costs nothing; a Devanagari one
44
+ * is romanised) and sentences run three at a time. The joined text has the
45
+ * same sentences in the same order; a sentence whose correction fails keeps
46
+ * its words. `onPartial` receives the whole utterance with the sentences
47
+ * corrected so far, so a caption can update progressively.
48
+ *
49
+ * Falls back to a single whole-text pass when there is one sentence or a
50
+ * second opinion (whole-utterance by nature) to reconcile.
51
+ */
52
+ export declare function correctTranscriptSentences(input: STTCorrectionInput, deps: STTCorrectionDeps): Promise<STTCorrectionOutput & {
53
+ sentences: number;
54
+ }>;
33
55
  /**
34
56
  * Correct a long transcript line by line (file transcription, diarized
35
57
  * turns). With the rewrite on, lines go to the model in numbered batches of
@@ -39,7 +61,7 @@ export declare function correctTranscript(input: STTCorrectionInput, deps: STTCo
39
61
  * four at a time) and the dictionary is applied per line. The result always
40
62
  * has exactly as many lines as the input.
41
63
  */
42
- export declare function correctTranscriptLines(lines: readonly string[], dictionary: readonly STTDictionaryEntry[] | undefined, options: STTCorrectionOptions | undefined, deps: STTCorrectionDeps): Promise<{
64
+ export declare function correctTranscriptLines(lines: readonly string[], dictionary: readonly STTDictionaryEntry[] | undefined, options: STTCorrectionOptions | undefined, deps: STTCorrectionDeps, language?: string): Promise<{
43
65
  lines: string[];
44
66
  decisions: STTDecisionRecord[];
45
67
  steps: string[];