@juspay/neurolink 12.48.1 → 12.49.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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +616 -595
- package/dist/cli/commands/serve.d.ts +5 -3
- package/dist/cli/commands/serve.js +113 -20
- package/dist/cli/commands/server.js +13 -6
- package/dist/cli/commands/transcribe.d.ts +24 -0
- package/dist/cli/commands/transcribe.js +718 -0
- package/dist/cli/factories/commandFactory.d.ts +10 -0
- package/dist/cli/factories/commandFactory.js +13 -0
- package/dist/cli/parser.js +3 -0
- package/dist/cli/utils/serverUtils.d.ts +28 -0
- package/dist/cli/utils/serverUtils.js +71 -0
- package/dist/factories/mediaHandlerCatalog.js +11 -9
- package/dist/factories/sttDescriptors.d.ts +63 -0
- package/dist/factories/sttDescriptors.js +286 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +7 -1
- package/dist/neurolink.d.ts +68 -1
- package/dist/neurolink.js +202 -4
- package/dist/processors/media/AudioProcessor.d.ts +15 -56
- package/dist/processors/media/AudioProcessor.js +105 -276
- package/dist/processors/media/VideoProcessor.d.ts +3 -5
- package/dist/processors/media/VideoProcessor.js +52 -51
- package/dist/server/abstract/baseServerAdapter.d.ts +28 -1
- package/dist/server/abstract/baseServerAdapter.js +84 -4
- package/dist/server/adapters/expressAdapter.d.ts +1 -0
- package/dist/server/adapters/expressAdapter.js +50 -10
- package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
- package/dist/server/adapters/fastifyAdapter.js +51 -8
- package/dist/server/adapters/honoAdapter.d.ts +1 -4
- package/dist/server/adapters/honoAdapter.js +95 -8
- package/dist/server/adapters/koaAdapter.d.ts +7 -0
- package/dist/server/adapters/koaAdapter.js +71 -10
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.js +3 -0
- package/dist/server/routes/transcribeRoutes.d.ts +23 -0
- package/dist/server/routes/transcribeRoutes.js +506 -0
- package/dist/server/utils/rawBody.d.ts +32 -0
- package/dist/server/utils/rawBody.js +76 -0
- package/dist/server/utils/validation.d.ts +192 -0
- package/dist/server/utils/validation.js +100 -0
- package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
- package/dist/server/websocket/transcribeWebSocket.js +264 -0
- package/dist/types/cli.d.ts +47 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/providers.d.ts +7 -1
- package/dist/types/server.d.ts +117 -1
- package/dist/types/stt.d.ts +160 -7
- package/dist/types/transcribe.d.ts +506 -0
- package/dist/types/transcribe.js +29 -0
- package/dist/utils/speakerTurns.d.ts +28 -0
- package/dist/utils/speakerTurns.js +93 -0
- package/dist/utils/sttProcessor.d.ts +20 -2
- package/dist/utils/sttProcessor.js +73 -5
- package/dist/voice/correction/dictionary.d.ts +49 -0
- package/dist/voice/correction/dictionary.js +134 -0
- package/dist/voice/correction/guard.d.ts +35 -0
- package/dist/voice/correction/guard.js +161 -0
- package/dist/voice/correction/index.d.ts +47 -0
- package/dist/voice/correction/index.js +211 -0
- package/dist/voice/correction/rewrite.d.ts +92 -0
- package/dist/voice/correction/rewrite.js +322 -0
- package/dist/voice/errors.d.ts +15 -0
- package/dist/voice/errors.js +45 -0
- package/dist/voice/index.d.ts +20 -2
- package/dist/voice/index.js +75 -9
- package/dist/voice/providers/AzureSTT.d.ts +10 -7
- package/dist/voice/providers/AzureSTT.js +23 -75
- package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
- package/dist/voice/providers/DeepgramSTT.js +109 -13
- package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
- package/dist/voice/providers/ElevenLabsSTT.js +34 -8
- package/dist/voice/providers/GoogleSTT.d.ts +10 -8
- package/dist/voice/providers/GoogleSTT.js +21 -75
- package/dist/voice/providers/OpenAISTT.d.ts +30 -9
- package/dist/voice/providers/OpenAISTT.js +196 -21
- package/dist/voice/providers/WhistleSTT.d.ts +87 -0
- package/dist/voice/providers/WhistleSTT.js +313 -0
- package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
- package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
- package/dist/voice/streaming/endpointer.d.ts +81 -0
- package/dist/voice/streaming/endpointer.js +247 -0
- package/dist/voice/streaming/localAgreement.d.ts +30 -0
- package/dist/voice/streaming/localAgreement.js +44 -0
- package/dist/voice/sttEnvCredentials.d.ts +12 -0
- package/dist/voice/sttEnvCredentials.js +12 -0
- package/dist/voice/transcribe.d.ts +47 -0
- package/dist/voice/transcribe.js +519 -0
- package/dist/voice/transcribeStream.d.ts +4 -0
- package/dist/voice/transcribeStream.js +584 -0
- package/dist/voice/whistle/assets.d.ts +47 -0
- package/dist/voice/whistle/assets.js +270 -0
- package/dist/voice/whistle/audio.d.ts +32 -0
- package/dist/voice/whistle/audio.js +267 -0
- package/dist/voice/whistle/engine.d.ts +32 -0
- package/dist/voice/whistle/engine.js +193 -0
- package/dist/voice/whistle/worker.d.ts +18 -0
- package/dist/voice/whistle/worker.js +294 -0
- package/docs-site/static/search-index.json +69 -10
- package/models/whistle/README.md +18 -0
- package/models/whistle/needle.js +2 -0
- package/models/whistle/needle.wasm +0 -0
- package/models/whistle/whistle.cact +0 -0
- package/package.json +7 -3
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side streaming for batch STT engines.
|
|
3
|
+
*
|
|
4
|
+
* PCM16LE frames → energy-gated utterances (see `endpointer.ts`) → the
|
|
5
|
+
* current utterance is re-transcribed every `intervalMs` → LocalAgreement
|
|
6
|
+
* commits the prefix two passes agree on → a final pass when the utterance
|
|
7
|
+
* ends. The adapter knows nothing about providers or correction: it calls
|
|
8
|
+
* `run` with 16 kHz audio and a role ("primary" / "fallback"), and the
|
|
9
|
+
* orchestration maps roles to engines.
|
|
10
|
+
*
|
|
11
|
+
* Nothing is ever dropped:
|
|
12
|
+
* - a final pass that returns nothing, or fewer than half the words already
|
|
13
|
+
* shown, keeps the shown text (the final covers the same audio as the
|
|
14
|
+
* interims plus the tail, so it can only be richer — anything else is an
|
|
15
|
+
* engine glitch);
|
|
16
|
+
* - an utterance that no engine read yields a `silence` event with its audio
|
|
17
|
+
* length instead of disappearing;
|
|
18
|
+
* - an utterance that already produced words is always finalized, however
|
|
19
|
+
* little speech the gate counted in it.
|
|
20
|
+
*
|
|
21
|
+
* Passes are serialized: one engine call in flight at a time. Interim passes
|
|
22
|
+
* are skipped while the engine is busy, final passes queue in utterance
|
|
23
|
+
* order, so finals are emitted in order.
|
|
24
|
+
*
|
|
25
|
+
* @module voice/streaming/chunkedStreamAdapter
|
|
26
|
+
*/
|
|
27
|
+
import { logger } from "../../utils/logger.js";
|
|
28
|
+
import { STTError } from "../errors.js";
|
|
29
|
+
import { Endpointer, ENGINE_SAMPLE_RATE, resolveEndpointerConfig, } from "./endpointer.js";
|
|
30
|
+
import { createLocalAgreement, resetLocalAgreement, splitWords, updateLocalAgreement, } from "./localAgreement.js";
|
|
31
|
+
/** Default cadence of interim passes. */
|
|
32
|
+
const DEFAULT_INTERVAL_MS = 900;
|
|
33
|
+
/** An interim pass needs at least this much audio the previous pass did not see. */
|
|
34
|
+
const MIN_NEW_AUDIO_INTERIM_S = 0.8;
|
|
35
|
+
/** Below this much new audio since the last pass, the final reuses the interim text. */
|
|
36
|
+
const MIN_NEW_AUDIO_FINAL_S = 0.4;
|
|
37
|
+
/**
|
|
38
|
+
* Language takeover thresholds. A language score is unreliable under ~2.5 s
|
|
39
|
+
* of audio even for clean speech (measured −0.32 at 1 s, −0.05 at 2 s on the
|
|
40
|
+
* same Tamil clip), and code-mixed speech hovers around the threshold as
|
|
41
|
+
* English words come and go, so one unsure pass means nothing: it takes two
|
|
42
|
+
* in a row, after 3 s of audio and 1.5 s of speech, and a single confident
|
|
43
|
+
* pass on the utterance rules the takeover out.
|
|
44
|
+
*/
|
|
45
|
+
const TAKEOVER_MIN_AUDIO_S = 3;
|
|
46
|
+
const TAKEOVER_MIN_SPEECH_MS = 1500;
|
|
47
|
+
const TAKEOVER_UNSURE_PASSES = 2;
|
|
48
|
+
const DEFAULT_FALLBACK_WHEN = [
|
|
49
|
+
"unsure",
|
|
50
|
+
"empty",
|
|
51
|
+
];
|
|
52
|
+
/** Minimal unbounded async queue: the producer never waits for the consumer. */
|
|
53
|
+
export class StreamEventQueue {
|
|
54
|
+
items = [];
|
|
55
|
+
waiter = null;
|
|
56
|
+
closed = false;
|
|
57
|
+
push(item) {
|
|
58
|
+
if (this.closed) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.items.push(item);
|
|
62
|
+
this.wake();
|
|
63
|
+
}
|
|
64
|
+
close() {
|
|
65
|
+
this.closed = true;
|
|
66
|
+
this.wake();
|
|
67
|
+
}
|
|
68
|
+
async *drain() {
|
|
69
|
+
while (true) {
|
|
70
|
+
const next = this.items.shift();
|
|
71
|
+
if (next !== undefined) {
|
|
72
|
+
yield next;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (this.closed) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
await new Promise((resolve) => {
|
|
79
|
+
this.waiter = resolve;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
wake() {
|
|
84
|
+
const w = this.waiter;
|
|
85
|
+
this.waiter = null;
|
|
86
|
+
w?.();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Per-utterance pass state. */
|
|
90
|
+
class UtteranceRun {
|
|
91
|
+
u;
|
|
92
|
+
la = createLocalAgreement();
|
|
93
|
+
engine = "primary";
|
|
94
|
+
/** Input samples the last pass covered. */
|
|
95
|
+
sentLen = 0;
|
|
96
|
+
/** Audio clock (s) when the last pass started; -Infinity before the first. */
|
|
97
|
+
lastReqAt = Number.NEGATIVE_INFINITY;
|
|
98
|
+
passes = 0;
|
|
99
|
+
final = false;
|
|
100
|
+
/** Full text of the latest interim pass (committed + tail). */
|
|
101
|
+
shown = "";
|
|
102
|
+
switched = false;
|
|
103
|
+
confidentOnce = false;
|
|
104
|
+
unsureN = 0;
|
|
105
|
+
languageDetected;
|
|
106
|
+
language;
|
|
107
|
+
lastLanguageKey = "";
|
|
108
|
+
constructor(u) {
|
|
109
|
+
this.u = u;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Lifts what an engine said about the language from an `STTResult`. */
|
|
113
|
+
function liftLanguage(res) {
|
|
114
|
+
if (!res) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const meta = res.metadata ?? {};
|
|
118
|
+
const flag = typeof meta.languageDetected === "boolean"
|
|
119
|
+
? meta.languageDetected
|
|
120
|
+
: undefined;
|
|
121
|
+
const rawScores = Array.isArray(meta.languageScores)
|
|
122
|
+
? meta.languageScores
|
|
123
|
+
: [];
|
|
124
|
+
const scores = [];
|
|
125
|
+
for (const s of rawScores) {
|
|
126
|
+
if (s &&
|
|
127
|
+
typeof s === "object" &&
|
|
128
|
+
typeof s.language === "string" &&
|
|
129
|
+
typeof s.score === "number") {
|
|
130
|
+
scores.push(s);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const language = (typeof res.language === "string" && res.language) ||
|
|
134
|
+
scores[0]?.language ||
|
|
135
|
+
undefined;
|
|
136
|
+
if (!language && flag === undefined) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
language: language ?? "und",
|
|
141
|
+
detected: flag ?? true,
|
|
142
|
+
...(scores.length ? { scores } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Shifts utterance-relative word times onto the stream clock. */
|
|
146
|
+
function shiftWords(words, offset) {
|
|
147
|
+
if (!words?.length) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
const add = (v) => typeof v === "number" ? v + offset : undefined;
|
|
151
|
+
return words.map((w) => ({
|
|
152
|
+
...w,
|
|
153
|
+
startTime: add(w.startTime ?? w.start),
|
|
154
|
+
endTime: add(w.endTime ?? w.end),
|
|
155
|
+
start: add(w.start ?? w.startTime),
|
|
156
|
+
end: add(w.end ?? w.endTime),
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
function errorMessage(err) {
|
|
160
|
+
return err instanceof Error ? err.message : String(err);
|
|
161
|
+
}
|
|
162
|
+
class ChunkedStreamSession {
|
|
163
|
+
run;
|
|
164
|
+
options;
|
|
165
|
+
endpointer;
|
|
166
|
+
inputRate;
|
|
167
|
+
intervalS;
|
|
168
|
+
queue = new StreamEventQueue();
|
|
169
|
+
runs = new Map();
|
|
170
|
+
fallbackWhen;
|
|
171
|
+
engineTail = Promise.resolve();
|
|
172
|
+
busy = 0;
|
|
173
|
+
aborted = false;
|
|
174
|
+
constructor(run, options) {
|
|
175
|
+
this.run = run;
|
|
176
|
+
this.options = options;
|
|
177
|
+
this.inputRate =
|
|
178
|
+
options.sampleRate && options.sampleRate > 0
|
|
179
|
+
? options.sampleRate
|
|
180
|
+
: ENGINE_SAMPLE_RATE;
|
|
181
|
+
this.endpointer = new Endpointer(resolveEndpointerConfig(options, this.inputRate));
|
|
182
|
+
this.intervalS =
|
|
183
|
+
(options.intervalMs && options.intervalMs > 0
|
|
184
|
+
? options.intervalMs
|
|
185
|
+
: DEFAULT_INTERVAL_MS) / 1000;
|
|
186
|
+
this.fallbackWhen = options.fallback?.when?.length
|
|
187
|
+
? options.fallback.when
|
|
188
|
+
: DEFAULT_FALLBACK_WHEN;
|
|
189
|
+
}
|
|
190
|
+
async *events(frames) {
|
|
191
|
+
const iterator = frames[Symbol.asyncIterator]();
|
|
192
|
+
void this.pump(iterator);
|
|
193
|
+
try {
|
|
194
|
+
yield* this.queue.drain();
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
// The consumer stopped early: stop pulling frames and let pending passes finish unseen.
|
|
198
|
+
if (!this.aborted) {
|
|
199
|
+
this.aborted = true;
|
|
200
|
+
void Promise.resolve(iterator.return?.()).catch(() => undefined);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
label(role) {
|
|
205
|
+
return role === "fallback"
|
|
206
|
+
? (this.options.fallback?.label ?? "fallback")
|
|
207
|
+
: (this.options.engineLabel ?? "primary");
|
|
208
|
+
}
|
|
209
|
+
/** Reads frames until the source ends, then finalizes and closes the queue. */
|
|
210
|
+
async pump(iterator) {
|
|
211
|
+
let carry = null;
|
|
212
|
+
let sourceError;
|
|
213
|
+
try {
|
|
214
|
+
while (!this.aborted) {
|
|
215
|
+
const next = await iterator.next();
|
|
216
|
+
if (next.done || this.aborted) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const bytes = next.value;
|
|
220
|
+
if (!bytes || bytes.length === 0) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// PCM16LE; a frame may split a sample, so an odd byte is carried over.
|
|
224
|
+
const total = bytes.length + (carry === null ? 0 : 1);
|
|
225
|
+
const n = Math.floor(total / 2);
|
|
226
|
+
const samples = new Float32Array(n);
|
|
227
|
+
let idx = 0;
|
|
228
|
+
let s = 0;
|
|
229
|
+
const byteAt = (k) => carry === null ? bytes[k] : k === 0 ? carry : bytes[k - 1];
|
|
230
|
+
for (; s < n; s++, idx += 2) {
|
|
231
|
+
const lo = byteAt(idx);
|
|
232
|
+
const hi = byteAt(idx + 1);
|
|
233
|
+
const v = (hi << 8) | lo;
|
|
234
|
+
samples[s] = (v >= 0x8000 ? v - 0x10000 : v) / 0x8000;
|
|
235
|
+
}
|
|
236
|
+
carry = total % 2 === 1 ? byteAt(total - 1) : null;
|
|
237
|
+
this.handle(this.endpointer.push(samples));
|
|
238
|
+
this.maybeInterim();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
sourceError = err;
|
|
243
|
+
}
|
|
244
|
+
// Whatever was being said when the source ended (or failed) is still finalized.
|
|
245
|
+
this.handle(this.endpointer.flush());
|
|
246
|
+
try {
|
|
247
|
+
// Every final is chained on the engine queue; wait until the chain is empty.
|
|
248
|
+
let tail;
|
|
249
|
+
do {
|
|
250
|
+
tail = this.engineTail;
|
|
251
|
+
await tail;
|
|
252
|
+
} while (tail !== this.engineTail);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Individual passes report their own errors; nothing to add here.
|
|
256
|
+
}
|
|
257
|
+
if (sourceError !== undefined && !this.aborted) {
|
|
258
|
+
this.queue.push({
|
|
259
|
+
type: "error",
|
|
260
|
+
message: STTError.streamError(errorMessage(sourceError)).message,
|
|
261
|
+
recoverable: false,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
this.queue.close();
|
|
265
|
+
}
|
|
266
|
+
handle(events) {
|
|
267
|
+
for (const ev of events) {
|
|
268
|
+
if (ev.type === "start") {
|
|
269
|
+
this.runs.set(ev.utterance.id, new UtteranceRun(ev.utterance));
|
|
270
|
+
}
|
|
271
|
+
else if (ev.type === "drop") {
|
|
272
|
+
this.runs.delete(ev.utterance.id);
|
|
273
|
+
if (logger.shouldLog("debug")) {
|
|
274
|
+
logger.debug(`[transcribeStream] utterance ${ev.utterance.id} dropped (${ev.utterance.speechMs} ms of speech, nothing heard)`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const r = this.runs.get(ev.utterance.id);
|
|
279
|
+
if (r) {
|
|
280
|
+
this.finalize(r, ev.reason);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Runs `fn` after every queued engine call; at most one is in flight. */
|
|
286
|
+
withEngine(fn) {
|
|
287
|
+
this.busy++;
|
|
288
|
+
const next = this.engineTail.then(fn).finally(() => {
|
|
289
|
+
this.busy--;
|
|
290
|
+
});
|
|
291
|
+
this.engineTail = next.catch(() => undefined);
|
|
292
|
+
return next;
|
|
293
|
+
}
|
|
294
|
+
maybeInterim() {
|
|
295
|
+
const u = this.endpointer.current;
|
|
296
|
+
if (!u || this.busy > 0 || this.aborted) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const r = this.runs.get(u.id);
|
|
300
|
+
if (!r || r.final) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// The cadence runs on the audio clock: a live source advances it in real
|
|
304
|
+
// time, and a file replayed faster than real time gets the same passes
|
|
305
|
+
// per second of audio, limited only by the one-in-flight rule.
|
|
306
|
+
if (this.endpointer.audioSeconds - r.lastReqAt < this.intervalS) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (u.length - r.sentLen < MIN_NEW_AUDIO_INTERIM_S * this.inputRate) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
void this.withEngine(async () => {
|
|
313
|
+
if (!r.final) {
|
|
314
|
+
await this.runPass(r, false);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
emitLanguage(r, lang) {
|
|
319
|
+
if (!lang) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
r.language = lang.language;
|
|
323
|
+
r.languageDetected = lang.detected;
|
|
324
|
+
if (lang.detected) {
|
|
325
|
+
r.confidentOnce = true;
|
|
326
|
+
}
|
|
327
|
+
const key = `${lang.language}|${lang.detected}`;
|
|
328
|
+
if (key === r.lastLanguageKey) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
r.lastLanguageKey = key;
|
|
332
|
+
this.queue.push({
|
|
333
|
+
type: "language",
|
|
334
|
+
utterance: r.u.id,
|
|
335
|
+
language: lang.language,
|
|
336
|
+
detected: lang.detected,
|
|
337
|
+
...(lang.scores ? { scores: lang.scores } : {}),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async callEngine(r, pcm, final) {
|
|
341
|
+
try {
|
|
342
|
+
const res = await this.run(pcm, {
|
|
343
|
+
utterance: r.u.id,
|
|
344
|
+
final,
|
|
345
|
+
engine: r.engine,
|
|
346
|
+
seconds: r.u.length / this.inputRate,
|
|
347
|
+
});
|
|
348
|
+
return { res };
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
return { err };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* One pass over the whole utterance so far. Returns the text to show, the
|
|
356
|
+
* result it came from (when the engine answered), and whether the pass failed.
|
|
357
|
+
*/
|
|
358
|
+
async runPass(r, isFinal, pcmIn) {
|
|
359
|
+
r.sentLen = r.u.length;
|
|
360
|
+
r.lastReqAt = this.endpointer.audioSeconds;
|
|
361
|
+
r.passes++;
|
|
362
|
+
const t0 = Date.now();
|
|
363
|
+
const pcm = pcmIn ?? this.endpointer.pcm16k(r.u);
|
|
364
|
+
const seconds = r.u.length / this.inputRate;
|
|
365
|
+
let { res, err } = await this.callEngine(r, pcm, isFinal);
|
|
366
|
+
let text = res?.text?.trim() ?? "";
|
|
367
|
+
let lang = liftLanguage(res);
|
|
368
|
+
this.emitLanguage(r, lang);
|
|
369
|
+
if (this.options.fallback && r.engine === "primary" && !r.switched) {
|
|
370
|
+
const when = this.fallbackWhen;
|
|
371
|
+
const unsure = (when.includes("unsure") && lang?.detected === false) ||
|
|
372
|
+
(when.includes("empty") && !err && !text) ||
|
|
373
|
+
(when.includes("error") && err !== undefined);
|
|
374
|
+
r.unsureN = unsure ? r.unsureN + 1 : 0;
|
|
375
|
+
if (!r.confidentOnce &&
|
|
376
|
+
seconds >= TAKEOVER_MIN_AUDIO_S &&
|
|
377
|
+
r.u.speechMs >= TAKEOVER_MIN_SPEECH_MS &&
|
|
378
|
+
r.unsureN >= TAKEOVER_UNSURE_PASSES) {
|
|
379
|
+
// Hand the rest of this utterance to the fallback right now, so the
|
|
380
|
+
// caption never goes blank; the primary's words are noise from here.
|
|
381
|
+
r.engine = "fallback";
|
|
382
|
+
r.switched = true;
|
|
383
|
+
resetLocalAgreement(r.la);
|
|
384
|
+
if (logger.shouldLog("debug")) {
|
|
385
|
+
logger.debug(`[transcribeStream] utterance ${r.u.id}: primary unsure on ${r.unsureN} passes, fallback takes over`);
|
|
386
|
+
}
|
|
387
|
+
const second = await this.callEngine(r, pcm, isFinal);
|
|
388
|
+
if (second.res) {
|
|
389
|
+
res = second.res;
|
|
390
|
+
err = undefined;
|
|
391
|
+
text = second.res.text?.trim() ?? "";
|
|
392
|
+
lang = liftLanguage(second.res);
|
|
393
|
+
this.emitLanguage(r, lang);
|
|
394
|
+
}
|
|
395
|
+
else if (logger.shouldLog("debug")) {
|
|
396
|
+
logger.debug(`[transcribeStream] utterance ${r.u.id}: fallback pass failed: ${errorMessage(second.err)}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (err !== undefined) {
|
|
401
|
+
this.queue.push({
|
|
402
|
+
type: "error",
|
|
403
|
+
utterance: r.u.id,
|
|
404
|
+
message: errorMessage(err),
|
|
405
|
+
recoverable: true,
|
|
406
|
+
});
|
|
407
|
+
// The live text stands in for a failed pass.
|
|
408
|
+
return { text: r.shown };
|
|
409
|
+
}
|
|
410
|
+
if (text) {
|
|
411
|
+
r.u.heard = true;
|
|
412
|
+
}
|
|
413
|
+
if (!isFinal) {
|
|
414
|
+
const split = updateLocalAgreement(r.la, text);
|
|
415
|
+
r.shown = split.words.join(" ");
|
|
416
|
+
if (r.shown) {
|
|
417
|
+
this.queue.push({
|
|
418
|
+
type: "interim",
|
|
419
|
+
utterance: r.u.id,
|
|
420
|
+
text: r.shown,
|
|
421
|
+
committed: split.committed,
|
|
422
|
+
tail: split.tail,
|
|
423
|
+
seconds,
|
|
424
|
+
latencyMs: Date.now() - t0,
|
|
425
|
+
engine: this.label(r.engine),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (logger.shouldLog("debug")) {
|
|
430
|
+
logger.debug(`[transcribeStream] utterance ${r.u.id} ${isFinal ? "final" : "interim"} pass: ${seconds.toFixed(1)} s, ${text.length} chars, ${Date.now() - t0} ms`);
|
|
431
|
+
}
|
|
432
|
+
return { text, res };
|
|
433
|
+
}
|
|
434
|
+
finalize(r, reason) {
|
|
435
|
+
r.final = true;
|
|
436
|
+
const pcm = this.endpointer.pcm16k(r.u);
|
|
437
|
+
try {
|
|
438
|
+
this.options.onUtteranceEnd?.(pcm, r.u.id, r.engine);
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
if (logger.shouldLog("debug")) {
|
|
442
|
+
logger.debug(`[transcribeStream] onUtteranceEnd threw: ${errorMessage(err)}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
void this.withEngine(async () => {
|
|
446
|
+
if (this.aborted) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const seconds = r.u.length / this.inputRate;
|
|
450
|
+
const needFinal = r.u.length - r.sentLen >= MIN_NEW_AUDIO_FINAL_S * this.inputRate ||
|
|
451
|
+
r.passes === 0;
|
|
452
|
+
let text = r.shown;
|
|
453
|
+
let res;
|
|
454
|
+
let keptInterim = false;
|
|
455
|
+
if (needFinal) {
|
|
456
|
+
const out = await this.runPass(r, true, pcm);
|
|
457
|
+
res = out.res;
|
|
458
|
+
text = out.text;
|
|
459
|
+
// Never shrink: an empty final, or one with fewer than half the words
|
|
460
|
+
// already shown, is an engine glitch on audio that can only be richer.
|
|
461
|
+
const shown = r.shown;
|
|
462
|
+
const nShown = splitWords(shown).length;
|
|
463
|
+
const nFinal = splitWords(text).length;
|
|
464
|
+
if (!text || (nShown >= 2 && nFinal < nShown * 0.5)) {
|
|
465
|
+
keptInterim = Boolean(shown) && text !== shown;
|
|
466
|
+
text = shown;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
this.runs.delete(r.u.id);
|
|
470
|
+
if (!text) {
|
|
471
|
+
this.queue.push({ type: "silence", utterance: r.u.id, seconds });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const start = r.u.startSeconds;
|
|
475
|
+
const end = start + seconds;
|
|
476
|
+
const words = res && !keptInterim ? shiftWords(res.words, start) : undefined;
|
|
477
|
+
this.queue.push({
|
|
478
|
+
type: "final",
|
|
479
|
+
utterance: r.u.id,
|
|
480
|
+
text,
|
|
481
|
+
segment: {
|
|
482
|
+
index: r.u.id,
|
|
483
|
+
text,
|
|
484
|
+
isFinal: true,
|
|
485
|
+
startTime: start,
|
|
486
|
+
endTime: end,
|
|
487
|
+
start,
|
|
488
|
+
end,
|
|
489
|
+
...(res && !keptInterim && typeof res.confidence === "number"
|
|
490
|
+
? { confidence: res.confidence }
|
|
491
|
+
: {}),
|
|
492
|
+
...(words ? { words } : {}),
|
|
493
|
+
...(r.language ? { language: r.language } : {}),
|
|
494
|
+
},
|
|
495
|
+
...(words ? { words } : {}),
|
|
496
|
+
...(r.language ? { language: r.language } : {}),
|
|
497
|
+
...(r.languageDetected !== undefined
|
|
498
|
+
? { languageDetected: r.languageDetected }
|
|
499
|
+
: {}),
|
|
500
|
+
engine: this.label(r.engine),
|
|
501
|
+
...(r.switched ? { fallbackUsed: true } : {}),
|
|
502
|
+
seconds,
|
|
503
|
+
});
|
|
504
|
+
if (logger.shouldLog("debug")) {
|
|
505
|
+
logger.debug(`[transcribeStream] utterance ${r.u.id} final (${reason}${keptInterim ? ", kept live text" : ""}${needFinal ? "" : ", reused interim"})`);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Streams a batch STT engine: `frames` are PCM16LE mono at
|
|
512
|
+
* `options.sampleRate` (default 16000); `run` transcribes one utterance at
|
|
513
|
+
* 16 kHz. Yields `interim`, `language`, `final`, `silence` and `error`
|
|
514
|
+
* events; utterance numbers are in order but may skip (a dropped cough
|
|
515
|
+
* consumes one). Ends after the source ends and every final is emitted.
|
|
516
|
+
*/
|
|
517
|
+
export function chunkedTranscribeStream(frames, run, options = {}) {
|
|
518
|
+
return new ChunkedStreamSession(run, options).events(frames);
|
|
519
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Energy-gated endpointer for client-side streaming of batch STT engines.
|
|
3
|
+
*
|
|
4
|
+
* Pure and synchronous: feed it float samples at any input rate, get back
|
|
5
|
+
* utterance start / end / drop events. It knows nothing about engines; the
|
|
6
|
+
* chunked adapter decides what to transcribe and when.
|
|
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):
|
|
12
|
+
*
|
|
13
|
+
* - 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
|
+
* - `prerollMs` of audio before the onset is kept, so the first consonant is
|
|
22
|
+
* not clipped.
|
|
23
|
+
* - An utterance ends after `endSilenceMs` without sustained speech. One with
|
|
24
|
+
* less than `minSpeechMs` of speech is a cough and is dropped — unless a
|
|
25
|
+
* pass already heard words in it, which must never be lost.
|
|
26
|
+
* - 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
|
|
29
|
+
* utterance, so no audio falls between the two.
|
|
30
|
+
*
|
|
31
|
+
* @module voice/streaming/endpointer
|
|
32
|
+
*/
|
|
33
|
+
import type { STTEndpointerConfig, STTEndpointerEvent, STTEndpointerUtterance, STTStreamingOptions } from "../../types/index.js";
|
|
34
|
+
/** Sample rate every engine pass receives. */
|
|
35
|
+
export declare const ENGINE_SAMPLE_RATE = 16000;
|
|
36
|
+
/** Defaults of `STTStreamingOptions` that the endpointer reads. */
|
|
37
|
+
export declare const ENDPOINTER_DEFAULTS: {
|
|
38
|
+
readonly endSilenceMs: 750;
|
|
39
|
+
readonly softCutSeconds: 16;
|
|
40
|
+
readonly maxUtteranceSeconds: 28;
|
|
41
|
+
readonly onsetRms: 0.006;
|
|
42
|
+
readonly prerollMs: 350;
|
|
43
|
+
readonly minSpeechMs: 250;
|
|
44
|
+
};
|
|
45
|
+
/** Applies the defaults to caller tuning; non-finite or non-positive values fall back. */
|
|
46
|
+
export declare function resolveEndpointerConfig(options: STTStreamingOptions | undefined, inputRate: number): STTEndpointerConfig;
|
|
47
|
+
export declare function rmsOf(samples: Float32Array): number;
|
|
48
|
+
/** Linear-interpolation resampler to 16 kHz; returns the input untouched when it already is. */
|
|
49
|
+
export declare function resampleTo16k(samples: Float32Array, inputRate: number): Float32Array;
|
|
50
|
+
export declare class Endpointer {
|
|
51
|
+
readonly config: STTEndpointerConfig;
|
|
52
|
+
private readonly blockLen;
|
|
53
|
+
private block;
|
|
54
|
+
private blockFill;
|
|
55
|
+
private noise;
|
|
56
|
+
private readonly noiseWin;
|
|
57
|
+
private pre;
|
|
58
|
+
private preLen;
|
|
59
|
+
private utt;
|
|
60
|
+
private seq;
|
|
61
|
+
/** Input samples that went through the gate so far: the audio clock, in whole blocks. */
|
|
62
|
+
private consumed;
|
|
63
|
+
constructor(config: STTEndpointerConfig);
|
|
64
|
+
/** The utterance being built, if speech is in progress. */
|
|
65
|
+
get current(): STTEndpointerUtterance | null;
|
|
66
|
+
/** Seconds of input consumed so far. */
|
|
67
|
+
get audioSeconds(): number;
|
|
68
|
+
/** Feeds samples at the input rate; returns the events they caused, in order. */
|
|
69
|
+
push(samples: Float32Array): STTEndpointerEvent[];
|
|
70
|
+
/**
|
|
71
|
+
* Ends the stream: a partial block is folded into the open utterance, which
|
|
72
|
+
* is then ended (`flush`) — or dropped when it is a cough nobody heard.
|
|
73
|
+
*/
|
|
74
|
+
flush(): STTEndpointerEvent[];
|
|
75
|
+
/** The utterance's audio at 16 kHz. */
|
|
76
|
+
pcm16k(u: STTEndpointerUtterance): Float32Array;
|
|
77
|
+
private sustainThreshold;
|
|
78
|
+
private newUtterance;
|
|
79
|
+
private onBlock;
|
|
80
|
+
private hardCut;
|
|
81
|
+
}
|