@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.
- package/CHANGELOG.md +3 -6
- package/dist/browser/neurolink.min.js +480 -460
- package/dist/cli/commands/serve.js +11 -1
- package/dist/cli/commands/transcribe.js +39 -3
- package/dist/constants/langfuse.d.ts +2 -0
- package/dist/constants/langfuse.js +2 -0
- package/dist/factories/sttDescriptors.js +7 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -2
- package/dist/mcp/externalServerManager.js +4 -1
- package/dist/mcp/mcpClientFactory.d.ts +6 -0
- package/dist/mcp/mcpClientFactory.js +44 -0
- package/dist/neurolink.js +42 -3
- package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
- package/dist/observability/exporters/langfuseExporter.js +29 -8
- package/dist/observability/otlpEncoding.js +2 -2
- package/dist/server/abstract/baseServerAdapter.js +2 -0
- package/dist/server/adapters/expressAdapter.js +1 -1
- package/dist/server/adapters/fastifyAdapter.js +4 -1
- package/dist/server/adapters/honoAdapter.js +1 -1
- package/dist/server/adapters/koaAdapter.js +1 -1
- package/dist/server/routes/transcribeRoutes.d.ts +0 -8
- package/dist/server/routes/transcribeRoutes.js +130 -3
- package/dist/server/utils/validation.d.ts +409 -15
- package/dist/server/utils/validation.js +106 -4
- package/dist/server/websocket/frameQueue.d.ts +16 -0
- package/dist/server/websocket/frameQueue.js +55 -0
- package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
- package/dist/server/websocket/realtimeTranscription.js +603 -0
- package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
- package/dist/server/websocket/transcribeWebSocket.js +263 -64
- package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/exporter.d.ts +3 -1
- package/dist/types/server.d.ts +304 -2
- package/dist/types/stt.d.ts +38 -3
- package/dist/types/transcribe.d.ts +401 -5
- package/dist/voice/audioLevel.d.ts +14 -0
- package/dist/voice/audioLevel.js +60 -0
- package/dist/voice/codeMix.d.ts +40 -0
- package/dist/voice/codeMix.js +282 -0
- package/dist/voice/correction/align.d.ts +12 -0
- package/dist/voice/correction/align.js +48 -0
- package/dist/voice/correction/guards.d.ts +39 -0
- package/dist/voice/correction/guards.js +122 -0
- package/dist/voice/correction/index.d.ts +25 -3
- package/dist/voice/correction/index.js +132 -4
- package/dist/voice/correction/rewrite.d.ts +64 -3
- package/dist/voice/correction/rewrite.js +295 -25
- package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
- package/dist/voice/eval/sttEvalLangfuse.js +182 -0
- package/dist/voice/eval/sttEvalStore.d.ts +27 -0
- package/dist/voice/eval/sttEvalStore.js +397 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/languages.d.ts +34 -0
- package/dist/voice/languages.js +290 -0
- package/dist/voice/modelAssets.d.ts +28 -0
- package/dist/voice/modelAssets.js +113 -0
- package/dist/voice/output/render.d.ts +32 -0
- package/dist/voice/output/render.js +368 -0
- package/dist/voice/providers/OpenAISTT.d.ts +11 -1
- package/dist/voice/providers/OpenAISTT.js +57 -6
- package/dist/voice/session/transcriptionSession.d.ts +55 -0
- package/dist/voice/session/transcriptionSession.js +225 -0
- package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
- package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
- package/dist/voice/streaming/endpointer.d.ts +50 -17
- package/dist/voice/streaming/endpointer.js +115 -19
- package/dist/voice/transcribe.d.ts +18 -1
- package/dist/voice/transcribe.js +459 -18
- package/dist/voice/transcribeStream.d.ts +18 -1
- package/dist/voice/transcribeStream.js +413 -16
- package/dist/voice/vad/assets.d.ts +36 -0
- package/dist/voice/vad/assets.js +142 -0
- package/dist/voice/vad/frameScorer.d.ts +28 -0
- package/dist/voice/vad/frameScorer.js +90 -0
- package/dist/voice/vad/sileroVad.d.ts +43 -0
- package/dist/voice/vad/sileroVad.js +178 -0
- package/dist/voice/whistle/assets.d.ts +3 -4
- package/dist/voice/whistle/assets.js +11 -95
- package/docs-site/static/search-index.json +21 -6
- package/models/silero-vad/README.md +19 -0
- package/models/silero-vad/silero_vad.onnx +0 -0
- 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[];
|