@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,718 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* NeuroLink CLI Transcribe Command
|
|
4
|
+
*
|
|
5
|
+
* Calls `NeuroLink.transcribe()` — audio in, text out — or, with `--stream`,
|
|
6
|
+
* `NeuroLink.transcribeStream()` over the file decoded to 16 kHz mono PCM16.
|
|
7
|
+
* See src/lib/types/transcribe.ts for the full vocabulary (dictionary,
|
|
8
|
+
* correction, second opinion, fallback).
|
|
9
|
+
*
|
|
10
|
+
* Credentials are env-only here, exactly as every other CLI command: each STT
|
|
11
|
+
* handler reads its own settings, and `NEUROLINK_STT_PROVIDER`,
|
|
12
|
+
* `NEUROLINK_STT_MODEL` and `NEUROLINK_STT_LANGUAGE` pick the defaults. With
|
|
13
|
+
* nothing configured the built-in local engine (Whistle) is used.
|
|
14
|
+
*/
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
import ora from "ora";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import { NeuroLink } from "../../neurolink.js";
|
|
19
|
+
import { logger } from "../../utils/logger.js";
|
|
20
|
+
const TARGET_SAMPLE_RATE = 16000;
|
|
21
|
+
/** 100 ms of 16 kHz mono PCM16. */
|
|
22
|
+
const STREAM_FRAME_BYTES = (TARGET_SAMPLE_RATE / 10) * 2;
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
function errorMessage(error) {
|
|
27
|
+
return error instanceof Error ? error.message : String(error);
|
|
28
|
+
}
|
|
29
|
+
function fail(message) {
|
|
30
|
+
logger.error(chalk.red(`Error: ${message}`));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Input
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
async function readStdin() {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
for await (const chunk of process.stdin) {
|
|
39
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
40
|
+
}
|
|
41
|
+
return Buffer.concat(chunks);
|
|
42
|
+
}
|
|
43
|
+
async function readAudio(file) {
|
|
44
|
+
const { audioFormatFromBytes, audioFormatFromName } = await import("../../voice/transcribe.js");
|
|
45
|
+
if (file === "-") {
|
|
46
|
+
if (process.stdin.isTTY) {
|
|
47
|
+
throw new Error("`-` reads audio from stdin, but stdin is a terminal — pipe a file in.");
|
|
48
|
+
}
|
|
49
|
+
const audio = await readStdin();
|
|
50
|
+
return { audio, format: audioFormatFromBytes(audio), label: "stdin" };
|
|
51
|
+
}
|
|
52
|
+
const audio = await fs.promises.readFile(file);
|
|
53
|
+
const format = audioFormatFromName(file) ?? audioFormatFromBytes(audio);
|
|
54
|
+
return { audio, format, label: file };
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Dictionary
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function toDictionaryEntry(value, where) {
|
|
60
|
+
if (!isRecord(value) ||
|
|
61
|
+
typeof value.term !== "string" ||
|
|
62
|
+
!value.term.trim()) {
|
|
63
|
+
throw new Error(`${where}: every entry needs a non-empty "term" string.`);
|
|
64
|
+
}
|
|
65
|
+
const entry = { term: value.term.trim() };
|
|
66
|
+
if (value.heardAs !== undefined) {
|
|
67
|
+
if (!Array.isArray(value.heardAs) ||
|
|
68
|
+
!value.heardAs.every((h) => typeof h === "string")) {
|
|
69
|
+
throw new Error(`${where}: "heardAs" must be an array of strings.`);
|
|
70
|
+
}
|
|
71
|
+
const heardAs = value.heardAs.map((h) => h.trim()).filter(Boolean);
|
|
72
|
+
if (heardAs.length > 0) {
|
|
73
|
+
entry.heardAs = heardAs;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (value.meaning !== undefined) {
|
|
77
|
+
if (typeof value.meaning !== "string") {
|
|
78
|
+
throw new Error(`${where}: "meaning" must be a string.`);
|
|
79
|
+
}
|
|
80
|
+
if (value.meaning.trim()) {
|
|
81
|
+
entry.meaning = value.meaning.trim();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
/** `"Term|heard,as|meaning"` — only the term is required. */
|
|
87
|
+
function parseTermFlag(raw) {
|
|
88
|
+
const [term = "", heard = "", ...meaningParts] = raw.split("|");
|
|
89
|
+
const meaning = meaningParts.join("|").trim();
|
|
90
|
+
if (!term.trim()) {
|
|
91
|
+
throw new Error(`--term "${raw}" has no term; use "Term|heard,as|meaning".`);
|
|
92
|
+
}
|
|
93
|
+
const heardAs = heard
|
|
94
|
+
.split(",")
|
|
95
|
+
.map((h) => h.trim())
|
|
96
|
+
.filter(Boolean);
|
|
97
|
+
return {
|
|
98
|
+
term: term.trim(),
|
|
99
|
+
...(heardAs.length > 0 ? { heardAs } : {}),
|
|
100
|
+
...(meaning ? { meaning } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function buildDictionary(argv) {
|
|
104
|
+
const entries = [];
|
|
105
|
+
if (argv.dictionary) {
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(fs.readFileSync(argv.dictionary, "utf-8"));
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
throw new Error(`could not read --dictionary ${argv.dictionary} — ${errorMessage(error)}`, { cause: error });
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(parsed)) {
|
|
114
|
+
throw new Error("--dictionary must hold a JSON array of { term, heardAs, meaning }.");
|
|
115
|
+
}
|
|
116
|
+
parsed.forEach((value, i) => entries.push(toDictionaryEntry(value, `--dictionary entry ${i}`)));
|
|
117
|
+
}
|
|
118
|
+
for (const raw of [].concat(argv.term ?? [])) {
|
|
119
|
+
entries.push(parseTermFlag(raw));
|
|
120
|
+
}
|
|
121
|
+
return entries.length > 0 ? entries : undefined;
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Options
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
function buildCorrection(argv, hasDictionary) {
|
|
127
|
+
const correction = {};
|
|
128
|
+
// A rewrite model, a transliteration choice or a second opinion only means
|
|
129
|
+
// something inside the correction layer, so each one turns it on.
|
|
130
|
+
const implied = Boolean(argv.rewriteProvider ||
|
|
131
|
+
argv.rewriteModel ||
|
|
132
|
+
argv.transliterate ||
|
|
133
|
+
argv.secondOpinion);
|
|
134
|
+
if (argv.correct || implied) {
|
|
135
|
+
correction.enabled = true;
|
|
136
|
+
}
|
|
137
|
+
if (argv.rewrite === false) {
|
|
138
|
+
correction.rewrite = false;
|
|
139
|
+
}
|
|
140
|
+
else if (argv.rewriteProvider || argv.rewriteModel) {
|
|
141
|
+
correction.rewrite = {
|
|
142
|
+
...(argv.rewriteProvider ? { provider: argv.rewriteProvider } : {}),
|
|
143
|
+
...(argv.rewriteModel ? { model: argv.rewriteModel } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (argv.transliterate) {
|
|
147
|
+
correction.transliterate = argv.transliterate;
|
|
148
|
+
}
|
|
149
|
+
if (argv.secondOpinion) {
|
|
150
|
+
correction.secondOpinion = { provider: argv.secondOpinion };
|
|
151
|
+
}
|
|
152
|
+
if (argv.context) {
|
|
153
|
+
correction.context = argv.context;
|
|
154
|
+
}
|
|
155
|
+
if (Object.keys(correction).length === 0) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
// The SDK reads any correction object as a request for correction. Here
|
|
159
|
+
// only --correct, the flags above or a dictionary ask for it; --context or
|
|
160
|
+
// --no-rewrite alone must not start an LLM rewrite.
|
|
161
|
+
if (correction.enabled === undefined && !hasDictionary) {
|
|
162
|
+
correction.enabled = false;
|
|
163
|
+
}
|
|
164
|
+
return correction;
|
|
165
|
+
}
|
|
166
|
+
function buildCommonOptions(argv, dictionary) {
|
|
167
|
+
const correction = buildCorrection(argv, Boolean(dictionary));
|
|
168
|
+
return {
|
|
169
|
+
...(argv.provider ? { provider: argv.provider } : {}),
|
|
170
|
+
...(argv.model ? { model: argv.model } : {}),
|
|
171
|
+
// Omitted → NEUROLINK_STT_LANGUAGE, else the engine detects it.
|
|
172
|
+
...(argv.language ? { language: argv.language } : {}),
|
|
173
|
+
...(argv.diarize ? { diarization: true } : {}),
|
|
174
|
+
...(argv.wordTimestamps ? { wordTimestamps: true } : {}),
|
|
175
|
+
...(argv.prompt ? { prompt: argv.prompt } : {}),
|
|
176
|
+
...(dictionary ? { dictionary } : {}),
|
|
177
|
+
...(correction ? { correction } : {}),
|
|
178
|
+
...(argv.fallback ? { fallback: { provider: argv.fallback } } : {}),
|
|
179
|
+
...(argv.timeout !== undefined ? { timeoutMs: argv.timeout } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Decoding for --stream
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
/**
|
|
186
|
+
* The file as 16 kHz mono PCM16LE, decoded exactly as the local engine decodes
|
|
187
|
+
* its input: WAV of any layout in-process, raw `pcm16` taken as 16 kHz, every
|
|
188
|
+
* other container through ffmpeg (`FFMPEG_PATH`, `ffmpeg-static`, or `ffmpeg`
|
|
189
|
+
* on PATH) — and a clear error when there is none.
|
|
190
|
+
*/
|
|
191
|
+
async function decodeToPcm16(audio, format, label) {
|
|
192
|
+
const { decodeToWhistlePcm } = await import("../../voice/whistle/audio.js");
|
|
193
|
+
let samples;
|
|
194
|
+
try {
|
|
195
|
+
samples = await decodeToWhistlePcm(audio, format, TARGET_SAMPLE_RATE);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
throw new Error(`--stream needs 16 kHz PCM and ${label} could not be decoded — ${errorMessage(error)}`, { cause: error });
|
|
199
|
+
}
|
|
200
|
+
const pcm = Buffer.alloc(samples.length * 2);
|
|
201
|
+
for (let i = 0; i < samples.length; i++) {
|
|
202
|
+
pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), i * 2);
|
|
203
|
+
}
|
|
204
|
+
return pcm;
|
|
205
|
+
}
|
|
206
|
+
async function* pcmFrames(pcm) {
|
|
207
|
+
for (let at = 0; at < pcm.length; at += STREAM_FRAME_BYTES) {
|
|
208
|
+
yield pcm.subarray(at, Math.min(pcm.length, at + STREAM_FRAME_BYTES));
|
|
209
|
+
// Let the consumer's timers and I/O run between frames.
|
|
210
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Subtitles
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
function startOf(item) {
|
|
217
|
+
return item.start ?? item.startTime;
|
|
218
|
+
}
|
|
219
|
+
function endOf(item) {
|
|
220
|
+
return item.end ?? item.endTime;
|
|
221
|
+
}
|
|
222
|
+
/** Words grouped into readable cues: at most ~7 words or 4 s, broken at sentence ends. */
|
|
223
|
+
function cuesFromWords(words) {
|
|
224
|
+
const cues = [];
|
|
225
|
+
let current;
|
|
226
|
+
let count = 0;
|
|
227
|
+
for (const word of words) {
|
|
228
|
+
const start = startOf(word);
|
|
229
|
+
const end = endOf(word) ?? start;
|
|
230
|
+
if (start === undefined || end === undefined) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!current) {
|
|
234
|
+
current = { start, end, text: word.word.trim() };
|
|
235
|
+
count = 1;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
current.text += ` ${word.word.trim()}`;
|
|
239
|
+
current.end = end;
|
|
240
|
+
count++;
|
|
241
|
+
}
|
|
242
|
+
if (count >= 7 ||
|
|
243
|
+
current.end - current.start >= 4 ||
|
|
244
|
+
/[.!?]$/.test(word.word.trim())) {
|
|
245
|
+
cues.push(current);
|
|
246
|
+
current = undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (current) {
|
|
250
|
+
cues.push(current);
|
|
251
|
+
}
|
|
252
|
+
return cues;
|
|
253
|
+
}
|
|
254
|
+
function buildCues(text, segments, words, duration) {
|
|
255
|
+
const timed = (segments ?? []).filter((s) => startOf(s) !== undefined && endOf(s) !== undefined && s.text.trim());
|
|
256
|
+
if (timed.length === 1) {
|
|
257
|
+
// One segment: it can carry the corrected text without losing alignment.
|
|
258
|
+
return [{ start: startOf(timed[0]) ?? 0, end: endOf(timed[0]) ?? 0, text }];
|
|
259
|
+
}
|
|
260
|
+
if (timed.length > 1) {
|
|
261
|
+
return timed.map((s) => ({
|
|
262
|
+
start: startOf(s) ?? 0,
|
|
263
|
+
end: endOf(s) ?? 0,
|
|
264
|
+
text: s.text.trim(),
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
const fromWords = words ? cuesFromWords(words) : [];
|
|
268
|
+
if (fromWords.length > 0) {
|
|
269
|
+
return fromWords;
|
|
270
|
+
}
|
|
271
|
+
return text.trim()
|
|
272
|
+
? [{ start: 0, end: duration ?? 0, text: text.trim() }]
|
|
273
|
+
: [];
|
|
274
|
+
}
|
|
275
|
+
function timestamp(seconds, separator) {
|
|
276
|
+
const ms = Math.max(0, Math.round(seconds * 1000));
|
|
277
|
+
const h = Math.floor(ms / 3_600_000);
|
|
278
|
+
const m = Math.floor((ms % 3_600_000) / 60_000);
|
|
279
|
+
const s = Math.floor((ms % 60_000) / 1000);
|
|
280
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
281
|
+
return `${pad(h)}:${pad(m)}:${pad(s)}${separator}${pad(ms % 1000, 3)}`;
|
|
282
|
+
}
|
|
283
|
+
function renderSubtitles(cues, format) {
|
|
284
|
+
const sep = format === "srt" ? "," : ".";
|
|
285
|
+
const body = cues
|
|
286
|
+
.map((cue, i) => {
|
|
287
|
+
const end = Math.max(cue.end, cue.start + 0.5);
|
|
288
|
+
const range = `${timestamp(cue.start, sep)} --> ${timestamp(end, sep)}`;
|
|
289
|
+
return format === "srt"
|
|
290
|
+
? `${i + 1}\n${range}\n${cue.text}`
|
|
291
|
+
: `${range}\n${cue.text}`;
|
|
292
|
+
})
|
|
293
|
+
.join("\n\n");
|
|
294
|
+
return format === "srt" ? `${body}\n` : `WEBVTT\n\n${body}\n`;
|
|
295
|
+
}
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Human output
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
function ms(value) {
|
|
300
|
+
if (value === undefined) {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
return value >= 1000
|
|
304
|
+
? `${(value / 1000).toFixed(1)}s`
|
|
305
|
+
: `${Math.round(value)}ms`;
|
|
306
|
+
}
|
|
307
|
+
function trailLine(result) {
|
|
308
|
+
const parts = [];
|
|
309
|
+
const engine = result.engine.model
|
|
310
|
+
? `${result.engine.provider}/${result.engine.model}`
|
|
311
|
+
: result.engine.provider;
|
|
312
|
+
parts.push(result.engine.fallbackUsed
|
|
313
|
+
? `engine ${engine} (fallback)`
|
|
314
|
+
: `engine ${engine}`);
|
|
315
|
+
if (result.engine.secondOpinion) {
|
|
316
|
+
parts.push(`second opinion ${result.engine.secondOpinion}`);
|
|
317
|
+
}
|
|
318
|
+
if (result.language) {
|
|
319
|
+
const how = result.languageDetected === false
|
|
320
|
+
? " (unsure)"
|
|
321
|
+
: result.languageDetected === true
|
|
322
|
+
? " (detected)"
|
|
323
|
+
: "";
|
|
324
|
+
parts.push(`language ${result.language}${how}`);
|
|
325
|
+
}
|
|
326
|
+
if (result.duration !== undefined) {
|
|
327
|
+
parts.push(`audio ${result.duration.toFixed(1)}s`);
|
|
328
|
+
}
|
|
329
|
+
const t = result.timings;
|
|
330
|
+
const timings = [
|
|
331
|
+
["transcribe", t.transcribeMs],
|
|
332
|
+
["second opinion", t.secondOpinionMs],
|
|
333
|
+
["decide", t.decideMs],
|
|
334
|
+
["rewrite", t.rewriteMs],
|
|
335
|
+
["total", t.totalMs],
|
|
336
|
+
]
|
|
337
|
+
.filter((entry) => typeof entry[1] === "number")
|
|
338
|
+
.map(([name, value]) => `${name} ${ms(value)}`);
|
|
339
|
+
parts.push(...timings);
|
|
340
|
+
if (result.steps.length > 0) {
|
|
341
|
+
parts.push(result.steps.join("; "));
|
|
342
|
+
}
|
|
343
|
+
return parts.join(" · ");
|
|
344
|
+
}
|
|
345
|
+
/** Which STT providers are usable right now, for an error message. */
|
|
346
|
+
async function describeConfiguredProviders() {
|
|
347
|
+
try {
|
|
348
|
+
const { registerDefaultSTTHandlers } = await import("../../voice/index.js");
|
|
349
|
+
const { STTProcessor } = await import("../../utils/sttProcessor.js");
|
|
350
|
+
registerDefaultSTTHandlers();
|
|
351
|
+
// Aliases register the same handler under several names; list each once.
|
|
352
|
+
const seen = new Set();
|
|
353
|
+
const configured = STTProcessor.listProviders().filter((name) => {
|
|
354
|
+
try {
|
|
355
|
+
const handler = STTProcessor.getHandler(name);
|
|
356
|
+
if (!handler || seen.has(handler) || !handler.isConfigured()) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
seen.add(handler);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
return configured.length > 0
|
|
367
|
+
? `Configured STT providers: ${configured.join(", ")}.`
|
|
368
|
+
: "No STT provider is configured; set NEUROLINK_STT_PROVIDER and that provider's key (see docs/features/transcribe.md).";
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return "";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function failWithProviders(error) {
|
|
375
|
+
const message = errorMessage(error);
|
|
376
|
+
// The SDK's provider errors already name what is configured.
|
|
377
|
+
const providers = /configured/i.test(message)
|
|
378
|
+
? ""
|
|
379
|
+
: await describeConfiguredProviders();
|
|
380
|
+
logger.error(chalk.red(`Error: ${message}`));
|
|
381
|
+
if (providers) {
|
|
382
|
+
logger.error(chalk.gray(providers));
|
|
383
|
+
}
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Stream mode
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
function createCliTranscribeStreamSink(argv) {
|
|
390
|
+
const format = argv.format ?? "text";
|
|
391
|
+
const tty = process.stdout.isTTY === true;
|
|
392
|
+
let liveLine = false;
|
|
393
|
+
const clearLive = () => {
|
|
394
|
+
if (liveLine) {
|
|
395
|
+
process.stdout.write("\r\x1b[2K");
|
|
396
|
+
liveLine = false;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
const live = (text) => {
|
|
400
|
+
process.stdout.write(`\r\x1b[2K${text}`);
|
|
401
|
+
liveLine = true;
|
|
402
|
+
};
|
|
403
|
+
// Subtitles need the whole run: finals keep their timing, a later
|
|
404
|
+
// `corrected` event replaces the text of the same utterance.
|
|
405
|
+
const finals = new Map();
|
|
406
|
+
const corrected = new Map();
|
|
407
|
+
return {
|
|
408
|
+
event(event) {
|
|
409
|
+
if (format === "json") {
|
|
410
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (format === "srt" || format === "vtt") {
|
|
414
|
+
if (event.type === "final") {
|
|
415
|
+
finals.set(event.utterance, {
|
|
416
|
+
...event.segment,
|
|
417
|
+
text: event.text,
|
|
418
|
+
words: event.words,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
else if (event.type === "corrected") {
|
|
422
|
+
corrected.set(event.utterance, event.text);
|
|
423
|
+
}
|
|
424
|
+
else if (event.type === "error") {
|
|
425
|
+
logger.error(chalk.red(`Error: ${event.message}`));
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
switch (event.type) {
|
|
430
|
+
case "interim":
|
|
431
|
+
if (tty) {
|
|
432
|
+
live(chalk.gray(event.committed) +
|
|
433
|
+
(event.committed && event.tail ? " " : "") +
|
|
434
|
+
event.tail);
|
|
435
|
+
}
|
|
436
|
+
else if (!argv.quiet) {
|
|
437
|
+
process.stdout.write(`[interim ${event.utterance}] ${event.text}\n`);
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
case "correcting":
|
|
441
|
+
if (tty) {
|
|
442
|
+
live(chalk.cyan(event.text));
|
|
443
|
+
}
|
|
444
|
+
break;
|
|
445
|
+
case "final":
|
|
446
|
+
clearLive();
|
|
447
|
+
process.stdout.write(`${event.text}\n`);
|
|
448
|
+
if (!argv.quiet && event.fallbackUsed) {
|
|
449
|
+
process.stdout.write(chalk.gray(` (fallback engine ${event.engine})\n`));
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
case "corrected":
|
|
453
|
+
clearLive();
|
|
454
|
+
if (event.text !== event.raw) {
|
|
455
|
+
process.stdout.write(`${chalk.cyan(" corrected:")} ${event.text}\n`);
|
|
456
|
+
}
|
|
457
|
+
if (!argv.quiet && event.steps.length > 0) {
|
|
458
|
+
process.stdout.write(chalk.gray(` ${event.steps.join("; ")}\n`));
|
|
459
|
+
}
|
|
460
|
+
break;
|
|
461
|
+
case "language":
|
|
462
|
+
if (!argv.quiet) {
|
|
463
|
+
clearLive();
|
|
464
|
+
process.stdout.write(chalk.gray(`[language ${event.language}${event.detected ? " detected" : " unsure"}]\n`));
|
|
465
|
+
}
|
|
466
|
+
break;
|
|
467
|
+
case "silence":
|
|
468
|
+
if (!argv.quiet) {
|
|
469
|
+
clearLive();
|
|
470
|
+
process.stdout.write(chalk.gray(`[utterance ${event.utterance}: ${event.seconds.toFixed(1)}s no engine could read]\n`));
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
case "error":
|
|
474
|
+
clearLive();
|
|
475
|
+
logger.error(chalk.red(`Error${event.utterance !== undefined ? ` (utterance ${event.utterance})` : ""}: ${event.message}`));
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
finish() {
|
|
480
|
+
clearLive();
|
|
481
|
+
if (format === "srt" || format === "vtt") {
|
|
482
|
+
const segments = [...finals.entries()]
|
|
483
|
+
.sort(([a], [b]) => a - b)
|
|
484
|
+
.map(([utterance, segment]) => ({
|
|
485
|
+
...segment,
|
|
486
|
+
text: corrected.get(utterance) ?? segment.text,
|
|
487
|
+
}));
|
|
488
|
+
const cues = segments.flatMap((segment) => buildCues(segment.text, [segment], segment.words, undefined));
|
|
489
|
+
process.stdout.write(renderSubtitles(cues, format));
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Command
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
export const transcribeCommand = {
|
|
498
|
+
command: "transcribe <file>",
|
|
499
|
+
describe: "Transcribe audio to text (local engine by default; dictionary, correction, fallback)",
|
|
500
|
+
builder: (yargs) => yargs
|
|
501
|
+
.positional("file", {
|
|
502
|
+
type: "string",
|
|
503
|
+
describe: "Audio file to transcribe, or - to read it from stdin",
|
|
504
|
+
})
|
|
505
|
+
.option("provider", {
|
|
506
|
+
type: "string",
|
|
507
|
+
describe: "STT provider (default: NEUROLINK_STT_PROVIDER, then the first configured, then the local engine)",
|
|
508
|
+
})
|
|
509
|
+
.option("model", {
|
|
510
|
+
type: "string",
|
|
511
|
+
describe: "Model of the STT provider (default: NEUROLINK_STT_MODEL)",
|
|
512
|
+
})
|
|
513
|
+
.option("language", {
|
|
514
|
+
type: "string",
|
|
515
|
+
describe: "Language code, or auto to let the engine detect it (default: NEUROLINK_STT_LANGUAGE, else auto)",
|
|
516
|
+
})
|
|
517
|
+
.option("diarize", {
|
|
518
|
+
type: "boolean",
|
|
519
|
+
describe: "Label speakers (providers that support diarization)",
|
|
520
|
+
})
|
|
521
|
+
.option("word-timestamps", {
|
|
522
|
+
type: "boolean",
|
|
523
|
+
describe: "Request word-level timings",
|
|
524
|
+
})
|
|
525
|
+
.option("prompt", {
|
|
526
|
+
type: "string",
|
|
527
|
+
describe: "Context prompt for engines that bias on one",
|
|
528
|
+
})
|
|
529
|
+
.option("dictionary", {
|
|
530
|
+
type: "string",
|
|
531
|
+
describe: "JSON file holding an array of { term, heardAs, meaning } entries",
|
|
532
|
+
})
|
|
533
|
+
.option("term", {
|
|
534
|
+
type: "string",
|
|
535
|
+
array: true,
|
|
536
|
+
nargs: 1,
|
|
537
|
+
describe: 'Inline dictionary entry "Term|heard,as|meaning" (repeatable)',
|
|
538
|
+
})
|
|
539
|
+
.option("correct", {
|
|
540
|
+
type: "boolean",
|
|
541
|
+
describe: "Run the correction layer (dictionary guard + LLM rewrite) on the transcript",
|
|
542
|
+
})
|
|
543
|
+
.option("rewrite-provider", {
|
|
544
|
+
type: "string",
|
|
545
|
+
describe: "Text provider for the correction rewrite",
|
|
546
|
+
})
|
|
547
|
+
.option("rewrite-model", {
|
|
548
|
+
type: "string",
|
|
549
|
+
describe: "Model for the correction rewrite",
|
|
550
|
+
})
|
|
551
|
+
.option("rewrite", {
|
|
552
|
+
type: "boolean",
|
|
553
|
+
describe: "LLM rewrite inside the correction layer (--no-rewrite keeps dictionary-only correction)",
|
|
554
|
+
})
|
|
555
|
+
.option("transliterate", {
|
|
556
|
+
type: "string",
|
|
557
|
+
choices: ["latin", "native"],
|
|
558
|
+
describe: "How Indic-script words are written: latin (as typed on phones) or native script",
|
|
559
|
+
})
|
|
560
|
+
.option("second-opinion", {
|
|
561
|
+
type: "string",
|
|
562
|
+
describe: "Second STT provider run on the same audio; the rewrite reconciles both",
|
|
563
|
+
})
|
|
564
|
+
.option("fallback", {
|
|
565
|
+
type: "string",
|
|
566
|
+
describe: "STT provider for audio the primary engine cannot read (unsure language or empty)",
|
|
567
|
+
})
|
|
568
|
+
.option("context", {
|
|
569
|
+
type: "string",
|
|
570
|
+
describe: "One sentence about who is talking and about what, for the engines and the guard",
|
|
571
|
+
})
|
|
572
|
+
.option("stream", {
|
|
573
|
+
type: "boolean",
|
|
574
|
+
describe: "Stream the file through transcribeStream (decoded to 16 kHz PCM16) and print live text",
|
|
575
|
+
})
|
|
576
|
+
.option("format", {
|
|
577
|
+
type: "string",
|
|
578
|
+
alias: ["f", "output-format"],
|
|
579
|
+
describe: "Output format",
|
|
580
|
+
choices: ["text", "json", "srt", "vtt"],
|
|
581
|
+
default: "text",
|
|
582
|
+
})
|
|
583
|
+
.option("timeout", {
|
|
584
|
+
type: "number",
|
|
585
|
+
describe: "Whole-call timeout in milliseconds",
|
|
586
|
+
})
|
|
587
|
+
.option("quiet", {
|
|
588
|
+
type: "boolean",
|
|
589
|
+
alias: "q",
|
|
590
|
+
default: false,
|
|
591
|
+
describe: "Print only the text (no trail line, spinner or notes)",
|
|
592
|
+
})
|
|
593
|
+
.option("debug", {
|
|
594
|
+
type: "boolean",
|
|
595
|
+
alias: ["v", "verbose"],
|
|
596
|
+
default: false,
|
|
597
|
+
describe: "Enable debug logging (written to stderr when --format json is used)",
|
|
598
|
+
})
|
|
599
|
+
.example("$0 transcribe meeting.wav", "Transcribe with the default engine")
|
|
600
|
+
.example('$0 transcribe call.mp3 --term "Acme|akmee,ack me|the company name" --correct', "Repair a name the engine mis-hears")
|
|
601
|
+
.example("$0 transcribe talk.wav --word-timestamps --format srt > talk.srt", "Write subtitles")
|
|
602
|
+
.example("cat clip.wav | $0 transcribe - --stream", "Stream stdin and print live text"),
|
|
603
|
+
handler: async (argv) => {
|
|
604
|
+
const format = argv.format ?? "text";
|
|
605
|
+
// --- Validate before any provider work ---------------------------------
|
|
606
|
+
// yargs turns a lone `-` positional into "", so recover it from argv.
|
|
607
|
+
const file = argv.file === "" && process.argv.includes("-") ? "-" : argv.file;
|
|
608
|
+
if (!file) {
|
|
609
|
+
fail("pass an audio file, or - to read from stdin.");
|
|
610
|
+
}
|
|
611
|
+
if (argv.timeout !== undefined &&
|
|
612
|
+
!(Number.isFinite(argv.timeout) && argv.timeout > 0)) {
|
|
613
|
+
fail("--timeout must be a positive number of milliseconds.");
|
|
614
|
+
}
|
|
615
|
+
if (argv.rewrite === false && (argv.rewriteProvider || argv.rewriteModel)) {
|
|
616
|
+
fail("--no-rewrite cannot be combined with --rewrite-provider or --rewrite-model.");
|
|
617
|
+
}
|
|
618
|
+
let dictionary;
|
|
619
|
+
try {
|
|
620
|
+
dictionary = buildDictionary(argv);
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
fail(errorMessage(error));
|
|
624
|
+
}
|
|
625
|
+
let input;
|
|
626
|
+
try {
|
|
627
|
+
input = await readAudio(file);
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
fail(`could not read audio — ${errorMessage(error)}`);
|
|
631
|
+
}
|
|
632
|
+
if (input.audio.length === 0) {
|
|
633
|
+
fail(`${input.label} is empty.`);
|
|
634
|
+
}
|
|
635
|
+
const options = buildCommonOptions(argv, dictionary);
|
|
636
|
+
const neurolink = new NeuroLink();
|
|
637
|
+
// --- Stream mode -----------------------------------------------------
|
|
638
|
+
if (argv.stream) {
|
|
639
|
+
let pcm;
|
|
640
|
+
try {
|
|
641
|
+
pcm = await decodeToPcm16(input.audio, input.format, input.label);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
fail(errorMessage(error));
|
|
645
|
+
}
|
|
646
|
+
const sink = createCliTranscribeStreamSink(argv);
|
|
647
|
+
let fatal;
|
|
648
|
+
try {
|
|
649
|
+
const events = neurolink.transcribeStream({
|
|
650
|
+
...options,
|
|
651
|
+
audio: pcmFrames(pcm),
|
|
652
|
+
streaming: { sampleRate: TARGET_SAMPLE_RATE },
|
|
653
|
+
});
|
|
654
|
+
for await (const event of events) {
|
|
655
|
+
sink.event(event);
|
|
656
|
+
if (event.type === "error" && !event.recoverable) {
|
|
657
|
+
fatal = event.message;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
sink.finish();
|
|
663
|
+
await failWithProviders(error);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
sink.finish();
|
|
667
|
+
if (fatal !== undefined) {
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
// --- Batch mode ------------------------------------------------------
|
|
673
|
+
const spinner = format === "text" && !argv.quiet && process.stderr.isTTY
|
|
674
|
+
? ora({ text: "Transcribing...", stream: process.stderr }).start()
|
|
675
|
+
: null;
|
|
676
|
+
let result;
|
|
677
|
+
try {
|
|
678
|
+
result = await neurolink.transcribe({
|
|
679
|
+
...options,
|
|
680
|
+
audio: input.audio,
|
|
681
|
+
...(input.format ? { format: input.format } : {}),
|
|
682
|
+
});
|
|
683
|
+
spinner?.stop();
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
spinner?.fail("Transcription failed");
|
|
687
|
+
await failWithProviders(error);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (format === "json") {
|
|
691
|
+
logger.always(JSON.stringify(result, null, 2));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (format === "srt" || format === "vtt") {
|
|
695
|
+
process.stdout.write(renderSubtitles(buildCues(result.text, result.segments, result.words, result.duration), format));
|
|
696
|
+
if (!argv.quiet &&
|
|
697
|
+
result.corrected !== undefined &&
|
|
698
|
+
(result.segments?.length ?? 0) > 1) {
|
|
699
|
+
logger.error(chalk.gray("Note: subtitle cues carry the engine's timed text; the corrected text is in --format text or json."));
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
logger.always(result.text);
|
|
704
|
+
if (!argv.quiet) {
|
|
705
|
+
logger.always(chalk.gray(trailLine(result)));
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
/**
|
|
710
|
+
* Create transcribe command factory for CLICommandFactory-style registration,
|
|
711
|
+
* matching the DecideCommandFactory pattern.
|
|
712
|
+
*/
|
|
713
|
+
export class TranscribeCommandFactory {
|
|
714
|
+
static createTranscribeCommand() {
|
|
715
|
+
return transcribeCommand;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
//# sourceMappingURL=transcribe.js.map
|