mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
342 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|