@juspay/neurolink 12.48.2 → 12.49.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 +2 -2
- 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/providerDescriptors.js +5 -4
- 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/providers/cloudflareClef.js +9 -1
- 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 +71 -12
- 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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whistle — the built-in, zero-config, on-device speech-to-text engine.
|
|
3
|
+
*
|
|
4
|
+
* Runs Cactus's Whistle model (17 MB) on the Cactus "needle" engine, an
|
|
5
|
+
* Emscripten WebAssembly build, inside a `worker_threads` Worker. No API key,
|
|
6
|
+
* no network after the first use, no native addon: it is the STT provider
|
|
7
|
+
* NeuroLink falls back to when no other one is configured.
|
|
8
|
+
*
|
|
9
|
+
* **Files.** `needle.js`, `needle.wasm` and `whistle.cact` (~17.9 MB in all)
|
|
10
|
+
* are downloaded from pinned Hugging Face revisions on first use, checked
|
|
11
|
+
* against SHA-256 and kept in the model directory:
|
|
12
|
+
* `credentials.stt.whistle.modelDir` → `NEUROLINK_WHISTLE_DIR` →
|
|
13
|
+
* `$NEUROLINK_MODEL_DIR/whistle` → `~/.neurolink/models/whistle`. Set
|
|
14
|
+
* `NEUROLINK_WHISTLE_AUTO_DOWNLOAD=0` (or `autoDownload: false`) to forbid the
|
|
15
|
+
* download; a missing file then fails with an error naming each file, where it
|
|
16
|
+
* goes and where to get it.
|
|
17
|
+
*
|
|
18
|
+
* **Languages.** English, German, French, Spanish, Italian, Dutch and Polish
|
|
19
|
+
* (`en de fr es it nl pl`). `options.language` accepts a code or a locale
|
|
20
|
+
* (`"de-DE"`); `"auto"`, omitted or any other language lets the engine detect
|
|
21
|
+
* one of the seven.
|
|
22
|
+
*
|
|
23
|
+
* **Limits.** The engine reads 30 s per call; longer audio is cut at the
|
|
24
|
+
* quietest 20 ms frame near every 24 s mark and the word times are offset
|
|
25
|
+
* back onto the whole recording. One hour per request. Roughly 0.5 s of CPU
|
|
26
|
+
* per 10 s of audio on a laptop core; one warm worker per handler instance,
|
|
27
|
+
* requests served in order.
|
|
28
|
+
*
|
|
29
|
+
* **Keyword biasing.** `options.vocabulary` is passed to the engine as
|
|
30
|
+
* keywords only when `options.model` is `"whistle-keywords"` or
|
|
31
|
+
* `NEUROLINK_WHISTLE_KEYWORDS=1` — the engine can loop on long keyword lists,
|
|
32
|
+
* so it is opt-in and capped at {@link MAX_KEYWORDS} entries.
|
|
33
|
+
*
|
|
34
|
+
* **Formats.** WAV (any PCM/float layout, rate and channel count) and raw
|
|
35
|
+
* `pcm16` are decoded in-process; mp3, m4a, ogg/opus, webm, flac and mp4 need
|
|
36
|
+
* ffmpeg (`ffmpeg-static`, `FFMPEG_PATH` or `ffmpeg` on PATH).
|
|
37
|
+
*
|
|
38
|
+
* **Streaming.** Native: `transcribeStream()` feeds the engine's own streaming
|
|
39
|
+
* decoder about a second at a time, on a dedicated worker per stream. Each
|
|
40
|
+
* step yields a final segment for the words two passes agreed on, and an
|
|
41
|
+
* interim segment for the unconfirmed tail. Input frames are PCM16LE mono at
|
|
42
|
+
* `options.sampleRate` (default 16 kHz).
|
|
43
|
+
*
|
|
44
|
+
* **Accuracy.** A 17 MB model: good on clear speech in its seven languages,
|
|
45
|
+
* noticeably behind cloud engines and large Whisper-class models on accents,
|
|
46
|
+
* noise, crosstalk, names and jargon. Use a dictionary/correction pass, or a
|
|
47
|
+
* cloud provider, where that matters.
|
|
48
|
+
*
|
|
49
|
+
* @module voice/providers/WhistleSTT
|
|
50
|
+
*/
|
|
51
|
+
import type { STTCredentials, STTHandler, STTLanguage, STTOptions, STTResult, TranscriptionSegment, TTSAudioFormat } from "../../types/index.js";
|
|
52
|
+
export declare class WhistleSTT implements STTHandler {
|
|
53
|
+
/** One hour per request (16 kHz float samples: ~230 MB in memory). */
|
|
54
|
+
readonly maxAudioDuration: number;
|
|
55
|
+
/** The engine has its own streaming decoder. */
|
|
56
|
+
readonly supportsStreaming = true;
|
|
57
|
+
private readonly modelDir;
|
|
58
|
+
private readonly autoDownload;
|
|
59
|
+
private engine;
|
|
60
|
+
constructor(slice?: STTCredentials["whistle"]);
|
|
61
|
+
/**
|
|
62
|
+
* True when the engine can run: its files are already in the model dir, or
|
|
63
|
+
* it is allowed to fetch them. With `NEUROLINK_WHISTLE_AUTO_DOWNLOAD=0` (or
|
|
64
|
+
* `autoDownload: false`) and no files, it reports unconfigured, so a caller
|
|
65
|
+
* asserting "no STT backend" is not answered by a 17 MB download.
|
|
66
|
+
*/
|
|
67
|
+
isConfigured(): boolean;
|
|
68
|
+
describeConfiguration(): string;
|
|
69
|
+
/** Directory the engine's files live in. */
|
|
70
|
+
getModelDir(): string;
|
|
71
|
+
getSupportedFormats(): TTSAudioFormat[];
|
|
72
|
+
getSupportedLanguages(): Promise<STTLanguage[]>;
|
|
73
|
+
/** Download/verify the files and start the worker now rather than on the first request. */
|
|
74
|
+
warmUp(): Promise<void>;
|
|
75
|
+
/** Stop the warm worker. The handler stays usable; the next call starts a new one. */
|
|
76
|
+
dispose(): Promise<void>;
|
|
77
|
+
transcribe(audio: Buffer | ArrayBuffer, options?: STTOptions): Promise<STTResult>;
|
|
78
|
+
/**
|
|
79
|
+
* Native streaming. `audioStream` yields PCM16LE mono frames at
|
|
80
|
+
* `options.sampleRate` (default 16 kHz), any frame size. Yields a final
|
|
81
|
+
* segment for each run of words the engine commits (times from the start of
|
|
82
|
+
* the stream; join the finals with a space for the transcript) and an
|
|
83
|
+
* interim segment carrying the unconfirmed tail whenever it changes.
|
|
84
|
+
*/
|
|
85
|
+
transcribeStream(audioStream: AsyncIterable<Buffer>, options?: STTOptions): AsyncIterable<TranscriptionSegment>;
|
|
86
|
+
private getEngine;
|
|
87
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whistle — the built-in, zero-config, on-device speech-to-text engine.
|
|
3
|
+
*
|
|
4
|
+
* Runs Cactus's Whistle model (17 MB) on the Cactus "needle" engine, an
|
|
5
|
+
* Emscripten WebAssembly build, inside a `worker_threads` Worker. No API key,
|
|
6
|
+
* no network after the first use, no native addon: it is the STT provider
|
|
7
|
+
* NeuroLink falls back to when no other one is configured.
|
|
8
|
+
*
|
|
9
|
+
* **Files.** `needle.js`, `needle.wasm` and `whistle.cact` (~17.9 MB in all)
|
|
10
|
+
* are downloaded from pinned Hugging Face revisions on first use, checked
|
|
11
|
+
* against SHA-256 and kept in the model directory:
|
|
12
|
+
* `credentials.stt.whistle.modelDir` → `NEUROLINK_WHISTLE_DIR` →
|
|
13
|
+
* `$NEUROLINK_MODEL_DIR/whistle` → `~/.neurolink/models/whistle`. Set
|
|
14
|
+
* `NEUROLINK_WHISTLE_AUTO_DOWNLOAD=0` (or `autoDownload: false`) to forbid the
|
|
15
|
+
* download; a missing file then fails with an error naming each file, where it
|
|
16
|
+
* goes and where to get it.
|
|
17
|
+
*
|
|
18
|
+
* **Languages.** English, German, French, Spanish, Italian, Dutch and Polish
|
|
19
|
+
* (`en de fr es it nl pl`). `options.language` accepts a code or a locale
|
|
20
|
+
* (`"de-DE"`); `"auto"`, omitted or any other language lets the engine detect
|
|
21
|
+
* one of the seven.
|
|
22
|
+
*
|
|
23
|
+
* **Limits.** The engine reads 30 s per call; longer audio is cut at the
|
|
24
|
+
* quietest 20 ms frame near every 24 s mark and the word times are offset
|
|
25
|
+
* back onto the whole recording. One hour per request. Roughly 0.5 s of CPU
|
|
26
|
+
* per 10 s of audio on a laptop core; one warm worker per handler instance,
|
|
27
|
+
* requests served in order.
|
|
28
|
+
*
|
|
29
|
+
* **Keyword biasing.** `options.vocabulary` is passed to the engine as
|
|
30
|
+
* keywords only when `options.model` is `"whistle-keywords"` or
|
|
31
|
+
* `NEUROLINK_WHISTLE_KEYWORDS=1` — the engine can loop on long keyword lists,
|
|
32
|
+
* so it is opt-in and capped at {@link MAX_KEYWORDS} entries.
|
|
33
|
+
*
|
|
34
|
+
* **Formats.** WAV (any PCM/float layout, rate and channel count) and raw
|
|
35
|
+
* `pcm16` are decoded in-process; mp3, m4a, ogg/opus, webm, flac and mp4 need
|
|
36
|
+
* ffmpeg (`ffmpeg-static`, `FFMPEG_PATH` or `ffmpeg` on PATH).
|
|
37
|
+
*
|
|
38
|
+
* **Streaming.** Native: `transcribeStream()` feeds the engine's own streaming
|
|
39
|
+
* decoder about a second at a time, on a dedicated worker per stream. Each
|
|
40
|
+
* step yields a final segment for the words two passes agreed on, and an
|
|
41
|
+
* interim segment for the unconfirmed tail. Input frames are PCM16LE mono at
|
|
42
|
+
* `options.sampleRate` (default 16 kHz).
|
|
43
|
+
*
|
|
44
|
+
* **Accuracy.** A 17 MB model: good on clear speech in its seven languages,
|
|
45
|
+
* noticeably behind cloud engines and large Whisper-class models on accents,
|
|
46
|
+
* noise, crosstalk, names and jargon. Use a dictionary/correction pass, or a
|
|
47
|
+
* cloud provider, where that matters.
|
|
48
|
+
*
|
|
49
|
+
* @module voice/providers/WhistleSTT
|
|
50
|
+
*/
|
|
51
|
+
import { logger } from "../../utils/logger.js";
|
|
52
|
+
import { STTError } from "../errors.js";
|
|
53
|
+
import { describeWhistleAssets, ensureWhistleAssets, resolveWhistleDir, whistleAssetsPresent, whistleAutoDownloadEnabled, } from "../whistle/assets.js";
|
|
54
|
+
import { WHISTLE_FFMPEG_FORMATS, WHISTLE_NATIVE_FORMATS, WHISTLE_SAMPLE_RATE, decodePcm16, decodeToWhistlePcm, } from "../whistle/audio.js";
|
|
55
|
+
import { WhistleEngine } from "../whistle/engine.js";
|
|
56
|
+
const PROVIDER = "whistle";
|
|
57
|
+
const MODEL = "whistle";
|
|
58
|
+
/** `options.model` value that turns keyword biasing on. */
|
|
59
|
+
const KEYWORDS_MODEL = "whistle-keywords";
|
|
60
|
+
/** Longer keyword lists make the decoder loop. */
|
|
61
|
+
const MAX_KEYWORDS = 32;
|
|
62
|
+
/** Confidence reported for non-empty text when the engine gave no word scores. */
|
|
63
|
+
const DEFAULT_CONFIDENCE = 0.9;
|
|
64
|
+
const LANGUAGES = [
|
|
65
|
+
{ code: "en", name: "English" },
|
|
66
|
+
{ code: "de", name: "German" },
|
|
67
|
+
{ code: "fr", name: "French" },
|
|
68
|
+
{ code: "es", name: "Spanish" },
|
|
69
|
+
{ code: "it", name: "Italian" },
|
|
70
|
+
{ code: "nl", name: "Dutch" },
|
|
71
|
+
{ code: "pl", name: "Polish" },
|
|
72
|
+
];
|
|
73
|
+
const LANGUAGE_CODES = new Set(LANGUAGES.map((l) => l.code));
|
|
74
|
+
export class WhistleSTT {
|
|
75
|
+
/** One hour per request (16 kHz float samples: ~230 MB in memory). */
|
|
76
|
+
maxAudioDuration = 60 * 60;
|
|
77
|
+
/** The engine has its own streaming decoder. */
|
|
78
|
+
supportsStreaming = true;
|
|
79
|
+
modelDir;
|
|
80
|
+
autoDownload;
|
|
81
|
+
engine;
|
|
82
|
+
constructor(slice) {
|
|
83
|
+
this.modelDir = resolveWhistleDir(slice);
|
|
84
|
+
this.autoDownload = whistleAutoDownloadEnabled(slice);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* True when the engine can run: its files are already in the model dir, or
|
|
88
|
+
* it is allowed to fetch them. With `NEUROLINK_WHISTLE_AUTO_DOWNLOAD=0` (or
|
|
89
|
+
* `autoDownload: false`) and no files, it reports unconfigured, so a caller
|
|
90
|
+
* asserting "no STT backend" is not answered by a 17 MB download.
|
|
91
|
+
*/
|
|
92
|
+
isConfigured() {
|
|
93
|
+
return whistleAssetsPresent(this.modelDir) || this.autoDownload;
|
|
94
|
+
}
|
|
95
|
+
describeConfiguration() {
|
|
96
|
+
return describeWhistleAssets(this.modelDir, "automatic download is off (NEUROLINK_WHISTLE_AUTO_DOWNLOAD=0 or autoDownload: false)");
|
|
97
|
+
}
|
|
98
|
+
/** Directory the engine's files live in. */
|
|
99
|
+
getModelDir() {
|
|
100
|
+
return this.modelDir;
|
|
101
|
+
}
|
|
102
|
+
getSupportedFormats() {
|
|
103
|
+
return [...WHISTLE_NATIVE_FORMATS, ...WHISTLE_FFMPEG_FORMATS];
|
|
104
|
+
}
|
|
105
|
+
async getSupportedLanguages() {
|
|
106
|
+
return LANGUAGES.map((l) => ({
|
|
107
|
+
code: l.code,
|
|
108
|
+
name: l.name,
|
|
109
|
+
supportsDiarization: false,
|
|
110
|
+
supportsPunctuation: true,
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/** Download/verify the files and start the worker now rather than on the first request. */
|
|
114
|
+
async warmUp() {
|
|
115
|
+
await this.getEngine();
|
|
116
|
+
}
|
|
117
|
+
/** Stop the warm worker. The handler stays usable; the next call starts a new one. */
|
|
118
|
+
async dispose() {
|
|
119
|
+
const engine = this.engine;
|
|
120
|
+
this.engine = undefined;
|
|
121
|
+
if (engine) {
|
|
122
|
+
await (await engine.catch(() => undefined))?.dispose();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async transcribe(audio, options = {}) {
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
const buffer = Buffer.isBuffer(audio) ? audio : Buffer.from(audio);
|
|
128
|
+
if (buffer.length === 0) {
|
|
129
|
+
throw STTError.audioEmpty(PROVIDER);
|
|
130
|
+
}
|
|
131
|
+
const pcm = await decodeToWhistlePcm(buffer, options.format, options.sampleRate);
|
|
132
|
+
if (pcm.length === 0) {
|
|
133
|
+
throw STTError.audioEmpty(PROVIDER);
|
|
134
|
+
}
|
|
135
|
+
const duration = pcm.length / WHISTLE_SAMPLE_RATE;
|
|
136
|
+
if (duration > this.maxAudioDuration) {
|
|
137
|
+
throw STTError.audioTooLong(Math.round(duration), this.maxAudioDuration, PROVIDER);
|
|
138
|
+
}
|
|
139
|
+
const language = normalizeLanguage(options.language);
|
|
140
|
+
const keywords = keywordsFor(options);
|
|
141
|
+
const engine = await this.getEngine();
|
|
142
|
+
// Timestamps are always requested: the per-word probabilities are the only confidence signal.
|
|
143
|
+
// Engine failures already arrive as STTErrors.
|
|
144
|
+
const out = await engine.transcribe(pcm, language, keywords, true);
|
|
145
|
+
const text = out.text.trim();
|
|
146
|
+
const confidence = confidenceOf(text, out.words);
|
|
147
|
+
const result = {
|
|
148
|
+
text,
|
|
149
|
+
confidence,
|
|
150
|
+
language: out.language ?? language ?? undefined,
|
|
151
|
+
duration: Math.round(duration * 1000) / 1000,
|
|
152
|
+
metadata: {
|
|
153
|
+
latency: Date.now() - started,
|
|
154
|
+
provider: PROVIDER,
|
|
155
|
+
model: MODEL,
|
|
156
|
+
confidenceSource: !text
|
|
157
|
+
? "none"
|
|
158
|
+
: out.words.length
|
|
159
|
+
? "word_logprobs"
|
|
160
|
+
: undefined,
|
|
161
|
+
ttftMs: out.ttftMs,
|
|
162
|
+
decodeTps: out.decodeTps,
|
|
163
|
+
chunks: out.chunks,
|
|
164
|
+
keywords: keywords !== null,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
if (options.wordTimestamps) {
|
|
168
|
+
result.words = out.words.map(toWordTiming);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Native streaming. `audioStream` yields PCM16LE mono frames at
|
|
174
|
+
* `options.sampleRate` (default 16 kHz), any frame size. Yields a final
|
|
175
|
+
* segment for each run of words the engine commits (times from the start of
|
|
176
|
+
* the stream; join the finals with a space for the transcript) and an
|
|
177
|
+
* interim segment carrying the unconfirmed tail whenever it changes.
|
|
178
|
+
*/
|
|
179
|
+
async *transcribeStream(audioStream, options = {}) {
|
|
180
|
+
const rate = options.sampleRate ?? WHISTLE_SAMPLE_RATE;
|
|
181
|
+
const language = normalizeLanguage(options.language);
|
|
182
|
+
const keywords = keywordsFor(options);
|
|
183
|
+
const paths = await ensureWhistleAssets(this.modelDir, this.autoDownload);
|
|
184
|
+
// The engine's stream state is global to its instance: a stream gets a worker of its own,
|
|
185
|
+
// so batch calls on this handler are never interleaved with it.
|
|
186
|
+
const engine = new WhistleEngine(paths);
|
|
187
|
+
const blockBytes = rate * 2; // about a second per engine pass
|
|
188
|
+
let pending = Buffer.alloc(0);
|
|
189
|
+
let index = 0;
|
|
190
|
+
let lastInterim = "";
|
|
191
|
+
let finished = false;
|
|
192
|
+
const segmentsOf = function* (step) {
|
|
193
|
+
if (step.text) {
|
|
194
|
+
const words = step.words.map(toWordTiming);
|
|
195
|
+
yield {
|
|
196
|
+
index: index++,
|
|
197
|
+
text: step.text,
|
|
198
|
+
isFinal: true,
|
|
199
|
+
confidence: confidenceOf(step.text, step.words),
|
|
200
|
+
startTime: step.words[0]?.start,
|
|
201
|
+
endTime: step.words[step.words.length - 1]?.end,
|
|
202
|
+
words,
|
|
203
|
+
language: step.language ?? language ?? undefined,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (step.pending !== lastInterim) {
|
|
207
|
+
lastInterim = step.pending;
|
|
208
|
+
yield {
|
|
209
|
+
index,
|
|
210
|
+
text: step.pending,
|
|
211
|
+
isFinal: false,
|
|
212
|
+
language: step.language ?? language ?? undefined,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
for await (const frame of audioStream) {
|
|
218
|
+
pending = pending.length
|
|
219
|
+
? Buffer.concat([pending, frame])
|
|
220
|
+
: Buffer.from(frame);
|
|
221
|
+
while (pending.length >= blockBytes) {
|
|
222
|
+
const block = pending.subarray(0, blockBytes);
|
|
223
|
+
pending = pending.subarray(blockBytes);
|
|
224
|
+
const step = await engine.streamProcess(decodePcm16(block, rate), language, keywords);
|
|
225
|
+
yield* segmentsOf(step);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const tail = pending.subarray(0, pending.length - (pending.length & 1));
|
|
229
|
+
if (tail.length > 0) {
|
|
230
|
+
const step = await engine.streamProcess(decodePcm16(tail, rate), language, keywords);
|
|
231
|
+
yield* segmentsOf(step);
|
|
232
|
+
}
|
|
233
|
+
const last = await engine.streamStop();
|
|
234
|
+
yield* segmentsOf(last);
|
|
235
|
+
finished = true;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error instanceof STTError) {
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
throw STTError.streamError(error instanceof Error ? error.message : String(error), PROVIDER);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
if (!finished) {
|
|
245
|
+
logger.debug("[whistle] stream ended early; discarding its worker");
|
|
246
|
+
}
|
|
247
|
+
await engine.dispose();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
getEngine() {
|
|
251
|
+
if (!this.engine) {
|
|
252
|
+
const job = ensureWhistleAssets(this.modelDir, this.autoDownload).then((paths) => new WhistleEngine(paths));
|
|
253
|
+
this.engine = job;
|
|
254
|
+
// A failed preparation (offline, download off) must not stick.
|
|
255
|
+
job.catch(() => {
|
|
256
|
+
if (this.engine === job) {
|
|
257
|
+
this.engine = undefined;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return this.engine;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** `"de-DE"` → `"de"`; `"auto"`, omitted, or a language Whistle does not know → `null` (detect). */
|
|
265
|
+
function normalizeLanguage(language) {
|
|
266
|
+
if (!language) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const base = language.trim().toLowerCase().split(/[-_]/)[0];
|
|
270
|
+
if (!base || base === "auto") {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
if (!LANGUAGE_CODES.has(base)) {
|
|
274
|
+
logger.debug(`[whistle] language "${language}" is not one of ${[...LANGUAGE_CODES].join(", ")}; detecting instead`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return base;
|
|
278
|
+
}
|
|
279
|
+
/** Newline-separated keywords, only when biasing is switched on. */
|
|
280
|
+
function keywordsFor(options) {
|
|
281
|
+
const enabled = options.model === KEYWORDS_MODEL ||
|
|
282
|
+
["1", "true", "on", "yes"].includes((process.env.NEUROLINK_WHISTLE_KEYWORDS ?? "").trim().toLowerCase());
|
|
283
|
+
if (!enabled || !options.vocabulary?.length) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const terms = [
|
|
287
|
+
...new Set(options.vocabulary.map((t) => t.replace(/\s+/g, " ").trim())),
|
|
288
|
+
].filter(Boolean);
|
|
289
|
+
if (terms.length > MAX_KEYWORDS) {
|
|
290
|
+
logger.debug(`[whistle] keyword list cut from ${terms.length} to ${MAX_KEYWORDS}`);
|
|
291
|
+
}
|
|
292
|
+
return terms.length ? terms.slice(0, MAX_KEYWORDS).join("\n") : null;
|
|
293
|
+
}
|
|
294
|
+
function confidenceOf(text, words) {
|
|
295
|
+
if (!text) {
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
if (!words.length) {
|
|
299
|
+
return DEFAULT_CONFIDENCE;
|
|
300
|
+
}
|
|
301
|
+
const mean = words.reduce((sum, w) => sum + w.probability, 0) / words.length;
|
|
302
|
+
return Math.min(1, Math.max(0, Math.round(mean * 1000) / 1000));
|
|
303
|
+
}
|
|
304
|
+
function toWordTiming(w) {
|
|
305
|
+
return {
|
|
306
|
+
word: w.word,
|
|
307
|
+
start: w.start,
|
|
308
|
+
end: w.end,
|
|
309
|
+
startTime: w.start,
|
|
310
|
+
endTime: w.end,
|
|
311
|
+
confidence: w.probability,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { STTChunkedRun, STTChunkedStreamOptions, TranscribeStreamEvent } from "../../types/index.js";
|
|
2
|
+
/** Minimal unbounded async queue: the producer never waits for the consumer. */
|
|
3
|
+
export declare class StreamEventQueue<T> {
|
|
4
|
+
private readonly items;
|
|
5
|
+
private waiter;
|
|
6
|
+
private closed;
|
|
7
|
+
push(item: T): void;
|
|
8
|
+
close(): void;
|
|
9
|
+
drain(): AsyncGenerator<T>;
|
|
10
|
+
private wake;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Streams a batch STT engine: `frames` are PCM16LE mono at
|
|
14
|
+
* `options.sampleRate` (default 16000); `run` transcribes one utterance at
|
|
15
|
+
* 16 kHz. Yields `interim`, `language`, `final`, `silence` and `error`
|
|
16
|
+
* events; utterance numbers are in order but may skip (a dropped cough
|
|
17
|
+
* consumes one). Ends after the source ends and every final is emitted.
|
|
18
|
+
*/
|
|
19
|
+
export declare function chunkedTranscribeStream(frames: AsyncIterable<Buffer | Uint8Array>, run: STTChunkedRun, options?: STTChunkedStreamOptions): AsyncIterable<TranscribeStreamEvent>;
|