@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
520 lines (519 loc) • 21.4 kB
JavaScript
/**
* Batch transcription orchestration behind `neurolink.transcribe()`:
*
* load audio → resolve provider → transcribe (+ second opinion in parallel)
* → fallback → correction → TranscribeResult
*
* Everything that touches the SDK (handlers, credentials, `tryDecide`, the
* rewrite model) arrives through `TranscribeDeps`, so this module stays free
* of provider plumbing. Nothing here may lose words: a failed second opinion
* or fallback keeps the primary text, and a correction that comes back empty
* keeps the engine text.
*
* @module voice/transcribe
*/
import { promises as fs } from "fs";
import { extname } from "path";
import { fileURLToPath } from "url";
import { logger } from "../utils/logger.js";
import { SpanSerializer, SpanStatus, SpanType, getMetricsAggregator, } from "../observability/index.js";
import { detectAudioFormat } from "./audio-utils.js";
import { correctTranscript, correctTranscriptLines, isCorrectionEnabled, } from "./correction/index.js";
/** `NEUROLINK_STT_CORRECT_WHOLE=1` forces one whole-transcript rewrite even for segmented results. */
function correctionLinesDisabled() {
const v = process.env.NEUROLINK_STT_CORRECT_WHOLE?.trim().toLowerCase();
return v === "1" || v === "true";
}
import { STTError } from "./errors.js";
/** Default timeout for fetching an `http(s)://` audio input. */
const URL_FETCH_TIMEOUT_MS = 60_000;
/** Container names an extension or magic number can map to. */
const KNOWN_FORMATS = [
"mp3",
"wav",
"ogg",
"opus",
"m4a",
"flac",
"webm",
"mp4",
"mpeg",
"mpga",
];
// ============================================================================
// AUDIO INPUT
// ============================================================================
/** True for an async stream of frames, which only `transcribeStream()` takes. */
function isAsyncIterableInput(input) {
return (typeof input === "object" &&
input !== null &&
!Buffer.isBuffer(input) &&
!(input instanceof ArrayBuffer) &&
!(input instanceof Uint8Array) &&
Symbol.asyncIterator in input);
}
/** The container a path or URL names by its extension, if it is a known one. */
export function audioFormatFromName(name) {
const ext = extname(name.split(/[?#]/, 1)[0] ?? name)
.slice(1)
.toLowerCase();
const normalized = ext === "oga" ? "ogg" : ext;
return KNOWN_FORMATS.find((format) => format === normalized);
}
/**
* The container a buffer's leading bytes identify: WAV, MP3 and Ogg via the
* shared detector, plus FLAC, WebM/Matroska and ISO-BMFF (m4a/mp4).
*/
export function audioFormatFromBytes(buffer) {
const detected = detectAudioFormat(buffer);
if (detected) {
return detected;
}
if (buffer.length >= 4 &&
buffer.subarray(0, 4).toString("ascii") === "fLaC") {
return "flac";
}
if (buffer.length >= 4 &&
buffer[0] === 0x1a &&
buffer[1] === 0x45 &&
buffer[2] === 0xdf &&
buffer[3] === 0xa3) {
return "webm";
}
if (buffer.length >= 8 &&
buffer.subarray(4, 8).toString("ascii") === "ftyp") {
return "m4a";
}
return undefined;
}
/**
* Read a batch audio input into a Buffer: bytes as they are, a local path or
* `file://` URL from disk, an `http(s)://` URL over the network. A format
* implied by the path or URL is returned alongside.
*
* @throws STTError when the input is a frame stream, or cannot be read
*/
export async function loadTranscribeAudio(input, timeoutMs) {
if (Buffer.isBuffer(input)) {
return { buffer: input };
}
if (input instanceof ArrayBuffer) {
return { buffer: Buffer.from(input) };
}
if (input instanceof Uint8Array) {
return {
buffer: Buffer.from(input.buffer, input.byteOffset, input.byteLength),
};
}
if (typeof input !== "string") {
if (isAsyncIterableInput(input)) {
throw STTError.streamInputNotSupported();
}
throw STTError.audioLoadFailed("expected a Buffer, ArrayBuffer, Uint8Array, path or URL");
}
const source = input.trim();
if (/^https?:\/\//i.test(source)) {
const controller = new AbortController();
const limit = timeoutMs ?? URL_FETCH_TIMEOUT_MS;
const timer = setTimeout(() => controller.abort(), limit);
try {
const response = await fetch(source, { signal: controller.signal });
if (!response.ok) {
throw STTError.audioLoadFailed(`fetching the audio URL returned HTTP ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
return {
buffer,
formatHint: audioFormatFromName(new URL(source).pathname),
};
}
catch (error) {
if (error instanceof STTError) {
throw error;
}
const aborted = error instanceof Error && error.name === "AbortError";
throw STTError.audioLoadFailed(aborted
? `fetching the audio URL timed out after ${limit}ms`
: `fetching the audio URL failed: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
finally {
clearTimeout(timer);
}
}
let path;
try {
path = /^file:/i.test(source) ? fileURLToPath(source) : source;
}
catch (error) {
throw STTError.audioLoadFailed("the file: URL is not a valid local path", error instanceof Error ? error : undefined);
}
try {
return {
buffer: await fs.readFile(path),
formatHint: audioFormatFromName(path),
};
}
catch (error) {
const code = error?.code;
throw STTError.audioLoadFailed(code === "ENOENT"
? "no file at the given path"
: `reading the file failed${code ? ` (${code})` : ""}`, error instanceof Error ? error : undefined);
}
}
// ============================================================================
// OPTIONS
// ============================================================================
/** `undefined` for "let the engine detect" (omitted, empty or "auto"). */
function normalizeLanguage(language) {
const trimmed = language?.trim();
return trimmed && trimmed.toLowerCase() !== "auto" ? trimmed : undefined;
}
/** Dictionary terms merged into the caller's vocabulary, without duplicates. */
function mergeVocabulary(vocabulary, dictionary) {
const seen = new Set();
const out = [];
for (const term of [
...(vocabulary ?? []),
...(dictionary ?? []).map((entry) => entry.term),
]) {
const trimmed = typeof term === "string" ? term.trim() : "";
if (trimmed && !seen.has(trimmed.toLowerCase())) {
seen.add(trimmed.toLowerCase());
out.push(trimmed);
}
}
return out.length > 0 ? out : undefined;
}
/**
* The handler options for one engine. Fields only the orchestration reads
* (dictionary, correction, fallback, streaming, credentials) are dropped;
* the correction context rides along in the prompt for engines that take one.
*/
export function buildEngineOptions(options, format, model) {
const { provider: _provider, model: _model, language, format: _format, prompt, dictionary, correction, fallback: _fallback, streaming: _streaming, credentials: _credentials, vocabulary, ...rest } = options;
const contextPrompt = [prompt?.trim(), correction?.context?.trim()]
.filter((part) => !!part)
.join(" ");
const engineOptions = { ...rest };
const resolvedLanguage = normalizeLanguage(language);
if (resolvedLanguage) {
engineOptions.language = resolvedLanguage;
}
if (format) {
engineOptions.format = format;
}
if (model) {
engineOptions.model = model;
}
if (contextPrompt) {
engineOptions.prompt = contextPrompt;
}
const mergedVocabulary = mergeVocabulary(vocabulary, dictionary);
if (mergedVocabulary) {
engineOptions.vocabulary = mergedVocabulary;
}
return engineOptions;
}
/** Whether the correction layer runs — the one rule shared with the correction layer and the stream. */
function correctionWanted(options) {
return isCorrectionEnabled(options.dictionary, options.correction);
}
// ============================================================================
// RESULT HELPERS
// ============================================================================
/** `metadata.languageDetected`, when the engine reported one. */
function readLanguageDetected(result) {
const value = result.metadata?.languageDetected;
return typeof value === "boolean" ? value : undefined;
}
/** `metadata.languageScores`, keeping only well-formed entries. */
function readLanguageScores(result) {
const value = result.metadata?.languageScores;
if (!Array.isArray(value)) {
return undefined;
}
const scores = [];
for (const entry of value) {
if (typeof entry === "object" &&
entry !== null &&
"language" in entry &&
"score" in entry &&
typeof entry.language === "string" &&
typeof entry.score === "number" &&
Number.isFinite(entry.score)) {
scores.push({ language: entry.language, score: entry.score });
}
}
return scores.length > 0 ? scores : undefined;
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
/** The model an engine reported, else the one that was asked for. */
function reportedModel(result, requested) {
const model = result.metadata?.model;
return typeof model === "string" ? model : requested;
}
// ============================================================================
// ORCHESTRATION
// ============================================================================
/**
* Transcribe one recording with every configured layer. See the module
* comment for the order of operations.
*
* @throws STTError when the audio cannot be loaded, the provider is unknown,
* or the primary engine fails with no fallback that covers errors
*/
export async function runTranscribe(options, deps) {
const startedAt = Date.now();
const span = SpanSerializer.createSpan(SpanType.STT, "stt.transcribe", {
"stt.operation": "transcribe",
"stt.provider": options.provider,
"stt.language": options.language,
"stt.format": options.format,
});
try {
const result = await transcribeLayers(options, deps, startedAt);
const endedSpan = SpanSerializer.endSpan(SpanSerializer.updateAttributes(span, {
"stt.provider": result.engine.provider,
"stt.fallback_used": result.engine.fallbackUsed === true,
"stt.corrected": result.corrected !== undefined,
}), SpanStatus.OK);
getMetricsAggregator().recordSpan(endedSpan);
return result;
}
catch (error) {
const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR, errorMessage(error));
getMetricsAggregator().recordSpan(endedSpan);
throw error;
}
}
async function transcribeLayers(options, deps, startedAt) {
const steps = [];
// 1. Audio, and its container when the caller did not name one.
const { buffer, formatHint } = await loadTranscribeAudio(options.audio, options.timeoutMs);
if (buffer.length === 0) {
throw STTError.audioEmpty(options.provider);
}
const format = options.format ?? formatHint ?? audioFormatFromBytes(buffer);
// 2. Engines. A model from the environment only applies to the provider
// the environment picked; an explicit provider gets only an explicit model.
const explicitProvider = !!options.provider?.trim();
const provider = deps.resolveProvider(options.provider);
const envModel = process.env.NEUROLINK_STT_MODEL?.trim() || undefined;
const primaryModel = options.model ?? (explicitProvider ? undefined : envModel);
const engineBase = {
...options,
language: options.language ?? process.env.NEUROLINK_STT_LANGUAGE,
};
const optionsFor = (model) => buildEngineOptions(engineBase, format, model);
// 3. Primary and second opinion, in parallel.
const secondOpinionSpec = options.correction?.secondOpinion;
const secondProvider = secondOpinionSpec
? deps.resolveProvider(secondOpinionSpec.provider)
: undefined;
const secondStarted = Date.now();
const secondPromise = secondProvider && secondOpinionSpec
? deps
.transcribe(buffer, secondProvider, optionsFor(secondOpinionSpec.model))
.then((result) => ({ result, ms: Date.now() - secondStarted }), (error) => ({ error, ms: Date.now() - secondStarted }))
: null;
const primaryStarted = Date.now();
const fallback = options.fallback;
const fallbackWhen = new Set(fallback?.when ?? ["unsure", "empty"]);
const fallbackProvider = fallback
? deps.resolveProvider(fallback.provider)
: undefined;
let primary;
let primaryError;
try {
primary = await deps.transcribe(buffer, provider, optionsFor(primaryModel));
}
catch (error) {
primaryError = error;
}
// 4. Fallback: on error, on an empty transcript, or when the engine said
// it could not identify the language — whichever `when` asks for.
let chosen = primary;
let chosenProvider = provider;
let chosenModel = primaryModel;
let fallbackUsed = false;
const fallbackReason = primaryError !== undefined
? fallbackWhen.has("error")
? "error"
: undefined
: primary && !primary.text.trim() && fallbackWhen.has("empty")
? "empty"
: primary &&
readLanguageDetected(primary) === false &&
fallbackWhen.has("unsure")
? "unsure"
: undefined;
if (fallback && fallbackProvider && fallbackReason) {
if (fallbackProvider === provider && fallback.model === primaryModel) {
steps.push(`fallback skipped · same engine as primary`);
}
else {
try {
const fallbackResult = await deps.transcribe(buffer, fallbackProvider, optionsFor(fallback.model));
if (fallbackResult.text.trim() || !primary?.text.trim()) {
chosen = fallbackResult;
chosenProvider = fallbackProvider;
chosenModel = fallback.model;
fallbackUsed = true;
steps.push(`fallback ${fallbackProvider} (${fallbackReason})`);
}
else {
steps.push(`fallback ${fallbackProvider} empty · kept primary`);
}
}
catch (error) {
steps.push(`fallback ${fallbackProvider} failed · kept primary`);
if (logger.shouldLog("debug")) {
logger.debug(`[transcribe] fallback ${fallbackProvider} failed: ${errorMessage(error)}`);
}
}
}
}
if (!chosen) {
// Primary failed and nothing replaced it. Let the second opinion settle
// first so its request is not left running unobserved.
await secondPromise;
throw primaryError instanceof Error
? primaryError
: STTError.transcriptionFailed(errorMessage(primaryError), provider);
}
const transcribeMs = Date.now() - primaryStarted;
steps.unshift(`transcribed ${chosenProvider}`);
// 5. Second opinion.
let secondText;
let secondOpinionMs;
if (secondPromise && secondProvider) {
const settled = await secondPromise;
secondOpinionMs = settled.ms;
if (fallbackUsed && secondProvider === chosenProvider) {
// The fallback already produced the text with this very engine; a
// "second opinion" from it would be the same transcript twice.
steps.push(`second opinion ${secondProvider} skipped · it is the fallback`);
}
else if ("result" in settled) {
secondText = settled.result.text.trim() || undefined;
steps.push(secondText
? `second opinion ${secondProvider}`
: `second opinion ${secondProvider} empty`);
}
else {
steps.push(`second opinion ${secondProvider} failed`);
if (logger.shouldLog("debug")) {
logger.debug(`[transcribe] second opinion ${secondProvider} failed: ${errorMessage(settled.error)}`);
}
}
}
// 6. Correction. The primary text leads unless the primary engine said it
// could not identify the language and a second opinion exists.
const languageDetected = readLanguageDetected(chosen);
const languageScores = readLanguageScores(chosen);
const engineText = chosen.text;
const secondLeads = languageDetected === false && !!secondText;
const raw = secondLeads && secondText ? secondText : engineText;
let finalText = raw;
let decisions;
const correctionTimings = {};
// A multi-segment transcript (diarized turns, long files) is corrected
// segment by segment so speakers and timings stay aligned with the text;
// a second opinion is whole-transcript and keeps the single-text path.
const segmentTexts = chosen.segments?.map((seg) => seg.text) ?? [];
const perSegment = segmentTexts.length > 1 && !secondText && !correctionLinesDisabled();
let correctedSegments;
if (correctionWanted(options) && perSegment && chosen.segments) {
try {
const corrected = await correctTranscriptLines(segmentTexts, options.dictionary, options.correction, deps);
decisions = corrected.decisions;
steps.push(...corrected.steps);
Object.assign(correctionTimings, corrected.timings);
correctedSegments = chosen.segments.map((seg, i) => ({
...seg,
text: corrected.lines[i]?.trim() || seg.text,
}));
const joined = correctedSegments
.map((seg) => seg.text)
.filter(Boolean)
.join(" ");
if (joined.trim()) {
finalText = joined;
}
else {
steps.push("correction returned nothing · kept text");
}
}
catch (error) {
steps.push("correction failed · kept text");
correctedSegments = undefined;
if (logger.shouldLog("debug")) {
logger.debug(`[transcribe] correction failed: ${errorMessage(error)}`);
}
}
}
else if (correctionWanted(options)) {
try {
const corrected = await correctTranscript({
text: engineText,
...(secondText ? { secondOpinion: secondText } : {}),
...(options.dictionary ? { dictionary: options.dictionary } : {}),
...(options.correction ? { options: options.correction } : {}),
...(languageDetected !== undefined ? { languageDetected } : {}),
}, deps);
decisions = corrected.decisions;
steps.push(...corrected.steps);
Object.assign(correctionTimings, corrected.timings);
if (corrected.text.trim()) {
finalText = corrected.text;
}
else {
steps.push("correction returned nothing · kept text");
}
}
catch (error) {
steps.push("correction failed · kept text");
if (logger.shouldLog("debug")) {
logger.debug(`[transcribe] correction failed: ${errorMessage(error)}`);
}
}
}
else if (secondLeads) {
steps.push("second opinion leads (primary language unsure)");
}
// 7. Result.
const { text: _engineText, ...chosenRest } = chosen;
const result = {
...chosenRest,
...(correctedSegments ? { segments: correctedSegments } : {}),
text: finalText,
raw,
...(finalText !== raw ? { corrected: finalText } : {}),
...(languageDetected !== undefined ? { languageDetected } : {}),
...(languageScores ? { languageScores } : {}),
...(decisions && decisions.length > 0 ? { decisions } : {}),
engine: {
provider: chosenProvider,
...(reportedModel(chosen, chosenModel)
? { model: reportedModel(chosen, chosenModel) }
: {}),
...(fallbackUsed ? { fallbackUsed: true } : {}),
...(secondText && secondProvider
? { secondOpinion: secondProvider }
: {}),
},
timings: {
transcribeMs,
...(secondOpinionMs !== undefined ? { secondOpinionMs } : {}),
...correctionTimings,
totalMs: Date.now() - startedAt,
},
steps,
};
if (logger.shouldLog("debug")) {
logger.debug(`[transcribe] ${chosenProvider}: ${raw.length} chars raw, ${finalText.length} final in ${result.timings.totalMs}ms`, { steps });
}
return result;
}