Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/contracts/2026-10-05-transcribe-language-progress.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Transcription language and progress contract</title><style>
:root{color-scheme:light dark;
--bg:#fbfbfa;--card:#ffffff;--line:#e6e4df;--ink:#1c1c1a;--dim:#6b6b66;--faint:#9a9a94;
--go:#178a5a;--go-bg:#e8f6ee;
--warn:#a6690a;--warn-bg:#fbf1dc;--bad:#c0392b;--bad-bg:#fbe7e4;--info:#2a5db0;--info-bg:#e8eefb;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
@media (prefers-color-scheme:dark){:root{--bg:#111213;--card:#191b1d;--line:#2a2d31;--ink:#ecebe8;--dim:#a2a29c;--faint:#6f6f6a;
--go:#5fd39a;--go-bg:#12291f;--warn:#e6b35a;--warn-bg:#2b2311;
--bad:#ff7b6b;--bad-bg:#2d1714;--info:#7fa9ff;--info-bg:#15203a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 var(--sans)}
.wrap{max-width:900px;margin:0 auto;padding:56px 24px 96px}
header h1{font-size:30px;line-height:1.15;letter-spacing:-.4px;margin:0 0 10px}
header .what{font-size:18px;color:var(--dim);margin:0 0 18px;max-width:70ch}
.strip{display:flex;gap:10px;flex-wrap:wrap;align-items:center;font:13px var(--mono);color:var(--faint)}
.strip .sep{opacity:.5}
.pill{display:inline-flex;align-items:center;gap:6px;font:600 12px/1 var(--mono);padding:6px 10px;border-radius:999px;white-space:nowrap}
.pill.go{color:var(--go);background:var(--go-bg)}.pill.warn{color:var(--warn);background:var(--warn-bg)}
.pill.bad{color:var(--bad);background:var(--bad-bg)}.pill.info{color:var(--info);background:var(--info-bg)}
.pill b{font-size:13px}
section{margin-top:56px}
h2{font-size:22px;letter-spacing:-.3px;margin:0 0 6px}
.sub{color:var(--dim);margin:0 0 18px;font-size:15px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:14px 0}
.breath{border-left:4px solid var(--go);font-size:18px;line-height:1.5}
.breath b{color:var(--go)}
.plain{color:var(--dim);font-size:15px;margin:0 0 10px}
.plain::before{content:"In plain words ";font:600 11px var(--mono);letter-spacing:1px;color:var(--faint);text-transform:uppercase}
pre{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow:auto;font:13px/1.55 var(--mono);margin:10px 0}
code{font:13px var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
.cite{font:12px var(--mono);color:var(--faint);margin-top:8px;display:block}
.states{display:flex;gap:8px;margin:12px 0 4px;flex-wrap:wrap}
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14.5px}
th{text-align:left;font:600 11px/1.4 var(--mono);text-transform:uppercase;letter-spacing:.7px;color:var(--faint);border-bottom:1px solid var(--line);padding:8px 10px}
td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
tr:last-child td{border-bottom:0}
td.k{font:13px var(--mono);white-space:nowrap}
.decision{border-left:4px solid var(--info)}
.decision h3{margin:0 0 8px;font-size:17px}
.decision .ask{color:var(--info);font-weight:600}
blockquote{margin:12px 0;padding:8px 0 8px 16px;border-left:2px solid var(--line);color:var(--dim);font-style:italic;font-size:14.5px}
blockquote .src{display:block;font-style:normal;font:12px var(--mono);color:var(--faint);margin-top:6px}
ul,ol{padding-left:22px}li{margin:6px 0}
.mermaid{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin:14px 0;overflow:auto}
.mermaid svg{max-width:100%}
footer{margin-top:72px;padding-top:16px;border-top:1px solid var(--line);font:12px/1.7 var(--mono);color:var(--faint)}
</style></head><body><main class="wrap"><header><h1>Transcription language and progress</h1><p class="what">Carry automatic language detection in the JSON result and emit progress separately so stdout stays parseable.</p><div class="strip">2026-10-05 · hyperframes @ d69eac0cb · 4 boundaries audited · native execution not exercised · decisions approved</div></header>
<section><div class="card breath"><b>In one breath.</b> stdout is one result, stderr carries typed progress records plus diagnostics, and detectedLanguage reports inference rather than a requested language.</div></section>
<section><h2>Where the value travels</h2><pre>Whisper JSON result.language → transcription result → CLI stdout → JSON consumer
HTTP response bytes → download pipeline → model callback → CLI stderr → progress consumer
Whisper words → normalization → transcript.json (word array, unchanged)</pre></section>
<section><h2>The contract, per boundary</h2>
<div class="card"><h3>Language</h3><p class="plain">The native result identifies the language used to decode. Only automatic selection qualifies as detection.</p><pre>result.language: string
Proposed terminal addition: detectedLanguage: string | null</pre><p>Upstream JSON writes result.language from whisper_full_lang_id. Explicit language also sets that state. Existing detection incorrectly searches stdout although the native logger writes stderr; missing detection then omits --language and falls back to native English default.</p><span class="cite">whisper.cpp 60c0be6ac: examples/cli/cli.cpp:84,735-736; src/whisper.cpp:6993-7013,7151-7153,9357-9366. CLI: whisper/transcribe.ts:17-23,466-504,534,560.</span><div class="states"><span class="pill go">exists: native JSON</span><span class="pill bad">reachable: dropped by wrapper</span><span class="pill bad">written: no terminal field</span></div></div>
<div class="card"><h3>Result and artifact</h3><p class="plain">Add metadata to the terminal result and preserve the existing word-array file.</p><pre>{ok:true, engine, model, wordCount, durationSeconds,
speechOnsetSeconds, transcriptPath, detectedLanguage}</pre><p>The existing model field currently reports the requested model even when the wrapper selects a multilingual variant. The wrapper will own the resolved model and return it with the result.</p><span class="cite">commands/transcribe.ts:397 writes Word[]; :400-410 emits stdout. Existing file consumer: commands/transcribe.ts:384 calls loadTranscript. External new-field consumption remains unverified.</span><div class="states"><span class="pill go">exists: result and words</span><span class="pill go">reachable: stdout and file</span><span class="pill warn">read: additions pending</span></div></div>
<div class="card"><h3>Download progress</h3><p class="plain">Count bytes in the existing transfer pipeline. Unknown size stays unknown.</p><pre>{"type":"progress","phase":"download","model":"small",
"receivedBytes":65536,"totalBytes":null}</pre><p>DownloadOptions currently has timeoutMs and maxBytes only. Add a byte callback at the transfer owner, thread it through ensureModel, and serialize it only at the command boundary. Cache hits emit no download events. A known total is bytes, never an invented percentage.</p><span class="cite">utils/download.ts:10-15,113-122; whisper/manager.ts:215-223; commands/transcribe.ts:341,347-354.</span><div class="states"><span class="pill bad">exists: callback absent</span><span class="pill bad">reachable: JSON disables callbacks</span><span class="pill bad">written: no progress records</span></div></div>
<div class="card"><h3>Transcription progress</h3><p class="plain">Indicate work started and completed without inventing numerical progress.</p><pre>{"type":"progress","phase":"transcription","model":"small",
"status":"started"}
{"type":"progress","phase":"transcription","model":"small",
"status":"completed"}</pre><p>Progress records are individual valid JSON lines on stderr. Other stderr diagnostics remain allowed. The terminal stdout result is emitted once. Failure never emits a completed transcription event.</p><span class="cite">whisper/transcribe.ts:488,512-515; whisper/parakeet.ts:294-304; whisper/sherpa.ts:397-413,445. Plain fallback diagnostics: commands/transcribe.ts:370.</span><div class="states"><span class="pill warn">exists: text callbacks</span><span class="pill bad">reachable: JSON disabled</span><span class="pill bad">written: no typed records</span></div></div></section>
<section><h2>How it says no</h2><table><tr><th>Signal</th><th>Meaning and response</th></tr><tr><td>detectedLanguage:null</td><td>No actual automatic inference was reported, including explicit language, English-only model, or an engine without detection. Never infer language from a model label.</td></tr><tr><td>totalBytes:null</td><td>Response has no trustworthy size. Show bytes or indeterminate progress.</td></tr><tr><td>ok:false / nonzero exit</td><td>Operation failed. Diagnostic stderr may explain why. No completion event is evidence of success without the terminal result.</td></tr><tr><td>Progress line without type</td><td>Not part of this progress contract. Consumers filter diagnostics separately.</td></tr></table></section>
<section><h2>Decisions approved</h2><ol><li>detectedLanguage means real automatic detection. Explicit --language and English-only models return null.</li><li>stderr permits diagnostics alongside progress; every progress line is valid JSON with type.</li><li>model reports the actual resolved model. The file stays Word[].</li><li>Transcription progress is phase-only. Download progress reports real bytes with a nullable total.</li><li>--no-runtime-install requires an existing Whisper runtime on all paths, including auto fallback. Model downloads remain allowed. No prior opt-out exists in the inspected CLI source; ensureWhisper owns this policy.</li></ol></section>
<section><h2>Coverage, honestly</h2><p><b>Audited:</b> CLI producer, transfer pipeline, native JSON writer and logger, current terminal/file paths. Installed CLI help executed.</p><p><b>Trusting:</b> arbitrary environment-selected Whisper versions are not pinned to the inspected upstream checkout.</p><p><b>Not exercised:</b> native audio inference, new progress writer and external live consumer. These are contract findings, not execution proof.</p></section>
<section><h2>What would prove this page wrong</h2><ul><li>A public transcribe invocation with a native fixture that emits detection only on stderr must decode automatically and return its language.</li><li>Explicit language and English-only models must return null even if native JSON reports a language.</li><li>Download callbacks must observe actual chunks, including unknown totals, and stdout must parse as one result while stderr records parse independently.</li><li>A switched multilingual model must be the model reported in the final result.</li></ul></section></main></body></html>
65 changes: 64 additions & 1 deletion packages/cli/src/commands/transcribe.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { runCommand } from "citty";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { chmodSync, existsSync, writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
Expand Down Expand Up @@ -44,7 +45,14 @@ import transcribeCmd from "./transcribe.js";
function fakeTranscript(dir: string, text: string) {
const transcriptPath = join(dir, "transcript.json");
writeFileSync(transcriptPath, JSON.stringify([{ text, start: 0, end: 1 }]));
return { transcriptPath, wordCount: 1, durationSeconds: 1, speechOnsetSeconds: null };
return {
model: text === "whisper" ? "small.en" : "parakeet-tdt-0.6b-v3",
detectedLanguage: null,
transcriptPath,
wordCount: 1,
durationSeconds: 1,
speechOnsetSeconds: null,
};
}

function lastJson(): Record<string, unknown> {
Expand Down Expand Up @@ -81,6 +89,47 @@ describe("transcribe command", () => {
vi.unstubAllEnvs();
});

it("keeps typed progress on stderr and reports resolved metadata in one stdout result", async () => {
const { dir, input } = dummyAudio();
dirs.push(dir);
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
transcribeMock.mockImplementation(async (_input, outputDir, options) => {
options.onEvent?.({
type: "progress",
phase: "download",
model: "small",
receivedBytes: 5,
totalBytes: null,
});
options.onEvent?.({
type: "progress",
phase: "transcription",
model: "small",
status: "started",
});
options.onEvent?.({
type: "progress",
phase: "transcription",
model: "small",
status: "completed",
});
return { ...fakeTranscript(outputDir, "hola"), model: "small", detectedLanguage: "es" };
});
await transcribeCmd.run!({
args: { input, dir, json: true, engine: "whisper", model: "small.en" },
} as never);
expect(console.log).toHaveBeenCalledTimes(1);
expect(lastJson()).toMatchObject({ ok: true, model: "small", detectedLanguage: "es" });
expect(stderr.mock.calls.map(([line]) => JSON.parse(String(line)))).toEqual([
{ type: "progress", phase: "download", model: "small", receivedBytes: 5, totalBytes: null },
{ type: "progress", phase: "transcription", model: "small", status: "started" },
{ type: "progress", phase: "transcription", model: "small", status: "completed" },
]);
expect(JSON.parse(readFileSync(join(dir, "transcript.json"), "utf8"))).toEqual([
{ id: "w0", text: "hola", start: 0, end: 1 },
]);
});

it("explicit run exits non-zero and is NOT reported as a command failure", async () => {
const { dir, input } = dummyAudio();
dirs.push(dir);
Expand Down Expand Up @@ -252,6 +301,20 @@ describe("transcribe command", () => {
return { exitCode: exitCode || consumeCommandResult().exitCode, out: lastJson() };
}

it.each(["whisper", "auto"])(
"--no-runtime-install reaches %s including the fallback",
async (engine) => {
crashChild("SIGABRT");
Object.assign(runners, { sherpa: true, mlx: false });
const { dir, input } = dummyAudio();
dirs.push(dir);
await runCommand(transcribeCmd, {
rawArgs: [input, "--engine", engine, "--json", "--no-runtime-install"],
});
expect(transcribeMock.mock.calls.at(-1)?.[2]).toMatchObject({ installRuntime: false });
},
);

it("auto falls back to whisper with one line naming the Parakeet error and the repair", async () => {
crashChild("SIGABRT", "terminate called after throwing an instance of 'Ort::Exception'\n");
expect(await transcribeWith("auto", { sherpa: true, mlx: false })).toMatchObject({
Expand Down
56 changes: 42 additions & 14 deletions packages/cli/src/commands/transcribe.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createProgressWriter } from "../whisper/progress.js";
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
// fallow-ignore-file code-duplication
Expand All @@ -7,7 +8,6 @@ import { existsSync, rmSync, writeFileSync } from "node:fs";
import {
findParakeet,
PARAKEET_LANGUAGES,
PARAKEET_MODEL_LABEL,
parakeetSpeaks,
transcribeWithParakeet,
} from "../whisper/parakeet.js";
Expand Down Expand Up @@ -77,7 +77,7 @@ export default defineCommand({
},
json: {
type: "boolean",
description: "Output result as JSON",
description: "Output result as JSON; progress JSON lines go to stderr",
default: false,
},
to: {
Expand All @@ -95,6 +95,12 @@ export default defineCommand({
"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,
},
"runtime-install": {
type: "boolean",
default: true,
description:
"Allow installing the Whisper runtime. Use --no-runtime-install to require an existing runtime; model downloads remain allowed.",
},
optional: {
type: "boolean",
description:
Expand Down Expand Up @@ -153,6 +159,7 @@ export default defineCommand({
language: args.language,
json: args.json,
optional: args.optional,
installRuntime: args["runtime-install"],
timeoutMs,
});
},
Expand Down Expand Up @@ -294,6 +301,7 @@ async function transcribeAudio(
language?: string;
json?: boolean;
optional?: boolean;
installRuntime?: boolean;
timeoutMs?: number;
},
): Promise<void> {
Expand Down Expand Up @@ -339,20 +347,39 @@ async function transcribeAudio(
const spin = opts.json ? null : clack.spinner();
spin?.start(`Transcribing with ${label(runner)}...`);
const onProgress = spin ? (msg: string) => spin.message(msg) : undefined;
const onEvent = opts.json ? createProgressWriter(process.stderr) : undefined;
let wavPath = inputPath;
// Before audio prep: under --json no spinner listens for SIGINT, so Ctrl-C would kill Node.
const cancellation = runner === "sherpa" ? createRenderCancellationScope() : null;
const run = (r: Runner) =>
r === "sherpa"
? transcribeWithSherpa(wavPath, dir, { onProgress, signal: cancellation!.signal })
: r === "parakeet-mlx"
? transcribeWithParakeet(wavPath, dir, { language: opts.language, onProgress })
: transcribe(wavPath, dir, {
model,
language: opts.language,
onProgress,
timeoutMs: opts.timeoutMs,
});
const run = (r: Runner) => {
switch (r) {
case "sherpa":
return transcribeWithSherpa(wavPath, dir, {
onProgress,
onEvent,
signal: cancellation!.signal,
});
case "parakeet-mlx":
return transcribeWithParakeet(wavPath, dir, {
language: opts.language,
onProgress,
onEvent,
});
case "whisper":
return transcribe(wavPath, dir, {
model,
language: opts.language,
onProgress,
onEvent,
timeoutMs: opts.timeoutMs,
installRuntime: opts.installRuntime,
});
default: {
const unreachable: never = r;
throw new Error(`Unknown transcription runner: ${unreachable}`);
}
}
};

try {
// Outside the fallback: an unreadable input is not a Parakeet failure. The fallback reuses it.
Expand Down Expand Up @@ -402,7 +429,8 @@ async function transcribeAudio(
JSON.stringify({
ok: true,
engine: runner === "whisper" ? "whisper" : "parakeet",
model: runner === "whisper" ? model : PARAKEET_MODEL_LABEL,
model: result.model,
detectedLanguage: result.detectedLanguage,
wordCount: words.length,
durationSeconds: result.durationSeconds,
speechOnsetSeconds: result.speechOnsetSeconds,
Expand Down
Loading
Loading