UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

718 lines • 27.7 kB
#!/usr/bin/env node /** * NeuroLink CLI Transcribe Command * * Calls `NeuroLink.transcribe()` — audio in, text out — or, with `--stream`, * `NeuroLink.transcribeStream()` over the file decoded to 16 kHz mono PCM16. * See src/lib/types/transcribe.ts for the full vocabulary (dictionary, * correction, second opinion, fallback). * * Credentials are env-only here, exactly as every other CLI command: each STT * handler reads its own settings, and `NEUROLINK_STT_PROVIDER`, * `NEUROLINK_STT_MODEL` and `NEUROLINK_STT_LANGUAGE` pick the defaults. With * nothing configured the built-in local engine (Whistle) is used. */ import chalk from "chalk"; import ora from "ora"; import fs from "node:fs"; import { NeuroLink } from "../../neurolink.js"; import { logger } from "../../utils/logger.js"; const TARGET_SAMPLE_RATE = 16000; /** 100 ms of 16 kHz mono PCM16. */ const STREAM_FRAME_BYTES = (TARGET_SAMPLE_RATE / 10) * 2; function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function errorMessage(error) { return error instanceof Error ? error.message : String(error); } function fail(message) { logger.error(chalk.red(`Error: ${message}`)); process.exit(1); } // --------------------------------------------------------------------------- // Input // --------------------------------------------------------------------------- async function readStdin() { const chunks = []; for await (const chunk of process.stdin) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks); } async function readAudio(file) { const { audioFormatFromBytes, audioFormatFromName } = await import("../../voice/transcribe.js"); if (file === "-") { if (process.stdin.isTTY) { throw new Error("`-` reads audio from stdin, but stdin is a terminal — pipe a file in."); } const audio = await readStdin(); return { audio, format: audioFormatFromBytes(audio), label: "stdin" }; } const audio = await fs.promises.readFile(file); const format = audioFormatFromName(file) ?? audioFormatFromBytes(audio); return { audio, format, label: file }; } // --------------------------------------------------------------------------- // Dictionary // --------------------------------------------------------------------------- function toDictionaryEntry(value, where) { if (!isRecord(value) || typeof value.term !== "string" || !value.term.trim()) { throw new Error(`${where}: every entry needs a non-empty "term" string.`); } const entry = { term: value.term.trim() }; if (value.heardAs !== undefined) { if (!Array.isArray(value.heardAs) || !value.heardAs.every((h) => typeof h === "string")) { throw new Error(`${where}: "heardAs" must be an array of strings.`); } const heardAs = value.heardAs.map((h) => h.trim()).filter(Boolean); if (heardAs.length > 0) { entry.heardAs = heardAs; } } if (value.meaning !== undefined) { if (typeof value.meaning !== "string") { throw new Error(`${where}: "meaning" must be a string.`); } if (value.meaning.trim()) { entry.meaning = value.meaning.trim(); } } return entry; } /** `"Term|heard,as|meaning"` — only the term is required. */ function parseTermFlag(raw) { const [term = "", heard = "", ...meaningParts] = raw.split("|"); const meaning = meaningParts.join("|").trim(); if (!term.trim()) { throw new Error(`--term "${raw}" has no term; use "Term|heard,as|meaning".`); } const heardAs = heard .split(",") .map((h) => h.trim()) .filter(Boolean); return { term: term.trim(), ...(heardAs.length > 0 ? { heardAs } : {}), ...(meaning ? { meaning } : {}), }; } function buildDictionary(argv) { const entries = []; if (argv.dictionary) { let parsed; try { parsed = JSON.parse(fs.readFileSync(argv.dictionary, "utf-8")); } catch (error) { throw new Error(`could not read --dictionary ${argv.dictionary} — ${errorMessage(error)}`, { cause: error }); } if (!Array.isArray(parsed)) { throw new Error("--dictionary must hold a JSON array of { term, heardAs, meaning }."); } parsed.forEach((value, i) => entries.push(toDictionaryEntry(value, `--dictionary entry ${i}`))); } for (const raw of [].concat(argv.term ?? [])) { entries.push(parseTermFlag(raw)); } return entries.length > 0 ? entries : undefined; } // --------------------------------------------------------------------------- // Options // --------------------------------------------------------------------------- function buildCorrection(argv, hasDictionary) { const correction = {}; // A rewrite model, a transliteration choice or a second opinion only means // something inside the correction layer, so each one turns it on. const implied = Boolean(argv.rewriteProvider || argv.rewriteModel || argv.transliterate || argv.secondOpinion); if (argv.correct || implied) { correction.enabled = true; } if (argv.rewrite === false) { correction.rewrite = false; } else if (argv.rewriteProvider || argv.rewriteModel) { correction.rewrite = { ...(argv.rewriteProvider ? { provider: argv.rewriteProvider } : {}), ...(argv.rewriteModel ? { model: argv.rewriteModel } : {}), }; } if (argv.transliterate) { correction.transliterate = argv.transliterate; } if (argv.secondOpinion) { correction.secondOpinion = { provider: argv.secondOpinion }; } if (argv.context) { correction.context = argv.context; } if (Object.keys(correction).length === 0) { return undefined; } // The SDK reads any correction object as a request for correction. Here // only --correct, the flags above or a dictionary ask for it; --context or // --no-rewrite alone must not start an LLM rewrite. if (correction.enabled === undefined && !hasDictionary) { correction.enabled = false; } return correction; } function buildCommonOptions(argv, dictionary) { const correction = buildCorrection(argv, Boolean(dictionary)); return { ...(argv.provider ? { provider: argv.provider } : {}), ...(argv.model ? { model: argv.model } : {}), // Omitted → NEUROLINK_STT_LANGUAGE, else the engine detects it. ...(argv.language ? { language: argv.language } : {}), ...(argv.diarize ? { diarization: true } : {}), ...(argv.wordTimestamps ? { wordTimestamps: true } : {}), ...(argv.prompt ? { prompt: argv.prompt } : {}), ...(dictionary ? { dictionary } : {}), ...(correction ? { correction } : {}), ...(argv.fallback ? { fallback: { provider: argv.fallback } } : {}), ...(argv.timeout !== undefined ? { timeoutMs: argv.timeout } : {}), }; } // --------------------------------------------------------------------------- // Decoding for --stream // --------------------------------------------------------------------------- /** * The file as 16 kHz mono PCM16LE, decoded exactly as the local engine decodes * its input: WAV of any layout in-process, raw `pcm16` taken as 16 kHz, every * other container through ffmpeg (`FFMPEG_PATH`, `ffmpeg-static`, or `ffmpeg` * on PATH) — and a clear error when there is none. */ async function decodeToPcm16(audio, format, label) { const { decodeToWhistlePcm } = await import("../../voice/whistle/audio.js"); let samples; try { samples = await decodeToWhistlePcm(audio, format, TARGET_SAMPLE_RATE); } catch (error) { throw new Error(`--stream needs 16 kHz PCM and ${label} could not be decoded — ${errorMessage(error)}`, { cause: error }); } const pcm = Buffer.alloc(samples.length * 2); for (let i = 0; i < samples.length; i++) { pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), i * 2); } return pcm; } async function* pcmFrames(pcm) { for (let at = 0; at < pcm.length; at += STREAM_FRAME_BYTES) { yield pcm.subarray(at, Math.min(pcm.length, at + STREAM_FRAME_BYTES)); // Let the consumer's timers and I/O run between frames. await new Promise((resolve) => setImmediate(resolve)); } } // --------------------------------------------------------------------------- // Subtitles // --------------------------------------------------------------------------- function startOf(item) { return item.start ?? item.startTime; } function endOf(item) { return item.end ?? item.endTime; } /** Words grouped into readable cues: at most ~7 words or 4 s, broken at sentence ends. */ function cuesFromWords(words) { const cues = []; let current; let count = 0; for (const word of words) { const start = startOf(word); const end = endOf(word) ?? start; if (start === undefined || end === undefined) { continue; } if (!current) { current = { start, end, text: word.word.trim() }; count = 1; } else { current.text += ` ${word.word.trim()}`; current.end = end; count++; } if (count >= 7 || current.end - current.start >= 4 || /[.!?]$/.test(word.word.trim())) { cues.push(current); current = undefined; } } if (current) { cues.push(current); } return cues; } function buildCues(text, segments, words, duration) { const timed = (segments ?? []).filter((s) => startOf(s) !== undefined && endOf(s) !== undefined && s.text.trim()); if (timed.length === 1) { // One segment: it can carry the corrected text without losing alignment. return [{ start: startOf(timed[0]) ?? 0, end: endOf(timed[0]) ?? 0, text }]; } if (timed.length > 1) { return timed.map((s) => ({ start: startOf(s) ?? 0, end: endOf(s) ?? 0, text: s.text.trim(), })); } const fromWords = words ? cuesFromWords(words) : []; if (fromWords.length > 0) { return fromWords; } return text.trim() ? [{ start: 0, end: duration ?? 0, text: text.trim() }] : []; } function timestamp(seconds, separator) { const ms = Math.max(0, Math.round(seconds * 1000)); const h = Math.floor(ms / 3_600_000); const m = Math.floor((ms % 3_600_000) / 60_000); const s = Math.floor((ms % 60_000) / 1000); const pad = (n, w = 2) => String(n).padStart(w, "0"); return `${pad(h)}:${pad(m)}:${pad(s)}${separator}${pad(ms % 1000, 3)}`; } function renderSubtitles(cues, format) { const sep = format === "srt" ? "," : "."; const body = cues .map((cue, i) => { const end = Math.max(cue.end, cue.start + 0.5); const range = `${timestamp(cue.start, sep)} --> ${timestamp(end, sep)}`; return format === "srt" ? `${i + 1}\n${range}\n${cue.text}` : `${range}\n${cue.text}`; }) .join("\n\n"); return format === "srt" ? `${body}\n` : `WEBVTT\n\n${body}\n`; } // --------------------------------------------------------------------------- // Human output // --------------------------------------------------------------------------- function ms(value) { if (value === undefined) { return undefined; } return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${Math.round(value)}ms`; } function trailLine(result) { const parts = []; const engine = result.engine.model ? `${result.engine.provider}/${result.engine.model}` : result.engine.provider; parts.push(result.engine.fallbackUsed ? `engine ${engine} (fallback)` : `engine ${engine}`); if (result.engine.secondOpinion) { parts.push(`second opinion ${result.engine.secondOpinion}`); } if (result.language) { const how = result.languageDetected === false ? " (unsure)" : result.languageDetected === true ? " (detected)" : ""; parts.push(`language ${result.language}${how}`); } if (result.duration !== undefined) { parts.push(`audio ${result.duration.toFixed(1)}s`); } const t = result.timings; const timings = [ ["transcribe", t.transcribeMs], ["second opinion", t.secondOpinionMs], ["decide", t.decideMs], ["rewrite", t.rewriteMs], ["total", t.totalMs], ] .filter((entry) => typeof entry[1] === "number") .map(([name, value]) => `${name} ${ms(value)}`); parts.push(...timings); if (result.steps.length > 0) { parts.push(result.steps.join("; ")); } return parts.join(" · "); } /** Which STT providers are usable right now, for an error message. */ async function describeConfiguredProviders() { try { const { registerDefaultSTTHandlers } = await import("../../voice/index.js"); const { STTProcessor } = await import("../../utils/sttProcessor.js"); registerDefaultSTTHandlers(); // Aliases register the same handler under several names; list each once. const seen = new Set(); const configured = STTProcessor.listProviders().filter((name) => { try { const handler = STTProcessor.getHandler(name); if (!handler || seen.has(handler) || !handler.isConfigured()) { return false; } seen.add(handler); return true; } catch { return false; } }); return configured.length > 0 ? `Configured STT providers: ${configured.join(", ")}.` : "No STT provider is configured; set NEUROLINK_STT_PROVIDER and that provider's key (see docs/features/transcribe.md)."; } catch { return ""; } } async function failWithProviders(error) { const message = errorMessage(error); // The SDK's provider errors already name what is configured. const providers = /configured/i.test(message) ? "" : await describeConfiguredProviders(); logger.error(chalk.red(`Error: ${message}`)); if (providers) { logger.error(chalk.gray(providers)); } process.exit(1); } // --------------------------------------------------------------------------- // Stream mode // --------------------------------------------------------------------------- function createCliTranscribeStreamSink(argv) { const format = argv.format ?? "text"; const tty = process.stdout.isTTY === true; let liveLine = false; const clearLive = () => { if (liveLine) { process.stdout.write("\r\x1b[2K"); liveLine = false; } }; const live = (text) => { process.stdout.write(`\r\x1b[2K${text}`); liveLine = true; }; // Subtitles need the whole run: finals keep their timing, a later // `corrected` event replaces the text of the same utterance. const finals = new Map(); const corrected = new Map(); return { event(event) { if (format === "json") { process.stdout.write(`${JSON.stringify(event)}\n`); return; } if (format === "srt" || format === "vtt") { if (event.type === "final") { finals.set(event.utterance, { ...event.segment, text: event.text, words: event.words, }); } else if (event.type === "corrected") { corrected.set(event.utterance, event.text); } else if (event.type === "error") { logger.error(chalk.red(`Error: ${event.message}`)); } return; } switch (event.type) { case "interim": if (tty) { live(chalk.gray(event.committed) + (event.committed && event.tail ? " " : "") + event.tail); } else if (!argv.quiet) { process.stdout.write(`[interim ${event.utterance}] ${event.text}\n`); } break; case "correcting": if (tty) { live(chalk.cyan(event.text)); } break; case "final": clearLive(); process.stdout.write(`${event.text}\n`); if (!argv.quiet && event.fallbackUsed) { process.stdout.write(chalk.gray(` (fallback engine ${event.engine})\n`)); } break; case "corrected": clearLive(); if (event.text !== event.raw) { process.stdout.write(`${chalk.cyan(" corrected:")} ${event.text}\n`); } if (!argv.quiet && event.steps.length > 0) { process.stdout.write(chalk.gray(` ${event.steps.join("; ")}\n`)); } break; case "language": if (!argv.quiet) { clearLive(); process.stdout.write(chalk.gray(`[language ${event.language}${event.detected ? " detected" : " unsure"}]\n`)); } break; case "silence": if (!argv.quiet) { clearLive(); process.stdout.write(chalk.gray(`[utterance ${event.utterance}: ${event.seconds.toFixed(1)}s no engine could read]\n`)); } break; case "error": clearLive(); logger.error(chalk.red(`Error${event.utterance !== undefined ? ` (utterance ${event.utterance})` : ""}: ${event.message}`)); break; } }, finish() { clearLive(); if (format === "srt" || format === "vtt") { const segments = [...finals.entries()] .sort(([a], [b]) => a - b) .map(([utterance, segment]) => ({ ...segment, text: corrected.get(utterance) ?? segment.text, })); const cues = segments.flatMap((segment) => buildCues(segment.text, [segment], segment.words, undefined)); process.stdout.write(renderSubtitles(cues, format)); } }, }; } // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- export const transcribeCommand = { command: "transcribe <file>", describe: "Transcribe audio to text (local engine by default; dictionary, correction, fallback)", builder: (yargs) => yargs .positional("file", { type: "string", describe: "Audio file to transcribe, or - to read it from stdin", }) .option("provider", { type: "string", describe: "STT provider (default: NEUROLINK_STT_PROVIDER, then the first configured, then the local engine)", }) .option("model", { type: "string", describe: "Model of the STT provider (default: NEUROLINK_STT_MODEL)", }) .option("language", { type: "string", describe: "Language code, or auto to let the engine detect it (default: NEUROLINK_STT_LANGUAGE, else auto)", }) .option("diarize", { type: "boolean", describe: "Label speakers (providers that support diarization)", }) .option("word-timestamps", { type: "boolean", describe: "Request word-level timings", }) .option("prompt", { type: "string", describe: "Context prompt for engines that bias on one", }) .option("dictionary", { type: "string", describe: "JSON file holding an array of { term, heardAs, meaning } entries", }) .option("term", { type: "string", array: true, nargs: 1, describe: 'Inline dictionary entry "Term|heard,as|meaning" (repeatable)', }) .option("correct", { type: "boolean", describe: "Run the correction layer (dictionary guard + LLM rewrite) on the transcript", }) .option("rewrite-provider", { type: "string", describe: "Text provider for the correction rewrite", }) .option("rewrite-model", { type: "string", describe: "Model for the correction rewrite", }) .option("rewrite", { type: "boolean", describe: "LLM rewrite inside the correction layer (--no-rewrite keeps dictionary-only correction)", }) .option("transliterate", { type: "string", choices: ["latin", "native"], describe: "How Indic-script words are written: latin (as typed on phones) or native script", }) .option("second-opinion", { type: "string", describe: "Second STT provider run on the same audio; the rewrite reconciles both", }) .option("fallback", { type: "string", describe: "STT provider for audio the primary engine cannot read (unsure language or empty)", }) .option("context", { type: "string", describe: "One sentence about who is talking and about what, for the engines and the guard", }) .option("stream", { type: "boolean", describe: "Stream the file through transcribeStream (decoded to 16 kHz PCM16) and print live text", }) .option("format", { type: "string", alias: ["f", "output-format"], describe: "Output format", choices: ["text", "json", "srt", "vtt"], default: "text", }) .option("timeout", { type: "number", describe: "Whole-call timeout in milliseconds", }) .option("quiet", { type: "boolean", alias: "q", default: false, describe: "Print only the text (no trail line, spinner or notes)", }) .option("debug", { type: "boolean", alias: ["v", "verbose"], default: false, describe: "Enable debug logging (written to stderr when --format json is used)", }) .example("$0 transcribe meeting.wav", "Transcribe with the default engine") .example('$0 transcribe call.mp3 --term "Acme|akmee,ack me|the company name" --correct', "Repair a name the engine mis-hears") .example("$0 transcribe talk.wav --word-timestamps --format srt > talk.srt", "Write subtitles") .example("cat clip.wav | $0 transcribe - --stream", "Stream stdin and print live text"), handler: async (argv) => { const format = argv.format ?? "text"; // --- Validate before any provider work --------------------------------- // yargs turns a lone `-` positional into "", so recover it from argv. const file = argv.file === "" && process.argv.includes("-") ? "-" : argv.file; if (!file) { fail("pass an audio file, or - to read from stdin."); } if (argv.timeout !== undefined && !(Number.isFinite(argv.timeout) && argv.timeout > 0)) { fail("--timeout must be a positive number of milliseconds."); } if (argv.rewrite === false && (argv.rewriteProvider || argv.rewriteModel)) { fail("--no-rewrite cannot be combined with --rewrite-provider or --rewrite-model."); } let dictionary; try { dictionary = buildDictionary(argv); } catch (error) { fail(errorMessage(error)); } let input; try { input = await readAudio(file); } catch (error) { fail(`could not read audio — ${errorMessage(error)}`); } if (input.audio.length === 0) { fail(`${input.label} is empty.`); } const options = buildCommonOptions(argv, dictionary); const neurolink = new NeuroLink(); // --- Stream mode ----------------------------------------------------- if (argv.stream) { let pcm; try { pcm = await decodeToPcm16(input.audio, input.format, input.label); } catch (error) { fail(errorMessage(error)); } const sink = createCliTranscribeStreamSink(argv); let fatal; try { const events = neurolink.transcribeStream({ ...options, audio: pcmFrames(pcm), streaming: { sampleRate: TARGET_SAMPLE_RATE }, }); for await (const event of events) { sink.event(event); if (event.type === "error" && !event.recoverable) { fatal = event.message; } } } catch (error) { sink.finish(); await failWithProviders(error); return; } sink.finish(); if (fatal !== undefined) { process.exit(1); } return; } // --- Batch mode ------------------------------------------------------ const spinner = format === "text" && !argv.quiet && process.stderr.isTTY ? ora({ text: "Transcribing...", stream: process.stderr }).start() : null; let result; try { result = await neurolink.transcribe({ ...options, audio: input.audio, ...(input.format ? { format: input.format } : {}), }); spinner?.stop(); } catch (error) { spinner?.fail("Transcription failed"); await failWithProviders(error); return; } if (format === "json") { logger.always(JSON.stringify(result, null, 2)); return; } if (format === "srt" || format === "vtt") { process.stdout.write(renderSubtitles(buildCues(result.text, result.segments, result.words, result.duration), format)); if (!argv.quiet && result.corrected !== undefined && (result.segments?.length ?? 0) > 1) { logger.error(chalk.gray("Note: subtitle cues carry the engine's timed text; the corrected text is in --format text or json.")); } return; } logger.always(result.text); if (!argv.quiet) { logger.always(chalk.gray(trailLine(result))); } }, }; /** * Create transcribe command factory for CLICommandFactory-style registration, * matching the DecideCommandFactory pattern. */ export class TranscribeCommandFactory { static createTranscribeCommand() { return transcribeCommand; } } //# sourceMappingURL=transcribe.js.map