import { existsSync, readFileSync } from "node:fs"; import type { TrxConfig } from "../utils/config.ts"; import { spawnOrThrow, spawnStreaming } from "../utils/spawn.ts"; export interface WhisperProgress { percent: number; } export interface WhisperResult { srtPath: string; txtPath: string; text: string; } export function buildWhisperArgs( config: WhisperConfig, wavPath: string, language: string, prompt?: string | null, ): string[] { const args = [ "whisper-cli", "-m", config.modelPath, "-f", wavPath, "-t", String(config.threads), "--max-len", config.wordTimestamps ? "1" : "0", "--output-srt", ]; // Without this the model writes what it believes was meant, dropping the hesitations and // false starts that an editing pass exists to find. A prompt has to be in the language // being transcribed, which is why the caller resolves it rather than this function. if (prompt) { args.push("--prompt", prompt); } // --max-len 1 caps a cue at one token, not one word, so without this a multi-token word // arrives split: "Crafter" as "Cra" + "fter". The result still looks word-level, since // every cue holds one token and no spaces, which is what makes the omission expensive: // anything matching on word text silently misses the fragments and nothing reports it. // Measured on 91s of Spanish with large-v3-turbo: 26% of cues were fragments without this // flag, 0% with it. if (config.wordTimestamps) { args.push("--split-on-word"); } if (language !== "auto") { args.push("--language", language); } const flags = config.whisperFlags; if (flags.suppressNst) args.push("--suppress-nst"); if (flags.noFallback) args.push("--no-fallback"); // An initial prompt *is* text context, so `--max-context 0` throws it away and the run // comes back byte-identical to one with no prompt at all. Measured on a 90.5s recording: // identical at 0, and 410 against 414 cues at 64. The default is 0 to stop the model // carrying its own hallucinations forward between windows, which is worth keeping when // nothing was asked for; when a prompt was, the room has to exist for it to sit in. args.push("--max-context", String(prompt && flags.maxContext === 0 ? 64 : flags.maxContext)); args.push("--entropy-thold", String(flags.entropyThold)); args.push("--logprob-thold", String(flags.logprobThold)); return args; } export async function transcribe( wavPath: string, config: TrxConfig, languageOverride?: string, onProgress?: (progress: WhisperProgress) => void, prompt?: string | null, ): Promise { if (!existsSync(config.modelPath)) { throw new Error(`Whisper model not found: ${config.modelPath}\nRun "trx init" to download a model.`); } const language = languageOverride || config.language; const args = buildWhisperArgs(config, wavPath, language, prompt); if (onProgress) { args.push("--print-progress"); await spawnStreaming(args, "whisper-cli transcription", (line) => { const match = line.match(/progress\s*=\s*(\d+)%/i); if (match) { onProgress({ percent: Number.parseInt(match[1], 10) }); } }); } else { await spawnOrThrow(args, "whisper-cli transcription"); } const srtPath = `${wavPath}.srt`; if (!existsSync(srtPath)) { throw new Error(`Whisper completed but SRT file not found: ${srtPath}`); } const srtContent = readFileSync(srtPath, "utf-8"); const text = srtToPlainText(srtContent); const txtPath = wavPath.replace(/\.wav$/, ".txt"); await Bun.write(txtPath, text); return { srtPath, txtPath, text }; } function srtToPlainText(srt: string): string { return srt .split("\n") .filter((line) => !/^\[|-->/.test(line)) .filter((line) => !/^\d+\s*$/.test(line)) .filter((line) => line.trim().length > 0) .join("\n"); }