// First-class registry of transcription apps. Each app owns how it builds its // argv/env from a resolved per-app config, what file it actually writes (the // `-o`/`-of` naming differs between tools), how its raw output is shaped, and // how its live progress output is parsed. The editor selects ONE active app // globally (settings.transcriptionApp) and stores a small per-app config block // (settings.transcriptionApps[id]); transcribeOne resolves the app and runs it. // // Pure definitions only (no I/O beyond reading process.env / getPaths inside // builders) so both the `common` controllers and the editor UI can import this. // Dependency direction is one-way: settings.ts -> transcriptionApps.ts -> // { paths, jobs/progressParsers }. Keep it that way to avoid import cycles. import { getPaths } from "./paths"; import { type ProgressUpdate, createTranscribeProgressParser, createChoughProgressParser, createParakeetProgressParser, } from "../jobs/progressParsers"; import type { FieldDocs } from "./fieldDocs"; export type TranscriptOutputFormat = "whisper-json" | "chough-json" | "vtt"; // Per-app configuration persisted under settings.transcriptionApps[id]. Every // field is optional; an app falls back to its own defaults (defaultBin, env). // Each field is documented in APP_INSTANCE_CONFIG_FIELD_DOCS below (rendered into SETTINGS.md). export type AppInstanceConfig = { bin?: string; model?: string; remoteUrl?: string; chunkSize?: number; customArgs?: string[]; device?: string; }; export const APP_INSTANCE_CONFIG_FIELD_DOCS: FieldDocs = { bin: "Binary path/name override. Empty/undefined falls back to " + "app.defaultBin().", model: "whisper.cpp model path (substituted for {model}); for chough this is " + "the optional CHOUGH_MODEL env (chough auto-downloads a model when " + "unset).", remoteUrl: "chough remote server URL (CHOUGH_URL). Empty/undefined = local " + "transcription.", chunkSize: "chough chunk size in seconds (-c). Undefined = chough's own default.", customArgs: "whisper.cpp custom argv template using the " + "{audioFile}/{outputBase}/{model} placeholders. Undefined = " + "DEFAULT_TRANSCRIBE_ARGS.", device: "parakeet compute device passed to parakeet-cli (--device / " + "PARAKEET_DEVICE), e.g. \"cuda:0\", \"cpu\". Undefined = parakeet-cli's " + "default device.", }; export type TranscribeBuild = { // Args passed after the binary. argv: string[]; // Extra environment variables merged over process.env for this run. env?: Record; // The file the app actually writes, relative to the video dir (cwd). // transcribeOne renames THIS to transcript.json — this is where the // chough-vs-whisper `-o`/`-of` naming difference is absorbed. outputFile: string; // Shape of the raw output, used as the authoritative parse hint downstream. outputFormat: TranscriptOutputFormat; }; export type TranscribeBuildInput = { audioFile: string; // basename relative to videoDir outputBase: string; // e.g. "transcript.tmp-" (NO extension) config: AppInstanceConfig; // Ask the engine to keep its word timestamps in the output document (a // top-level `words` array) as well as the grouped cues. Only parakeet has // them to give; the others ignore it. Off for corpus transcriptions, whose // transcript.json would otherwise carry every word twice. words?: boolean; }; export type TranscriptionApp = { id: string; label: string; // Which config fields this app surfaces in the settings UI / consumes. fields: { model?: boolean; remoteUrl?: boolean; chunkSize?: boolean; customArgs?: boolean; device?: boolean; }; // Default binary when the per-app `bin` override is empty. defaultBin: () => string; // The model this config runs with, after the app's own default — what a // record of "which model produced this" should say. Undefined when the app // picks one itself (chough with no model set). resolveModel: (config: AppInstanceConfig) => string | undefined; build: (input: TranscribeBuildInput) => TranscribeBuild; makeProgressParser: () => { feed: (line: string) => ProgressUpdate | null }; // True when the engine can be stopped mid-run and still produce a usable // partial transcript (parakeet stitches completed windows on SIGTERM). Surfaces // a "Stop & keep partial" control on the Workers page. supportsPartialStop?: boolean; }; // --------------------------------------------------------------------------- // whisper.cpp argv template + placeholders (formerly in settings.ts; kept here // so the whisper-cpp app owns its own arg construction). settings.ts re-exports // these for backward compatibility with existing import sites. // --------------------------------------------------------------------------- export const TRANSCRIBE_PLACEHOLDER_AUDIO = "{audioFile}"; export const TRANSCRIBE_PLACEHOLDER_OUTPUT_BASE = "{outputBase}"; export const TRANSCRIBE_PLACEHOLDER_MODEL = "{model}"; export const TRANSCRIBE_KNOWN_PLACEHOLDERS: ReadonlyArray = [ TRANSCRIBE_PLACEHOLDER_AUDIO, TRANSCRIBE_PLACEHOLDER_OUTPUT_BASE, TRANSCRIBE_PLACEHOLDER_MODEL, ]; export const DEFAULT_TRANSCRIBE_ARGS: ReadonlyArray = [ "-ojf", "-l", "en", "-m", TRANSCRIBE_PLACEHOLDER_MODEL, "-of", TRANSCRIBE_PLACEHOLDER_OUTPUT_BASE, TRANSCRIBE_PLACEHOLDER_AUDIO, ]; // Validate a whisper.cpp customArgs template: must reference {audioFile} and // {outputBase}, may reference {model}, and must contain no unknown placeholders. // Returns an error string or null when valid. export function validateTranscribeArgs(args: string[]): string | null { if (!Array.isArray(args) || args.length === 0) { return "Transcribe args must contain at least one entry"; } const joined = args.join(" "); if (!joined.includes(TRANSCRIBE_PLACEHOLDER_AUDIO)) { return `Transcribe args must include the ${TRANSCRIBE_PLACEHOLDER_AUDIO} placeholder`; } if (!joined.includes(TRANSCRIBE_PLACEHOLDER_OUTPUT_BASE)) { return `Transcribe args must include the ${TRANSCRIBE_PLACEHOLDER_OUTPUT_BASE} placeholder`; } for (const arg of args) { const tokens = arg.match(/\{[^}]+\}/g) ?? []; for (const token of tokens) { if (!TRANSCRIBE_KNOWN_PLACEHOLDERS.includes(token)) { return `Unknown placeholder ${token}; valid: ${TRANSCRIBE_KNOWN_PLACEHOLDERS.join(", ")}`; } } } return null; } function substitutePlaceholders( args: ReadonlyArray, values: { audioFile: string; outputBase: string; model: string }, ): string[] { return args.map((arg) => arg.replace(/\{[^}]+\}/g, (token) => { if (!TRANSCRIBE_KNOWN_PLACEHOLDERS.includes(token)) { throw new Error( `Unknown placeholder ${token} in transcribeArgs; valid: ${TRANSCRIBE_KNOWN_PLACEHOLDERS.join(", ")}`, ); } if (token === TRANSCRIBE_PLACEHOLDER_AUDIO) return values.audioFile; if (token === TRANSCRIBE_PLACEHOLDER_OUTPUT_BASE) return values.outputBase; if (token === TRANSCRIBE_PLACEHOLDER_MODEL) return values.model; return token; }), ); } // --------------------------------------------------------------------------- // App registry // --------------------------------------------------------------------------- const whisperCpp: TranscriptionApp = { id: "whisper-cpp", label: "whisper.cpp (whisper-cli)", fields: { model: true, customArgs: true }, defaultBin: () => getPaths().whisperBin, resolveModel: (config) => config.model ?? getPaths().whisperModel, build({ audioFile, outputBase, config }) { const template = config.customArgs && config.customArgs.length > 0 ? config.customArgs : DEFAULT_TRANSCRIBE_ARGS; const model = whisperCpp.resolveModel(config) as string; const argv = substitutePlaceholders(template, { audioFile, outputBase, model, }); // whisper-cli's `-of ` writes ".json". return { argv, outputFile: `${outputBase}.json`, outputFormat: "whisper-json" }; }, makeProgressParser: createTranscribeProgressParser, }; const chough: TranscriptionApp = { id: "chough", label: "chough", fields: { model: true, remoteUrl: true, chunkSize: true }, defaultBin: () => process.env.CHOUGH_BIN ?? "chough", resolveModel: (config) => config.model?.trim() || undefined, build({ audioFile, outputBase, config }) { const argv = ["-f", "json", "-o", outputBase]; if (typeof config.chunkSize === "number" && config.chunkSize > 0) { argv.push("-c", String(Math.floor(config.chunkSize))); } const env: Record = {}; const remoteUrl = config.remoteUrl?.trim(); if (remoteUrl) { argv.push("-r"); env.CHOUGH_URL = remoteUrl; } const model = chough.resolveModel(config); if (model) env.CHOUGH_MODEL = model; argv.push(audioFile); // chough writes EXACTLY the `-o` path — no ".json" is appended. return { argv, env: Object.keys(env).length > 0 ? env : undefined, outputFile: outputBase, outputFormat: "chough-json", }; }, makeProgressParser: createChoughProgressParser, }; // parakeet.cpp via the overlapping-segment wrapper (scripts/parakeet-stitch.mjs). // The wrapper IS the binary here: it slices the audio into overlapping 16kHz-mono // windows, runs parakeet-cli per window, and stitches the word timestamps into a // single chough-native JSON document written to the exact `-o` path (no extension // appended — same contract as chough). `model` is the .gguf; `chunkSize` is the // per-window length in seconds (overlap is wrapper-defaulted, tunable via the // PARAKEET_OVERLAP_SEC env). const parakeet: TranscriptionApp = { id: "parakeet", label: "parakeet.cpp (overlapping segments)", fields: { model: true, chunkSize: true, device: true }, // The overlapping-segment wrapper can stop after the current window and stitch // a partial transcript on SIGTERM — so a long run is interruptible. supportsPartialStop: true, defaultBin: () => getPaths().parakeetBin, resolveModel: (config) => config.model?.trim() || getPaths().parakeetModel, build({ audioFile, outputBase, config, words }) { const paths = getPaths(); const model = parakeet.resolveModel(config) as string; const argv = ["--model", model, "--output", outputBase]; if (words) argv.push("--words"); if (typeof config.chunkSize === "number" && config.chunkSize > 0) { argv.push("--segment", String(Math.floor(config.chunkSize))); } const device = config.device?.trim(); if (device) argv.push("--device", device); argv.push(audioFile); return { argv, env: { PARAKEET_CLI: paths.parakeetCliBin, FFMPEG_BIN: paths.ffmpegBin, }, // The wrapper writes EXACTLY the --output path (like chough's -o). outputFile: outputBase, outputFormat: "chough-json", }; }, makeProgressParser: createParakeetProgressParser, }; export const TRANSCRIPTION_APPS: Record = { [whisperCpp.id]: whisperCpp, [chough.id]: chough, [parakeet.id]: parakeet, }; export const DEFAULT_TRANSCRIPTION_APP_ID = "whisper-cpp"; export function getTranscriptionApp(id: string | undefined): TranscriptionApp { return ( (id ? TRANSCRIPTION_APPS[id] : undefined) ?? TRANSCRIPTION_APPS[DEFAULT_TRANSCRIPTION_APP_ID] ); } // A client-safe view of an app (id/label/fields only — no functions). Build it // on the server and pass it to the settings form so the registry module (which // reaches getPaths/process.env) never ends up in the client bundle. export type TranscriptionAppDescriptor = { id: string; label: string; fields: TranscriptionApp["fields"]; }; export function listTranscriptionApps(): TranscriptionAppDescriptor[] { return Object.values(TRANSCRIPTION_APPS).map((a) => ({ id: a.id, label: a.label, fields: a.fields, })); }