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
20 changes: 19 additions & 1 deletion packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/schemas/transcribe.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
},
Expand Down
66 changes: 64 additions & 2 deletions packages/cli/src/core/audio.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
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<AudioResult> {
await spawnOrThrow(
[
"ffmpeg",
"-i",
inputPath,
"-af",
"silenceremove=stop_periods=-1:stop_duration=1:stop_threshold=-40dB,dynaudnorm,afftdn=nf=-25",
"dynaudnorm,afftdn=nf=-25",
"-ar",
"16000",
"-ac",
Expand All @@ -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<number | null> {
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;
}
37 changes: 36 additions & 1 deletion packages/cli/src/core/pipeline.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<PipelineResult> {
const { config, outputDir } = opts;
const backend = opts.backend || config.backend || "local";
Expand Down Expand Up @@ -88,6 +120,7 @@ export async function runPipeline(opts: PipelineOptions): Promise<PipelineResult
metadata: {
language: opts.language || "auto",
model,
...(await coverage(inputFile, audioInput, result.srtPath)),
},
text: result.text,
};
Expand All @@ -113,6 +146,7 @@ export async function runPipeline(opts: PipelineOptions): Promise<PipelineResult
metadata: {
language: opts.language || "auto",
model,
...(await coverage(inputFile, audioInput, result.srtPath)),
},
text: result.text,
};
Expand All @@ -133,6 +167,7 @@ export async function runPipeline(opts: PipelineOptions): Promise<PipelineResult
metadata: {
language: opts.language || "auto",
model: config.modelSize,
...(await coverage(inputFile, audioInput, result.srtPath)),
},
text: result.text,
};
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/core/whisper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ export function buildWhisperArgs(
const flags = config.whisperFlags;
if (flags.suppressNst) args.push("--suppress-nst");
if (flags.noFallback) args.push("--no-fallback");
args.push("--max-context", String(flags.maxContext));
// An initial prompt *is* text context, so `--max-context 0` throws it away and the run
// comes back byte-identical to one with no prompt at all. Measured on a 90.5s recording:
// identical at 0, and 410 against 414 cues at 64. The default is 0 to stop the model
// carrying its own hallucinations forward between windows, which is worth keeping when
// nothing was asked for; when a prompt was, the room has to exist for it to sit in.
args.push("--max-context", String(prompt && flags.maxContext === 0 ? 64 : flags.maxContext));
args.push("--entropy-thold", String(flags.entropyThold));
args.push("--logprob-thold", String(flags.logprobThold));

Expand Down
51 changes: 51 additions & 0 deletions packages/cli/tests/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "node:path";
import { lastCueEndMs } from "../src/core/audio.ts";
import { stitchSrt } from "../src/core/chunk.ts";
import { presetLanguages, presetPrompt } from "../src/core/prompts.ts";
import { buildWhisperArgs } from "../src/core/whisper.ts";
Expand Down Expand Up @@ -704,6 +705,32 @@ describe("buildWhisperArgs", () => {
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");
Expand Down Expand Up @@ -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);
});
});
Loading