diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index da91d42..79f36e5 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -2,6 +2,24 @@ Notable changes to `@crafter/trx`. Entries say what changed and, where it is not obvious, what measurement led to it. +## 0.8.1 + +### Fixed + +- **The transcription path no longer removes silence before transcribing.** `silenceremove` led the cleaning chain, and deleting pauses rewrites the timeline: every cue after the first removed pause was early, and the error accumulated rather than being a constant offset a consumer could subtract. Measured on one 90.538s recording, the audio that reached the model ran 88.966s — **1.572s of drift**, with nothing in the output saying the timeline had changed. Anything using those timestamps against the original file was quietly wrong. + + It also bought nothing. Same recording, with it and without: **436 cues either way**, and the run without was faster (7.6s against 8.7s), because dropping pauses costs more in the filter than the shorter audio saves in the model. `dynaudnorm` and `afftdn` stay; both preserve duration. Closes #35. + +- **`--preset verbatim` and `--prompt` now reach the model.** They were being built into the whisper invocation correctly and then discarded: an initial prompt *is* text context, and the default `--max-context 0` throws it away, so a prompted run came back byte-identical to an unprompted one. The default exists to stop the model carrying its own hallucinations forward between windows and is worth keeping when nothing was asked for; when a prompt was, the room now exists for it to sit in. + + Verified end to end rather than by flag inspection: on the same recording the preset now recovers `Ok.` and `Eh,` where the unprompted run dropped both, which is the material a cutting tool reads. + + What neither fix changes: a consumer measuring transcript positions against audio energy still finds drift, because the model stretches a cue backwards into the pause before a word. Measured with `vcut detect` on the same recording, the share of cues claiming a word starts inside measured silence went from 28% to 25% while the worst single case rose from 1318ms to 1418ms. That is a different phenomenon from a rewritten timeline and it is unaffected by this release. + +### Added + +- **`transcribedDurationMs` and `lastCueEndMs` in the transcribe result**, alongside `inputDurationMs`. Three numbers rather than a verdict: the gap between the first two is what the cleaning stage changed, and the gap between the second and third is how much audio produced no words. A short transcript used to be ambiguous — mostly-silent recording, model stopping early, or the wrong file handed in all read the same. Closes #36. + ## 0.8.0 ### Added @@ -10,7 +28,7 @@ Notable changes to `@crafter/trx`. Entries say what changed and, where it is not The prompt is a table rather than a translated string, because it has to be written in the language being spoken and name the fillers that language actually uses. Covers `de`, `en`, `es`, `fr`, `it`, `pt`. Any other language is an error naming the available ones rather than a fallback, since a prompt in the wrong language steers the model worse than no prompt at all. `--prompt` remains as the escape hatch. - Note that the cleaning stage currently removes silence before transcribing, which deletes the pauses these hesitations live around, so the preset has less to work with than it should. Tracked in #35. + (Shipped inert: the prompt was discarded before reaching the model until 0.8.1.) ### Fixed diff --git a/packages/cli/package.json b/packages/cli/package.json index 0631905..9201b7b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@crafter/trx", - "version": "0.8.0", + "version": "0.8.1", "description": "Agent-first CLI for audio/video transcription via Whisper", "module": "bin/trx.ts", "type": "module", diff --git a/packages/cli/schemas/transcribe.json b/packages/cli/schemas/transcribe.json index 83e9d49..d9d4713 100644 --- a/packages/cli/schemas/transcribe.json +++ b/packages/cli/schemas/transcribe.json @@ -102,7 +102,10 @@ }, "metadata": { "language": "string", - "model": "string" + "model": "string", + "inputDurationMs": "integer|null, how long the file handed in ran", + "transcribedDurationMs": "integer|null, how long the audio that reached the model ran. A large gap from inputDurationMs means the timeline was rewritten", + "lastCueEndMs": "integer|null, where the last cue ends. The gap to transcribedDurationMs is audio that produced no words" }, "text": "string (full transcript)" }, diff --git a/packages/cli/src/core/audio.ts b/packages/cli/src/core/audio.ts index f726db5..43882ea 100644 --- a/packages/cli/src/core/audio.ts +++ b/packages/cli/src/core/audio.ts @@ -1,9 +1,26 @@ -import { spawnOrThrow } from "../utils/spawn.ts"; +import { spawn, spawnOrThrow } from "../utils/spawn.ts"; export interface AudioResult { wavPath: string; } +/** + * Level and noise only. Every filter here preserves duration, so a timestamp in the + * transcript means the same instant in the file the caller handed in. + * + * `silenceremove=stop_periods=-1:stop_duration=1:stop_threshold=-40dB` used to lead this + * chain and was removed: deleting pauses rewrites the timeline, so every cue after the first + * removed pause was early and the error accumulated rather than being a constant offset a + * consumer could subtract. Measured on one 90.538s recording, the audio that reached the + * model ran 88.966s, and nothing in the output said the timeline had changed. Anything using + * those timestamps against the original file was quietly wrong. + * + * It also bought nothing. Same recording, with and against without: 436 cues either way, and + * the run without it was faster (7.6s against 8.7s) because dropping pauses costs more in the + * filter than the shorter audio saves in the model. The only measurable effects were the + * broken timeline and the loss of the pauses that hesitations live around, which is where a + * verbatim prompt does its work. + */ export async function cleanAudio(inputPath: string, outputPath: string): Promise { await spawnOrThrow( [ @@ -11,7 +28,7 @@ export async function cleanAudio(inputPath: string, outputPath: string): Promise "-i", inputPath, "-af", - "silenceremove=stop_periods=-1:stop_duration=1:stop_threshold=-40dB,dynaudnorm,afftdn=nf=-25", + "dynaudnorm,afftdn=nf=-25", "-ar", "16000", "-ac", @@ -26,3 +43,48 @@ export async function cleanAudio(inputPath: string, outputPath: string): Promise return { wavPath: outputPath }; } + +/** + * Duration in milliseconds, or null when it cannot be read. + * + * Null rather than a throw: this exists to describe a result that already succeeded, and a + * missing number is worth reporting as missing rather than failing a transcription over. + */ +export async function durationMs(path: string): Promise { + const { stdout, exitCode } = await spawn([ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + path, + ]); + if (exitCode !== 0) { + return null; + } + const seconds = Number(stdout.trim()); + return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null; +} + +/** + * Where the last cue ends, which is not the same as how long the audio ran. The gap between + * the two is how much audio produced no words, and reading it is what separates "this + * recording is mostly silence" from "the transcription stopped early". + */ +export function lastCueEndMs(srt: string): number | null { + let latest: number | null = null; + for (const line of srt.split("\n")) { + const match = line.match(/-->\s*(\d+):(\d+):(\d+)[,.](\d+)/); + if (match === null) { + continue; + } + const [, hours, minutes, secs, millis] = match; + const end = (Number(hours) * 3600 + Number(minutes) * 60 + Number(secs)) * 1000 + Number(millis); + if (latest === null || end > latest) { + latest = end; + } + } + return latest; +} diff --git a/packages/cli/src/core/pipeline.ts b/packages/cli/src/core/pipeline.ts index eacef07..0dae25b 100644 --- a/packages/cli/src/core/pipeline.ts +++ b/packages/cli/src/core/pipeline.ts @@ -1,7 +1,7 @@ import { mkdir } from "node:fs/promises"; import { basename, resolve } from "node:path"; import type { Backend, TrxConfig } from "../utils/config.ts"; -import { cleanAudio } from "./audio.ts"; +import { cleanAudio, durationMs, lastCueEndMs } from "./audio.ts"; import { downloadMedia } from "./download.ts"; import { transcribeOpenAI } from "./openai.ts"; import { transcribeVercel } from "./vercel.ts"; @@ -36,10 +36,42 @@ export interface PipelineResult { metadata: { language: string; model: string; + /** How long the file handed in ran, in milliseconds. Null when it could not be read. */ + inputDurationMs: number | null; + /** How long the audio that reached the model ran. */ + transcribedDurationMs: number | null; + /** Where the last cue ends. The gap to transcribedDurationMs is audio that produced no words. */ + lastCueEndMs: number | null; }; text: string; } +/** + * Three numbers rather than a verdict. A short transcript reads the same whether the + * recording is mostly silence, the model stopped early, or the file handed in was not the one + * intended; the gaps between these separate those cases. The caller decides what is suspicious + * for its own material, which beats a threshold picked here. + */ +async function coverage( + inputFile: string, + audioInput: string, + srtPath: string, +): Promise<{ + inputDurationMs: number | null; + transcribedDurationMs: number | null; + lastCueEndMs: number | null; +}> { + return { + inputDurationMs: await durationMs(inputFile), + transcribedDurationMs: await durationMs(audioInput), + lastCueEndMs: lastCueEndMs( + await Bun.file(srtPath) + .text() + .catch(() => ""), + ), + }; +} + export async function runPipeline(opts: PipelineOptions): Promise { const { config, outputDir } = opts; const backend = opts.backend || config.backend || "local"; @@ -88,6 +120,7 @@ export async function runPipeline(opts: PipelineOptions): Promise { expect(args[args.indexOf("--prompt") + 1]).toBe("Transcripción literal."); }); + // An initial prompt is text context, so --max-context 0 discards it and the run comes + // back identical to one with no prompt. The default is 0 to stop the model carrying its + // own hallucinations between windows; a prompt needs room to sit in. + test("makes room for a prompt when the config leaves no context", () => { + const noContext = { ...config, whisperFlags: { ...config.whisperFlags, maxContext: 0 } }; + const args = buildWhisperArgs(noContext as never, "/tmp/a.wav", "es", "literal"); + expect(args[args.indexOf("--max-context") + 1]).toBe("64"); + }); + + test("leaves a configured context alone", () => { + const args = buildWhisperArgs(config as never, "/tmp/a.wav", "es", "literal"); + expect(args[args.indexOf("--max-context") + 1]).toBe("64"); + const wide = { ...config, whisperFlags: { ...config.whisperFlags, maxContext: 128 } }; + expect( + buildWhisperArgs(wide as never, "/tmp/a.wav", "es", "literal")[ + buildWhisperArgs(wide as never, "/tmp/a.wav", "es", "literal").indexOf("--max-context") + 1 + ], + ).toBe("128"); + }); + + test("keeps max-context at zero when no prompt was given", () => { + const noContext = { ...config, whisperFlags: { ...config.whisperFlags, maxContext: 0 } }; + const args = buildWhisperArgs(noContext as never, "/tmp/a.wav", "es"); + expect(args[args.indexOf("--max-context") + 1]).toBe("0"); + }); + test("adds no prompt flag when there is none, so old invocations are unchanged", () => { expect(buildWhisperArgs(config as never, "/tmp/a.wav", "es")).not.toContain("--prompt"); expect(buildWhisperArgs(config as never, "/tmp/a.wav", "es", null)).not.toContain("--prompt"); @@ -768,3 +795,27 @@ describe("validateOutputFormat", () => { expect(() => validateOutputFormat("jsonn")).toThrow(/Available: json, table, auto\.$/); }); }); + +describe("lastCueEndMs", () => { + test("reports where the last cue ends, not the first", () => { + const srt = "1\n00:00:01,000 --> 00:00:02,500\nhola\n\n2\n00:00:03,000 --> 00:00:04,250\nmundo\n"; + expect(lastCueEndMs(srt)).toBe(4250); + }); + + test("reads hours past the first", () => { + expect(lastCueEndMs("1\n01:02:03,456 --> 01:02:04,500\nx\n")).toBe(3_724_500); + }); + + // The gap between this and the audio duration is what separates "mostly silence" from + // "the transcription stopped early", so an empty transcript has to be reportable rather + // than collapse to zero. + test("returns null for a transcript with no cues", () => { + expect(lastCueEndMs("")).toBeNull(); + expect(lastCueEndMs("not an srt at all")).toBeNull(); + }); + + test("survives cues that arrive out of order", () => { + const srt = "1\n00:00:09,000 --> 00:00:10,000\nb\n\n2\n00:00:01,000 --> 00:00:02,000\na\n"; + expect(lastCueEndMs(srt)).toBe(10_000); + }); +});