@juspay/neurolink 12.48.2 → 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 +0 -3
- package/dist/cli/commands/serve.js +85 -3
- 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/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 +7 -0
- package/dist/server/abstract/baseServerAdapter.js +13 -1
- package/dist/server/adapters/expressAdapter.d.ts +1 -0
- package/dist/server/adapters/expressAdapter.js +48 -4
- package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
- package/dist/server/adapters/fastifyAdapter.js +38 -1
- package/dist/server/adapters/honoAdapter.d.ts +1 -4
- package/dist/server/adapters/honoAdapter.js +83 -1
- package/dist/server/adapters/koaAdapter.d.ts +1 -0
- package/dist/server/adapters/koaAdapter.js +43 -0
- 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 +46 -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 +66 -8
- 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,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming transcription orchestration: `neurolink.transcribeStream()`.
|
|
3
|
+
*
|
|
4
|
+
* Two ways to stream, chosen per request:
|
|
5
|
+
*
|
|
6
|
+
* - **native** — the handler streams itself (`transcribeStream` +
|
|
7
|
+
* `supportsStreaming`); its segments are mapped to `interim` / `final`
|
|
8
|
+
* events, with LocalAgreement applied to consecutive interim segments.
|
|
9
|
+
* - **chunked** — any batch handler, streamed on the client by the chunked
|
|
10
|
+
* adapter (energy gate, rolling re-transcription, LocalAgreement, language
|
|
11
|
+
* takeover to the fallback engine).
|
|
12
|
+
*
|
|
13
|
+
* `streaming.mode` `"auto"` (the default) and `"native"` use the native
|
|
14
|
+
* stream when the handler has one, else the adapter; `"chunked"` always uses
|
|
15
|
+
* the adapter.
|
|
16
|
+
*
|
|
17
|
+
* Every final is followed, when correction is configured, by `correcting`
|
|
18
|
+
* events (the streamed rewrite) and one `corrected` event. Correction runs
|
|
19
|
+
* beside the stream: utterance N being corrected never holds back the
|
|
20
|
+
* interim passes of utterance N+1. The second-opinion engine runs on the
|
|
21
|
+
* utterance audio in parallel with the final pass, not after it.
|
|
22
|
+
*
|
|
23
|
+
* @module voice/transcribeStream
|
|
24
|
+
*/
|
|
25
|
+
import { logger } from "../utils/logger.js";
|
|
26
|
+
import { SpanSerializer, SpanStatus, SpanType, getMetricsAggregator, } from "../observability/index.js";
|
|
27
|
+
import { createWavFile } from "./audio-utils.js";
|
|
28
|
+
import { correctTranscript, isCorrectionEnabled } from "./correction/index.js";
|
|
29
|
+
import { StreamEventQueue, chunkedTranscribeStream, } from "./streaming/chunkedStreamAdapter.js";
|
|
30
|
+
import { ENDPOINTER_DEFAULTS, ENGINE_SAMPLE_RATE, } from "./streaming/endpointer.js";
|
|
31
|
+
import { createLocalAgreement, resetLocalAgreement, splitWords, updateLocalAgreement, } from "./streaming/localAgreement.js";
|
|
32
|
+
/** Keys of `TranscribeOptions` that are not `STTOptions` and must not reach a handler. */
|
|
33
|
+
const NON_STT_KEYS = new Set([
|
|
34
|
+
"audio",
|
|
35
|
+
"provider",
|
|
36
|
+
"model",
|
|
37
|
+
"language",
|
|
38
|
+
"format",
|
|
39
|
+
"prompt",
|
|
40
|
+
"dictionary",
|
|
41
|
+
"correction",
|
|
42
|
+
"fallback",
|
|
43
|
+
"streaming",
|
|
44
|
+
"credentials",
|
|
45
|
+
]);
|
|
46
|
+
// `timeoutMs` is deliberately NOT in the set: it is an engine option as well
|
|
47
|
+
// (handlers that honour one apply it per request), so it rides through.
|
|
48
|
+
/** Float samples in [-1, 1] → 16 kHz mono PCM16 WAV. */
|
|
49
|
+
export function encodeWav16k(pcm) {
|
|
50
|
+
const data = Buffer.alloc(pcm.length * 2);
|
|
51
|
+
for (let i = 0; i < pcm.length; i++) {
|
|
52
|
+
const s = Math.max(-1, Math.min(1, pcm[i]));
|
|
53
|
+
data.writeInt16LE(Math.round(s < 0 ? s * 0x8000 : s * 0x7fff), i * 2);
|
|
54
|
+
}
|
|
55
|
+
return createWavFile(data, ENGINE_SAMPLE_RATE, 1, 16);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Engine options for one provider: the caller's pass-through `STTOptions`,
|
|
59
|
+
* dictionary terms as vocabulary and appended to the prompt, `"auto"`
|
|
60
|
+
* language left to the engine.
|
|
61
|
+
*/
|
|
62
|
+
function buildEngineOptions(options, model, audio) {
|
|
63
|
+
const passthrough = {};
|
|
64
|
+
for (const [key, value] of Object.entries(options)) {
|
|
65
|
+
if (!NON_STT_KEYS.has(key) && value !== undefined) {
|
|
66
|
+
passthrough[key] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const terms = (options.dictionary ?? [])
|
|
70
|
+
.map((d) => d.term.trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
const vocabulary = [...(options.vocabulary ?? []), ...terms];
|
|
73
|
+
const promptParts = [options.prompt, options.correction?.context].filter((p) => typeof p === "string" && p.trim().length > 0);
|
|
74
|
+
if (terms.length) {
|
|
75
|
+
promptParts.push(terms.join(", "));
|
|
76
|
+
}
|
|
77
|
+
const language = options.language && options.language.toLowerCase() !== "auto"
|
|
78
|
+
? options.language
|
|
79
|
+
: undefined;
|
|
80
|
+
return {
|
|
81
|
+
...passthrough,
|
|
82
|
+
...(model ? { model } : {}),
|
|
83
|
+
...(language ? { language } : {}),
|
|
84
|
+
...(vocabulary.length ? { vocabulary } : {}),
|
|
85
|
+
...(promptParts.length ? { prompt: promptParts.join(" ") } : {}),
|
|
86
|
+
format: audio.format,
|
|
87
|
+
sampleRate: audio.sampleRate,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Uint8Array frames → Buffers (no copy), reporting each frame as it is handed over. */
|
|
91
|
+
async function* asBuffers(frames, onFrame) {
|
|
92
|
+
for await (const f of frames) {
|
|
93
|
+
onFrame(f.byteLength);
|
|
94
|
+
yield Buffer.isBuffer(f)
|
|
95
|
+
? f
|
|
96
|
+
: Buffer.from(f.buffer, f.byteOffset, f.byteLength);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The second engine on one utterance; never rejects (`text: null` when it heard nothing or failed). */
|
|
100
|
+
async function runSecondOpinion(deps, pcm, provider, engineOptions, utterance) {
|
|
101
|
+
const t0 = Date.now();
|
|
102
|
+
try {
|
|
103
|
+
const r = await deps.transcribe(encodeWav16k(pcm), provider, engineOptions);
|
|
104
|
+
return { text: r.text?.trim() || null, ms: Date.now() - t0 };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (logger.shouldLog("debug")) {
|
|
108
|
+
logger.debug(`[transcribeStream] second opinion failed for utterance ${utterance}: ${err instanceof Error ? err.message : String(err)}`);
|
|
109
|
+
}
|
|
110
|
+
return { text: null, ms: Date.now() - t0 };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Text ending a sentence: Latin, Devanagari danda, CJK and ellipsis, before closing quotes or brackets. */
|
|
114
|
+
const SENTENCE_END = /[.?!\u0964\u3002\uFF1F\uFF01\u2026]["'\u201D\u2019)\]]*$/;
|
|
115
|
+
function segStart(s) {
|
|
116
|
+
return s.startTime ?? s.start;
|
|
117
|
+
}
|
|
118
|
+
function segEnd(s) {
|
|
119
|
+
return s.endTime ?? s.end;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* One utterance of a native stream, assembled from the engine's segments.
|
|
123
|
+
*
|
|
124
|
+
* Native engines (Whistle, Deepgram live) report a final segment for each run
|
|
125
|
+
* of words they commit and an interim for the still-unconfirmed tail, which
|
|
126
|
+
* does not repeat what was already committed. A commit therefore is not the
|
|
127
|
+
* end of an utterance: Whistle shows "Hello there." as its tail and then
|
|
128
|
+
* commits just "Hello", with "there" still pending. The utterance stays open
|
|
129
|
+
* until a commit covers everything the engine had shown as pending (or the
|
|
130
|
+
* engine reports an empty tail, or the stream ends), so a final is never cut
|
|
131
|
+
* below what the caller already saw as one utterance, and the boundaries are
|
|
132
|
+
* the engine's own.
|
|
133
|
+
*/
|
|
134
|
+
class NativeUtterance {
|
|
135
|
+
id;
|
|
136
|
+
floor;
|
|
137
|
+
/** Text of the commits so far, in order. */
|
|
138
|
+
commits = [];
|
|
139
|
+
words = [];
|
|
140
|
+
/** The engine's current unconfirmed tail. */
|
|
141
|
+
tail = "";
|
|
142
|
+
/** LocalAgreement over successive tails; shifted when a commit moves the base. */
|
|
143
|
+
la = createLocalAgreement();
|
|
144
|
+
startTime;
|
|
145
|
+
endTime;
|
|
146
|
+
language;
|
|
147
|
+
confidence;
|
|
148
|
+
constructor(id,
|
|
149
|
+
/** Where the previous utterance ended, the fallback start when the engine gives no times. */
|
|
150
|
+
floor) {
|
|
151
|
+
this.id = id;
|
|
152
|
+
this.floor = floor;
|
|
153
|
+
}
|
|
154
|
+
get committedText() {
|
|
155
|
+
return this.commits.join(" ");
|
|
156
|
+
}
|
|
157
|
+
get empty() {
|
|
158
|
+
return this.commits.length === 0 && !this.tail;
|
|
159
|
+
}
|
|
160
|
+
/** Start on the stream clock: the first commit's time, else where the previous one ended. */
|
|
161
|
+
get start() {
|
|
162
|
+
return this.startTime ?? this.floor;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Maps a native handler's segments to stream events. */
|
|
166
|
+
async function* nativeEvents(handler, frames, engineOptions, engine, inputRate, maxUtteranceSeconds) {
|
|
167
|
+
let lastFrameAt = Date.now();
|
|
168
|
+
let bytesIn = 0;
|
|
169
|
+
let lastLanguage = "";
|
|
170
|
+
// Seconds of audio handed to the engine: interim segments carry no times,
|
|
171
|
+
// so this is what an interim's length is measured against.
|
|
172
|
+
const clock = () => bytesIn / 2 / inputRate;
|
|
173
|
+
let u = new NativeUtterance(1, 0);
|
|
174
|
+
/**
|
|
175
|
+
* The current utterance as an interim. `fromEngine` is true for a new tail
|
|
176
|
+
* from the engine, which is one LocalAgreement pass; a tail trimmed after a
|
|
177
|
+
* partial commit is the same pass seen again and must not count as agreement.
|
|
178
|
+
*/
|
|
179
|
+
const interim = (fromEngine) => {
|
|
180
|
+
let tailCommitted;
|
|
181
|
+
let tailRest;
|
|
182
|
+
if (fromEngine) {
|
|
183
|
+
const split = updateLocalAgreement(u.la, u.tail);
|
|
184
|
+
tailCommitted = split.committed;
|
|
185
|
+
tailRest = split.tail;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const words = splitWords(u.tail);
|
|
189
|
+
tailCommitted = u.la.committedWords.join(" ");
|
|
190
|
+
tailRest = words.slice(u.la.committedN).join(" ");
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
type: "interim",
|
|
194
|
+
utterance: u.id,
|
|
195
|
+
text: [u.committedText, u.tail].filter(Boolean).join(" "),
|
|
196
|
+
committed: [u.committedText, tailCommitted].filter(Boolean).join(" "),
|
|
197
|
+
tail: tailRest,
|
|
198
|
+
seconds: Math.max(0, Math.max(clock(), u.endTime ?? 0) - u.start),
|
|
199
|
+
// A native stream has no passes; this is how far the text trails the newest audio.
|
|
200
|
+
latencyMs: Date.now() - lastFrameAt,
|
|
201
|
+
engine,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
/** Ends the current utterance; words still pending are kept, never dropped. */
|
|
205
|
+
const close = () => {
|
|
206
|
+
const done = u;
|
|
207
|
+
const text = [done.committedText, done.tail].filter(Boolean).join(" ");
|
|
208
|
+
const start = done.start;
|
|
209
|
+
const end = Math.max(start, done.tail ? clock() : (done.endTime ?? clock()));
|
|
210
|
+
u = new NativeUtterance(done.id + 1, end);
|
|
211
|
+
if (!text) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const seconds = end - start;
|
|
215
|
+
return {
|
|
216
|
+
type: "final",
|
|
217
|
+
utterance: done.id,
|
|
218
|
+
text,
|
|
219
|
+
segment: {
|
|
220
|
+
index: done.id,
|
|
221
|
+
text,
|
|
222
|
+
isFinal: true,
|
|
223
|
+
startTime: start,
|
|
224
|
+
endTime: end,
|
|
225
|
+
start,
|
|
226
|
+
end,
|
|
227
|
+
...(done.confidence !== undefined
|
|
228
|
+
? { confidence: done.confidence }
|
|
229
|
+
: {}),
|
|
230
|
+
...(done.words.length ? { words: done.words } : {}),
|
|
231
|
+
...(done.language ? { language: done.language } : {}),
|
|
232
|
+
},
|
|
233
|
+
...(done.words.length ? { words: done.words } : {}),
|
|
234
|
+
...(done.language ? { language: done.language } : {}),
|
|
235
|
+
engine,
|
|
236
|
+
seconds,
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
const stream = handler.transcribeStream(asBuffers(frames, (bytes) => {
|
|
240
|
+
bytesIn += bytes;
|
|
241
|
+
lastFrameAt = Date.now();
|
|
242
|
+
}), engineOptions);
|
|
243
|
+
for await (const seg of stream) {
|
|
244
|
+
if (seg.language && seg.language !== lastLanguage) {
|
|
245
|
+
lastLanguage = seg.language;
|
|
246
|
+
yield {
|
|
247
|
+
type: "language",
|
|
248
|
+
utterance: u.id,
|
|
249
|
+
language: seg.language,
|
|
250
|
+
detected: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (seg.language) {
|
|
254
|
+
u.language = seg.language;
|
|
255
|
+
}
|
|
256
|
+
const text = seg.text.trim();
|
|
257
|
+
if (!seg.isFinal) {
|
|
258
|
+
if (text) {
|
|
259
|
+
u.tail = text;
|
|
260
|
+
yield interim(true);
|
|
261
|
+
}
|
|
262
|
+
else if (!u.empty) {
|
|
263
|
+
// The engine has nothing pending any more: what it committed is the utterance.
|
|
264
|
+
u.tail = "";
|
|
265
|
+
const ev = close();
|
|
266
|
+
if (ev) {
|
|
267
|
+
yield ev;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (!text) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const pending = splitWords(u.tail);
|
|
276
|
+
const committedWords = splitWords(text);
|
|
277
|
+
u.commits.push(text);
|
|
278
|
+
u.startTime ??= segStart(seg);
|
|
279
|
+
u.endTime = segEnd(seg) ?? u.endTime;
|
|
280
|
+
if (seg.words?.length) {
|
|
281
|
+
u.words.push(...seg.words);
|
|
282
|
+
}
|
|
283
|
+
if (typeof seg.confidence === "number") {
|
|
284
|
+
u.confidence = seg.confidence;
|
|
285
|
+
}
|
|
286
|
+
if (committedWords.length >= pending.length) {
|
|
287
|
+
// The commit covers everything shown as pending. That ends the
|
|
288
|
+
// utterance when the engine ended a sentence, or the turn has run past
|
|
289
|
+
// `maxUtteranceSeconds`; a commit like "Let us review the" (measured on
|
|
290
|
+
// Whistle mid-sentence, the rest arriving a second later) leaves it open.
|
|
291
|
+
// An empty tail from the engine, or the end of the stream, closes it too.
|
|
292
|
+
u.tail = "";
|
|
293
|
+
if (SENTENCE_END.test(text) ||
|
|
294
|
+
(u.endTime ?? clock()) - u.start >= maxUtteranceSeconds) {
|
|
295
|
+
const ev = close();
|
|
296
|
+
if (ev) {
|
|
297
|
+
yield ev;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
resetLocalAgreement(u.la);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
// A partial commit: the rest of the shown tail is still pending in this utterance.
|
|
306
|
+
u.tail = pending.slice(committedWords.length).join(" ");
|
|
307
|
+
// Shift LocalAgreement past the committed words rather than resetting
|
|
308
|
+
// it, so the committed text an interim reports never shrinks.
|
|
309
|
+
u.la.prevWords = u.la.prevWords.slice(committedWords.length);
|
|
310
|
+
u.la.committedWords = u.la.committedWords.slice(committedWords.length);
|
|
311
|
+
u.la.committedN = u.la.committedWords.length;
|
|
312
|
+
yield interim(false);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Stream over: anything committed or still shown is finalized.
|
|
316
|
+
const last = close();
|
|
317
|
+
if (last) {
|
|
318
|
+
yield last;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* `"auto"` and `"native"` take the handler's own stream when it has one;
|
|
323
|
+
* `"native"` on a handler without one falls back to the adapter.
|
|
324
|
+
*/
|
|
325
|
+
function chooseNative(mode, handler, provider) {
|
|
326
|
+
const useNative = mode !== "chunked" &&
|
|
327
|
+
handler?.supportsStreaming === true &&
|
|
328
|
+
typeof handler.transcribeStream === "function";
|
|
329
|
+
if (mode === "native" && !useNative && logger.shouldLog("debug")) {
|
|
330
|
+
logger.debug(`[transcribeStream] "${provider}" has no native stream; using the chunked adapter`);
|
|
331
|
+
}
|
|
332
|
+
return useNative;
|
|
333
|
+
}
|
|
334
|
+
export async function* runTranscribeStream(options, deps) {
|
|
335
|
+
const provider = deps.resolveProvider(options.provider);
|
|
336
|
+
const handler = deps.getHandler(provider);
|
|
337
|
+
const mode = options.streaming?.mode ?? "auto";
|
|
338
|
+
const inputRate = options.streaming?.sampleRate && options.streaming.sampleRate > 0
|
|
339
|
+
? options.streaming.sampleRate
|
|
340
|
+
: ENGINE_SAMPLE_RATE;
|
|
341
|
+
const useNative = chooseNative(mode, handler, provider);
|
|
342
|
+
const out = new StreamEventQueue();
|
|
343
|
+
const span = SpanSerializer.createSpan(SpanType.STT, "stt.transcribeStream", {
|
|
344
|
+
"stt.operation": "transcribeStream",
|
|
345
|
+
"stt.provider": provider,
|
|
346
|
+
"stt.language": options.language,
|
|
347
|
+
"stt.streaming_mode": useNative ? "native" : "chunked",
|
|
348
|
+
});
|
|
349
|
+
// Optional engines resolve up front; a misconfigured one is reported and
|
|
350
|
+
// left out rather than ending a stream the primary engine can serve.
|
|
351
|
+
const resolveOptional = (name, role) => {
|
|
352
|
+
try {
|
|
353
|
+
return deps.resolveProvider(name);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
out.push({
|
|
357
|
+
type: "error",
|
|
358
|
+
message: `${role} provider "${name}" unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
359
|
+
recoverable: true,
|
|
360
|
+
});
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
const fallbackProvider = !useNative && options.fallback
|
|
365
|
+
? resolveOptional(options.fallback.provider, "fallback")
|
|
366
|
+
: undefined;
|
|
367
|
+
const correcting = isCorrectionEnabled(options.dictionary, options.correction);
|
|
368
|
+
const secondSpec = correcting ? options.correction?.secondOpinion : undefined;
|
|
369
|
+
const secondProvider = !useNative && secondSpec
|
|
370
|
+
? resolveOptional(secondSpec.provider, "second-opinion")
|
|
371
|
+
: undefined;
|
|
372
|
+
const wavAudio = { format: "wav", sampleRate: ENGINE_SAMPLE_RATE };
|
|
373
|
+
const primaryOpts = buildEngineOptions(options, options.model, wavAudio);
|
|
374
|
+
const fallbackOpts = buildEngineOptions(options, options.fallback?.model, wavAudio);
|
|
375
|
+
const secondOpts = buildEngineOptions(options, secondSpec?.model, wavAudio);
|
|
376
|
+
// Per-utterance bookkeeping shared by the adapter hooks and the correction tasks.
|
|
377
|
+
const finalPassMs = new Map();
|
|
378
|
+
const endedAt = new Map();
|
|
379
|
+
const secondOpinions = new Map();
|
|
380
|
+
const run = async (pcm, pass) => {
|
|
381
|
+
const wav = encodeWav16k(pcm);
|
|
382
|
+
const t0 = Date.now();
|
|
383
|
+
const res = pass.engine === "fallback" && fallbackProvider
|
|
384
|
+
? await deps.transcribe(wav, fallbackProvider, fallbackOpts)
|
|
385
|
+
: await deps.transcribe(wav, provider, primaryOpts);
|
|
386
|
+
if (pass.final) {
|
|
387
|
+
finalPassMs.set(pass.utterance, Date.now() - t0);
|
|
388
|
+
}
|
|
389
|
+
return res;
|
|
390
|
+
};
|
|
391
|
+
const source = useNative
|
|
392
|
+
? nativeEvents(handler, options.audio, buildEngineOptions(options, options.model, {
|
|
393
|
+
format: "pcm16",
|
|
394
|
+
sampleRate: inputRate,
|
|
395
|
+
}), provider, inputRate, options.streaming?.maxUtteranceSeconds ??
|
|
396
|
+
ENDPOINTER_DEFAULTS.maxUtteranceSeconds)
|
|
397
|
+
: chunkedTranscribeStream(options.audio, run, {
|
|
398
|
+
...options.streaming,
|
|
399
|
+
sampleRate: inputRate,
|
|
400
|
+
engineLabel: provider,
|
|
401
|
+
...(fallbackProvider
|
|
402
|
+
? {
|
|
403
|
+
fallback: {
|
|
404
|
+
label: fallbackProvider,
|
|
405
|
+
...(options.fallback?.when
|
|
406
|
+
? { when: options.fallback.when }
|
|
407
|
+
: {}),
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
: {}),
|
|
411
|
+
onUtteranceEnd: (pcm, utterance, engine) => {
|
|
412
|
+
endedAt.set(utterance, Date.now());
|
|
413
|
+
// After a takeover the fallback's text is the transcript; a second
|
|
414
|
+
// opinion of it would only reconcile against the primary's noise.
|
|
415
|
+
if (!secondProvider || engine !== "primary") {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
secondOpinions.set(utterance, runSecondOpinion(deps, pcm, secondProvider, secondOpts, utterance));
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
const takeSecondOpinion = async (utterance) => {
|
|
422
|
+
const p = secondOpinions.get(utterance);
|
|
423
|
+
secondOpinions.delete(utterance);
|
|
424
|
+
return p ? await p : undefined;
|
|
425
|
+
};
|
|
426
|
+
/** Correction of one final; pushes `correcting` partials and one `corrected`. */
|
|
427
|
+
const correct = async (final, preSteps, alt) => {
|
|
428
|
+
const utterance = final.utterance;
|
|
429
|
+
const started = endedAt.get(utterance) ?? Date.now();
|
|
430
|
+
const transcribeMs = finalPassMs.get(utterance) ?? 0;
|
|
431
|
+
finalPassMs.delete(utterance);
|
|
432
|
+
endedAt.delete(utterance);
|
|
433
|
+
const steps = [...preSteps];
|
|
434
|
+
const secondOpinion = !final.fallbackUsed && alt?.text ? alt.text : undefined;
|
|
435
|
+
if (secondSpec && !final.fallbackUsed && !useNative && !alt?.text) {
|
|
436
|
+
steps.push("second opinion returned nothing");
|
|
437
|
+
}
|
|
438
|
+
// Nothing after the consumer has gone: no second opinion is reconciled
|
|
439
|
+
// and no rewrite is paid for that nobody will read.
|
|
440
|
+
if (aborted) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const res = await correctTranscript({
|
|
445
|
+
text: final.text,
|
|
446
|
+
...(secondOpinion ? { secondOpinion } : {}),
|
|
447
|
+
...(options.dictionary ? { dictionary: options.dictionary } : {}),
|
|
448
|
+
...(options.correction ? { options: options.correction } : {}),
|
|
449
|
+
...(final.languageDetected !== undefined
|
|
450
|
+
? { languageDetected: final.languageDetected }
|
|
451
|
+
: {}),
|
|
452
|
+
onPartial: (text) => {
|
|
453
|
+
if (text) {
|
|
454
|
+
out.push({ type: "correcting", utterance, text });
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
}, deps);
|
|
458
|
+
out.push({
|
|
459
|
+
type: "corrected",
|
|
460
|
+
utterance,
|
|
461
|
+
raw: final.text,
|
|
462
|
+
text: res.text || final.text,
|
|
463
|
+
decisions: res.decisions,
|
|
464
|
+
steps: [...steps, ...res.steps],
|
|
465
|
+
timings: {
|
|
466
|
+
transcribeMs,
|
|
467
|
+
...(alt ? { secondOpinionMs: alt.ms } : {}),
|
|
468
|
+
...res.timings,
|
|
469
|
+
totalMs: Date.now() - started,
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
// correctTranscript keeps its own timeouts and fails open; this is the
|
|
475
|
+
// last guard so a bug there costs the correction, never the transcript.
|
|
476
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
477
|
+
logger.warn(`[transcribeStream] correction failed for utterance ${utterance}: ${message}`);
|
|
478
|
+
out.push({
|
|
479
|
+
type: "corrected",
|
|
480
|
+
utterance,
|
|
481
|
+
raw: final.text,
|
|
482
|
+
text: final.text,
|
|
483
|
+
decisions: [],
|
|
484
|
+
steps: [...steps, `correction failed: ${message} · raw text kept`],
|
|
485
|
+
timings: { transcribeMs, totalMs: Date.now() - started },
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const pending = new Set();
|
|
490
|
+
const track = (p) => {
|
|
491
|
+
pending.add(p);
|
|
492
|
+
void p.finally(() => pending.delete(p));
|
|
493
|
+
};
|
|
494
|
+
let aborted = false;
|
|
495
|
+
let utterances = 0;
|
|
496
|
+
let failure;
|
|
497
|
+
// An explicit iterator so the consumer's exit can close the source (the
|
|
498
|
+
// native handler stream or the chunked adapter) instead of leaving it
|
|
499
|
+
// running on a live audio feed that never ends by itself.
|
|
500
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
501
|
+
const producer = (async () => {
|
|
502
|
+
try {
|
|
503
|
+
for (;;) {
|
|
504
|
+
const { value: ev, done } = await iterator.next();
|
|
505
|
+
if (done || aborted) {
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
if (ev.type === "final") {
|
|
509
|
+
utterances++;
|
|
510
|
+
out.push(ev);
|
|
511
|
+
const preSteps = ev.fallbackUsed
|
|
512
|
+
? [`${provider} unsure → ${ev.engine} took over`]
|
|
513
|
+
: [];
|
|
514
|
+
if (correcting && !aborted) {
|
|
515
|
+
track(takeSecondOpinion(ev.utterance).then((alt) => aborted ? undefined : correct(ev, preSteps, alt)));
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
secondOpinions.delete(ev.utterance);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
else if (ev.type === "silence" && secondOpinions.has(ev.utterance)) {
|
|
522
|
+
// The primary heard nothing; the second engine may have. Its text
|
|
523
|
+
// becomes the final rather than letting the utterance go silent.
|
|
524
|
+
track(takeSecondOpinion(ev.utterance).then(async (alt) => {
|
|
525
|
+
if (!alt?.text || !secondProvider) {
|
|
526
|
+
out.push(ev);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
utterances++;
|
|
530
|
+
const final = {
|
|
531
|
+
type: "final",
|
|
532
|
+
utterance: ev.utterance,
|
|
533
|
+
text: alt.text,
|
|
534
|
+
segment: {
|
|
535
|
+
index: ev.utterance,
|
|
536
|
+
text: alt.text,
|
|
537
|
+
isFinal: true,
|
|
538
|
+
},
|
|
539
|
+
engine: secondProvider,
|
|
540
|
+
seconds: ev.seconds,
|
|
541
|
+
};
|
|
542
|
+
out.push(final);
|
|
543
|
+
await correct(final, [`${secondProvider} text used (${provider} returned nothing)`], undefined);
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
out.push(ev);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
failure = err;
|
|
553
|
+
out.push({
|
|
554
|
+
type: "error",
|
|
555
|
+
message: err instanceof Error ? err.message : String(err),
|
|
556
|
+
recoverable: false,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
while (pending.size > 0) {
|
|
561
|
+
await Promise.allSettled([...pending]);
|
|
562
|
+
}
|
|
563
|
+
out.close();
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
try {
|
|
567
|
+
yield* out.drain();
|
|
568
|
+
await producer;
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
aborted = true;
|
|
572
|
+
// Close the source; a generator parked on an `await` (waiting for the
|
|
573
|
+
// next frame) only honours this once that await settles, so it is not
|
|
574
|
+
// awaited here — the flag above already stops every later step.
|
|
575
|
+
void iterator.return?.().catch(() => undefined);
|
|
576
|
+
const ended = SpanSerializer.endSpan(span, failure === undefined ? SpanStatus.OK : SpanStatus.ERROR, failure === undefined
|
|
577
|
+
? undefined
|
|
578
|
+
: failure instanceof Error
|
|
579
|
+
? failure.message
|
|
580
|
+
: String(failure));
|
|
581
|
+
ended.attributes = { ...ended.attributes, "stt.utterances": utterances };
|
|
582
|
+
getMetricsAggregator().recordSpan(ended);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whistle assets: the pinned manifest, where the files live, and the
|
|
3
|
+
* first-use download.
|
|
4
|
+
*
|
|
5
|
+
* Three files make up the engine — the Cactus "needle" runtime (an Emscripten
|
|
6
|
+
* build: `needle.js` + `needle.wasm`) and the Whistle speech model
|
|
7
|
+
* (`whistle.cact`). They are fetched from pinned Hugging Face revisions on
|
|
8
|
+
* first use, verified against a SHA-256, and written atomically (temp file +
|
|
9
|
+
* rename), so an interrupted download never leaves a file that looks complete.
|
|
10
|
+
*
|
|
11
|
+
* Both upstream repositories are Apache-2.0.
|
|
12
|
+
*
|
|
13
|
+
* @module voice/whistle/assets
|
|
14
|
+
*/
|
|
15
|
+
import type { STTCredentials, WhistleAsset, WhistleAssetPaths } from "../../types/index.js";
|
|
16
|
+
/** Pinned files, in the order they are fetched. */
|
|
17
|
+
export declare const WHISTLE_ASSETS: readonly WhistleAsset[];
|
|
18
|
+
/**
|
|
19
|
+
* Where Whistle keeps its files: `slice.modelDir` → `NEUROLINK_WHISTLE_DIR` →
|
|
20
|
+
* `NEUROLINK_MODEL_DIR/whistle` → the copy shipped with the package
|
|
21
|
+
* (`models/whistle`, when all three files are there) →
|
|
22
|
+
* `~/.neurolink/models/whistle`, the only one a download ever writes to.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveWhistleDir(slice?: STTCredentials["whistle"]): string;
|
|
25
|
+
/**
|
|
26
|
+
* The package's own `models/whistle`, found by walking up from this module
|
|
27
|
+
* to the directory holding NeuroLink's `package.json`. Works from the source
|
|
28
|
+
* tree, from `dist/` and from the single-file bundles, since all of them sit
|
|
29
|
+
* below the package root; `null` when nothing above looks like the package.
|
|
30
|
+
*/
|
|
31
|
+
export declare function bundledWhistleDir(): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Whether missing files may be fetched: on unless `slice.autoDownload` is
|
|
34
|
+
* `false` or `NEUROLINK_WHISTLE_AUTO_DOWNLOAD` is `0` / `false` / `off` / `no`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function whistleAutoDownloadEnabled(slice?: STTCredentials["whistle"]): boolean;
|
|
37
|
+
/** Cheap presence check (no hashing): all three files exist in `dir`. */
|
|
38
|
+
export declare function whistleAssetsPresent(dir: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Make sure all three files are in `dir` and match their pinned hashes,
|
|
41
|
+
* downloading what is missing when allowed. Concurrent calls for the same
|
|
42
|
+
* directory share one preparation.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ensureWhistleAssets(dir: string, autoDownload: boolean): Promise<WhistleAssetPaths>;
|
|
45
|
+
/** The friendly "here is exactly what to put where" error. */
|
|
46
|
+
/** The three files, where they go and where they come from — the body of every "unusable" message. */
|
|
47
|
+
export declare function describeWhistleAssets(dir: string, reason: string): string;
|