Files
hyperframes/packages/cli/src/commands/transcribe.ts
T

342 lines
13 KiB
TypeScript

// fallow-ignore-file code-duplication
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { existsSync, writeFileSync } from "node:fs";
import { findParakeet, transcribeWithParakeet } from "../whisper/parakeet.js";
type CaptionExportFormat = "srt" | "vtt";
export const examples: Example[] = [
["Transcribe an audio file", "hyperframes transcribe audio.mp3"],
["Transcribe a video file", "hyperframes transcribe video.mp4"],
["Use a larger model for better accuracy", "hyperframes transcribe audio.mp3 --model medium.en"],
["Set language to filter non-target speech", "hyperframes transcribe audio.mp3 --language en"],
["Import an existing SRT file", "hyperframes transcribe subtitles.srt"],
["Import an OpenAI Whisper JSON response", "hyperframes transcribe response.json"],
["Export captions to SRT", "hyperframes transcribe transcript.json --to srt"],
[
"Export single-word/CJK captions without re-grouping",
"hyperframes transcribe transcript.json --to vtt --preserve-cues",
],
];
import { resolve, join, extname, dirname } from "node:path";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { DEFAULT_MODEL, isWhisperUnavailable } from "../whisper/manager.js";
import { trackCommandFailure, trackTranscribeUnavailable } from "../telemetry/events.js";
export default defineCommand({
meta: {
name: "transcribe",
description:
"Transcribe audio/video to word-level timestamps, or import an existing transcript",
},
args: {
input: {
type: "positional",
description:
"Audio/video file to transcribe, or transcript file to import (.json, .srt, .vtt)",
required: true,
},
dir: {
type: "string",
description: "Project directory (default: current directory)",
alias: "d",
},
engine: {
type: "string",
description:
"ASR engine: auto (Parakeet if installed, else whisper), parakeet, or whisper. Default: auto. Parakeet is more accurate and faster; enable with `uv pip install parakeet-mlx`.",
alias: "e",
},
model: {
type: "string",
description: `Whisper model (default: ${DEFAULT_MODEL}). Options: tiny.en, base.en, small.en, medium.en, large-v3`,
alias: "m",
},
language: {
type: "string",
description: "Language code (e.g. en, es, ja). Filters out non-target language speech.",
alias: "l",
},
json: {
type: "boolean",
description: "Output result as JSON",
default: false,
},
to: {
type: "string",
description: "Export transcript sidecar format: srt or vtt",
},
output: {
type: "string",
alias: "o",
description: "Output path for exported SRT/VTT sidecar",
},
"preserve-cues": {
type: "boolean",
description:
"Keep each transcript entry as its own caption cue (skip word-level grouping). Use when exporting an already-cued transcript whose entries have no internal spaces, e.g. single-word or CJK captions.",
default: false,
},
optional: {
type: "boolean",
description:
"Treat captions as optional: if whisper-cpp is unavailable, skip and exit 0 instead of failing. For pipelines that continue without captions.",
default: false,
},
},
async run({ args }) {
const inputPath = resolve(args.input);
if (!existsSync(inputPath)) {
const message = `File not found: ${args.input}`;
trackCommandFailure("transcribe", message);
console.error(c.error(message));
process.exit(1);
}
// Default to the directory containing the input file so transcript.json
// lands next to narration.wav regardless of where the command is run from.
// Explicit --dir overrides this (e.g. for import mode targeting a project dir).
const dir = resolve(args.dir ?? dirname(inputPath));
const ext = extname(inputPath).toLowerCase();
// ── Import mode: convert existing transcript ──────────────────────────
const isImport = ext === ".json" || ext === ".srt" || ext === ".vtt";
const to = parseExportFormat(args.to, args.json);
if (to) {
if (!isImport) {
failWith(
"--to can only export from transcript files (.json, .srt, .vtt). Run transcribe first.",
args.json,
);
}
return exportTranscript(inputPath, dir, to, args.output, args.json, args["preserve-cues"]);
}
if (isImport) {
return importTranscript(inputPath, dir, args.json);
}
// ── Transcribe mode: run the ASR engine ──────────────────────────────
return transcribeAudio(inputPath, dir, {
engine: args.engine,
model: args.model,
language: args.language,
json: args.json,
optional: args.optional,
});
},
});
function failWith(message: string, json: boolean): never {
trackCommandFailure("transcribe", message);
if (json) {
console.log(JSON.stringify({ ok: false, error: message }));
} else {
console.error(c.error(message));
}
process.exit(1);
}
function parseExportFormat(
value: string | undefined,
json: boolean,
): CaptionExportFormat | undefined {
if (!value) return undefined;
const normalized = value.toLowerCase();
if (normalized === "srt" || normalized === "vtt") return normalized;
failWith(`Unsupported caption export format: ${value}. Use srt or vtt.`, json);
}
// ---------------------------------------------------------------------------
// Import existing transcript
// ---------------------------------------------------------------------------
function exitNoWords(json: boolean): never {
failWith("No words found in transcript.", json);
}
async function importTranscript(inputPath: string, dir: string, json: boolean): Promise<void> {
const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
const { words, format } = loadTranscript(inputPath);
if (words.length === 0) exitNoWords(json);
const outPath = join(dir, "transcript.json");
writeFileSync(outPath, JSON.stringify(words, null, 2));
patchCaptionHtml(dir, words);
if (json) {
console.log(
JSON.stringify({ ok: true, format, wordCount: words.length, transcriptPath: outPath }),
);
} else {
console.log(
`${c.success("◇")} Imported ${c.accent(String(words.length))} words from ${c.accent(format)} format → ${c.accent("transcript.json")}`,
);
}
}
// ---------------------------------------------------------------------------
// Export transcript sidecars
// ---------------------------------------------------------------------------
async function exportTranscript(
inputPath: string,
dir: string,
to: CaptionExportFormat,
output: string | undefined,
json: boolean,
preserveCues: boolean,
): Promise<void> {
const { loadTranscript, formatSrt, formatVtt } = await import("../whisper/normalize.js");
const { words, format } = loadTranscript(inputPath);
if (words.length === 0) exitNoWords(json);
// A .srt/.vtt source is already phrase-level; keep its cue boundaries 1:1.
// --preserve-cues forces the same for an already-cued transcript.json whose
// entries have no internal whitespace (single-word or CJK captions), which
// the automatic whitespace heuristic in wordsToCues can't detect.
const preGrouped = preserveCues || format === "srt" || format === "vtt" || undefined;
const outPath = resolve(output ?? join(dir, `transcript.${to}`));
const content =
to === "srt" ? formatSrt(words, { preGrouped }) : formatVtt(words, { preGrouped });
writeFileSync(outPath, content);
if (json) {
console.log(
JSON.stringify({ ok: true, format: to, wordCount: words.length, outputPath: outPath }),
);
} else {
console.log(
`${c.success("◇")} Exported ${c.accent(String(words.length))} words to ${c.accent(to.toUpperCase())}${c.accent(outPath)}`,
);
}
}
// ---------------------------------------------------------------------------
// Transcribe audio/video with whisper
// ---------------------------------------------------------------------------
// fallow-ignore-next-line complexity
async function transcribeAudio(
inputPath: string,
dir: string,
opts: { engine?: string; model?: string; language?: string; json?: boolean; optional?: boolean },
): Promise<void> {
const { transcribe } = await import("../whisper/transcribe.js");
const { loadTranscript, patchCaptionHtml, stripBeforeOnset } =
await import("../whisper/normalize.js");
// Engine: auto (Parakeet if installed, else whisper), or forced parakeet/whisper.
const engine = (opts.engine ?? "auto").toLowerCase();
if (engine !== "auto" && engine !== "parakeet" && engine !== "whisper") {
failWith(`Unknown --engine: ${opts.engine}. Use auto, parakeet, or whisper.`, !!opts.json);
}
const useParakeet = engine === "parakeet" || (engine === "auto" && !!findParakeet());
const model = opts.model ?? DEFAULT_MODEL;
// --model selects the whisper model only; Parakeet uses its own fixed model.
if (useParakeet && opts.model && !opts.json) {
console.error(
c.dim(` Note: --model applies to the whisper engine only; ignored under Parakeet.`),
);
}
const label = useParakeet ? "Parakeet" : model;
const spin = opts.json ? null : clack.spinner();
spin?.start(`Transcribing with ${c.accent(label)}...`);
try {
const result = useParakeet
? transcribeWithParakeet(inputPath, dir, {
language: opts.language,
onProgress: spin ? (msg) => spin.message(msg) : undefined,
})
: await transcribe(inputPath, dir, {
model,
language: opts.language,
onProgress: spin ? (msg) => spin.message(msg) : undefined,
});
let { words } = loadTranscript(result.transcriptPath);
if (result.speechOnsetSeconds != null) {
const before = words.length;
words = stripBeforeOnset(words, result.speechOnsetSeconds);
const stripped = before - words.length;
if (stripped > 0 && !opts.json) {
spin?.message(
`Stripped ${stripped} words before speech onset at ${result.speechOnsetSeconds.toFixed(1)}s`,
);
}
}
writeFileSync(result.transcriptPath, JSON.stringify(words, null, 2));
patchCaptionHtml(dir, words);
if (opts.json) {
console.log(
JSON.stringify({
ok: true,
engine: useParakeet ? "parakeet" : "whisper",
model: useParakeet ? "parakeet-tdt-0.6b-v3" : model,
wordCount: words.length,
durationSeconds: result.durationSeconds,
speechOnsetSeconds: result.speechOnsetSeconds,
transcriptPath: result.transcriptPath,
}),
);
} else {
const onsetNote =
result.speechOnsetSeconds != null
? ` — speech detected at ${result.speechOnsetSeconds.toFixed(1)}s`
: "";
spin?.stop(
c.success(
`Transcribed ${c.accent(String(words.length))} words (${result.durationSeconds.toFixed(1)}s${onsetNote})`,
),
);
}
} catch (err) {
// Surface the last few lines of the ASR subprocess's stderr, which
// execFileSync captures but otherwise drops on the floor — that's where
// parakeet-mlx / whisper report the actual failure cause.
const stderr =
err && typeof err === "object" && "stderr" in err && err.stderr
? String(err.stderr).trim().split("\n").slice(-3).join("\n")
: "";
const base = err instanceof Error ? err.message : String(err);
const message = stderr ? `${base}\n${stderr}` : base;
// whisper-cpp is an optional prerequisite, not part of the CLI. When it is
// simply unavailable (no binary, no toolchain to build one), that is a setup
// condition, not a command crash — report it on its own metric so it does
// not inflate the cli_error budget, and let `--optional` callers continue.
if (isWhisperUnavailable(err)) {
trackTranscribeUnavailable({ optional: opts.optional === true });
if (opts.json) {
console.log(JSON.stringify({ ok: false, skipped: true, reason: "whisper_unavailable" }));
} else {
spin?.stop(c.warn(`Captions skipped — ${message}`));
}
// Optional callers (pipelines) treat a missing prerequisite as a clean
// skip; explicit runs still surface non-zero. Set the status and return
// rather than guarding a process.exit() on the flag.
process.exitCode = opts.optional ? 0 : 1;
return;
}
trackCommandFailure("transcribe", err);
if (opts.json) {
console.log(JSON.stringify({ ok: false, error: message }));
} else {
spin?.stop(c.error(`Transcription failed: ${message}`));
}
process.exit(1);
}
}