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
11 changes: 11 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

Notable changes to `@crafter/vcut`. Entries say what changed and, where it is not obvious, what measurement led to it.

## 0.26.0

### Added

- **`verify --windows` now catches a restart whose attempts diverge at word three (#72).** A speaker who backs up mid-clause and starts the phrase over with a different destination leaves a shared prefix and a divergence: "para simplemente cifrar, para simplemente detectar". Six of one recording's annotated defects have this shape and every `RUN_LENGTH` scan in the codebase is blind to it, not because the width is wrong but because normalised, the only runs occurring twice are the **bigrams**. No trigram repeats at all, so `findRepeatedPhrases` returns zero findings on the real text. Dropping to a bare 2-word run is not the fix, and the reason is measurable on the issue's own named strings: the real restart prefixes score 0, 1, 1, 1, 1, 2, 2 content words and the false positives it names score 0, 1, 1, so the two distributions **overlap completely** and no content-word floor separates them. Scoring the grown span or the prefix plus both destinations was measured too and separates them no better (`voy a mostrar / cerrar` scores 3, higher than two of the real restarts). So `findRestarts` takes the escape `findStackedOpeners` already took for the identical problem, structure instead of a lexicon: the prefix must repeat and then diverge (`growMatch` already computes that divergence index as its loop's exit condition and discarded it; for this class that index is the answer), the abandoned attempt must be shorter than the prefix the two attempts share, and the prefix must carry a content word. No constant is picked: the bound on the abandoned attempt is the repeat's own width. Measured against the issue's cases, it fires on "la ia tendria" and "para simplemente" and stays silent on all three connectives it names (`para que`, `voy a`, `de normal`) and on the `le damos clic a Create / a Sign in` reuse `auto-cut.ts` forbids cutting. **Stated limit:** it does not fire on "abrimos la guia para vincular / abrimos la opcion para vincular", whose abandoned attempt runs three words against a two-word shared prefix. Catching that one needs a wider bound, and the corpus that measured the 8-to-64 false-positive figure is not in this repo, so the bound would be fitted to fixtures rather than derived. The honest boundary is documented at `RESTART_PROBE_LENGTH` rather than papered over.

### Fixed

- **A repetition cut spanned both occurrences, so removing it ate the words between them (#73).** The span ran from the first occurrence's first word to the SECOND occurrence's first word, which removes occurrence 1 plus everything spoken in between. That is wrong whenever the speaker got further into the first attempt than the repeated phrase itself, because what sits between the two readings is not only the trailing-off: it is the continuation the surviving sentence was going to complete. Measured while building a corroborated detection pipeline over one master, **5 of 5 repetition cuts ate real words**, one of them removing "cual es la diferencia" outright. The cut now ends at the **seam**, the widest measured gap between two consecutive words in that stretch, which is the micro-pause a speaker leaves when they stop one attempt and start another. No threshold is introduced: the seam is the largest gap present rather than the first gap over some minimum, so this asks where the break is rather than whether a break is big enough, and two readings butting straight up against each other fall back to the retake's own start, which is where the old span already ended and is correct when there is nothing in between to preserve. Verified by transcribing 4s around every junction in source and render: 9 junctions identical, 6 differing only by ASR variance (`click`/`clic`, `aprobar`/`a probar`), **0 words lost**. This shipped no damage yet because `MAX_FUMBLE_WORDS` is 0 and the pass declines nearly everything, which is exactly why the geometry is corrected now: #71 and #72 both raise the sensitivity, and whoever raises it further inherits a cutter that eats speech.
- **`repeatedPhrases` required a repetition to straddle two transcript lines, and found 0 of 12 known repetitions in production (#71).** `semantic.ts` skipped any run it had already seen inside the current line, on the reading that a stutter the transcript kept is one line saying a thing once, badly. That reading assumed a repetition spans lines, and whisper does not transcribe that way: measured across 639 windows over the full 417s of a real recording at seven widths from 4s to 32s, it returns **one cue per window 83% of the time** at the shipped 16s sweep width, so most repeats live inside a single line and were structurally unreachable. The detector found **0 of 12** known repetitions at every width, and the cause was never the window size. A real 16s cue it discarded, verbatim: "Podemos comenzar a probar el MCP en normal. Entonces, para usarlo, simplemente lo taggeamos, arroba normal. Entonces, para usarlo, lo taggeamos." Occurrences inside a line are now counted, which takes recall to **10 of 12** with false positives going 0 to 8, every one of them carrying 2 or more content words, so `MIN_CONTENT_WORDS` is still doing its own job and no threshold moved. The narrower guard the old rule was actually reaching for is kept: occurrences are counted non-overlapping, so "eh eh eh eh" is one hesitation seen through two sliding probes rather than two sayings of it. `count` now reports occurrences rather than lines, which is also what `survivingRepeats` needs, since it compares that number against occurrences in the rendered master's own text. `verify.ts`'s own `findRepeatedPhrases` never had this defect: it scans a window's raw text and already counts within it.

## 0.25.0

### Added
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/vcut",
"version": "0.25.0",
"version": "0.26.0",
"description": "Cut dead air out of a recording, reproducibly. Agent-first CLI over ffmpeg.",
"type": "module",
"license": "MIT",
Expand Down
84 changes: 76 additions & 8 deletions packages/cli/src/auto-cut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@
* own annotation of that entry writes the fuller phrasing first and the tighter one second, and
* every repetition entry on the list reads the same way: the first pass is the one that trails
* off. Removing the later occurrence would keep the abandoned attempt and delete the landing,
* which destroys the good take. So the span cut runs from the first occurrence's first word to
* the second occurrence's first word, leaving the second reading whole.
* which destroys the good take. So the span cut runs from the first occurrence's first word to the
* SEAM between the abandoned attempt and the retake (#73, `seamBetween`), leaving the second
* reading whole along with anything the speaker said after abandoning: running it to the second
* occurrence's own start instead removed those words too, and measured over one master that ate
* real speech in 5 of 5 repetition cuts.
*
* Legitimate reuse is excluded by three tests, and the measurement said all three were needed.
* #57's content-word floor is re-applied here rather than inherited (`mergeRepeats` folds in
Expand Down Expand Up @@ -629,14 +632,77 @@ export const continuesTheSame = (
return between.length <= MAX_FUMBLE_WORDS
}

/**
* The seam between an abandoned attempt and the retake that replaces it: the point the repetition
* cut ends at (#73).
*
* The cut used to run from the first occurrence's first word to the SECOND occurrence's first
* word, which removes occurrence 1 plus every word spoken between the two readings. That is wrong
* whenever the speaker got further into the first attempt than the repeated phrase itself, because
* what sits between the readings is not only the trailing-off: it is the continuation the surviving
* sentence was going to complete. Measured while building a corroborated detection pipeline over
* one master, **5 of 5 repetition cuts ate real words**, one of them removing "cual es la
* diferencia" outright.
*
* The seam is the widest measured gap between two consecutive words in that stretch, which is the
* micro-pause a speaker leaves when they stop one attempt and start another. Ending the cut there
* removes the abandoned attempt and leaves everything after it standing, so the retake arrives with
* the words it needs rather than with a hole in front of it.
*
* No threshold is introduced. The seam is chosen as the largest gap present rather than the first
* gap over some minimum duration, so this asks "where is the break" rather than "is this break big
* enough", and a stretch with no gap at all (two readings butting up against each other, the only
* shape `MAX_FUMBLE_WORDS = 0` admits today) falls back to the retake's own start, which is where
* the old span already ended and is correct when there is nothing in between to preserve.
*
* Verified by transcribing 4s around every junction in source and render after the change: 9
* junctions identical, 6 differing only by ASR variance (`click`/`clic`, `aprobar`/`a probar`),
* 0 words lost.
*/
export const seamBetween = (
words: Word[],
first: { startMs: number; endMs: number },
second: { startMs: number; endMs: number },
): number => {
// Everything spoken after the first reading and before the retake begins. With
// MAX_FUMBLE_WORDS at 0 this is empty today, which is exactly why #73 is latent rather than
// shipping: raising the sensitivity is what exposes the geometry.
const between = words
.filter((word) => word.startMs >= first.endMs && word.startMs < second.startMs)
.sort((left, right) => left.startMs - right.startMs)
if (between.length === 0) {
return second.startMs
}
// Walk the boundaries from the end of the abandoned attempt through to the retake, and take the
// widest one. The candidates include the gap before the first intervening word and the gap after
// the last, so a speaker who pauses immediately after abandoning is handled the same way as one
// who trails off through a word or two first.
let seamMs = second.startMs
let widestMs = -1
let previousEndMs = first.endMs
for (const word of between) {
const gapMs = word.startMs - previousEndMs
if (gapMs > widestMs) {
widestMs = gapMs
seamMs = word.startMs
}
previousEndMs = Math.max(previousEndMs, word.endMs)
}
const trailingGapMs = second.startMs - previousEndMs
if (trailingGapMs > widestMs) {
seamMs = second.startMs
}
return seamMs
}

/**
* Exact repeated runs, cutting the EARLIER occurrence and keeping the LATER one.
*
* See the header for why that direction and not the other: the second attempt is the speaker's
* completed one, and reversing this deletes the landing and keeps the fumble. The removed span
* runs from the first occurrence's first word to the second occurrence's first word, so everything
* between the two readings (the trailing-off, the "eh", the breath) goes with it and the second
* reading survives whole.
* completed one, and reversing this deletes the landing and keeps the fumble. The removed span runs
* from the first occurrence's first word to the SEAM between the abandoned attempt and the retake
* (#73, see `seamBetween`), not to the second occurrence's own start: ending it there removes the
* words the speaker said in the failed attempt that belong to the sentence that survives.
*
* Only `repeatedPhrases` is read. `discountedRepeats` is #57's content-word floor already having
* decided a finding is connective tissue, and legitimate reuse spread across a render is excluded
Expand Down Expand Up @@ -689,10 +755,12 @@ export const repetitionCuts = (
if (!continuesTheSame(finding.words, first, second)) {
continue
}
const measurement = `"${finding.phrase}" x${finding.count}, first reading ${(first.startMs / 1000).toFixed(2)}-${(first.endMs / 1000).toFixed(2)}s, second at ${(second.startMs / 1000).toFixed(2)}s, ${(gapMs / 1000).toFixed(2)}s apart; earlier removed, later kept`
// #73: the seam, not the retake's start. Everything after the abandoned attempt survives.
const seamMs = seamBetween(finding.words, first, second)
const measurement = `"${finding.phrase}" x${finding.count}, first reading ${(first.startMs / 1000).toFixed(2)}-${(first.endMs / 1000).toFixed(2)}s, second at ${(second.startMs / 1000).toFixed(2)}s, ${(gapMs / 1000).toFixed(2)}s apart; earlier removed to the seam at ${(seamMs / 1000).toFixed(2)}s, later kept`
cuts.push({
startMs: first.startMs,
endMs: second.startMs,
endMs: seamMs,
kind: 'repetition',
instrument: 'repetition',
measurement,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,7 @@ export const commitCommand = async (argv: string[]): Promise<void> => {
autoCutSummary = rebuilt.summary
writeFileSync(args.edlPath, `${JSON.stringify(finalEdl, null, 2)}\n`)
writeRound(roundDir, finalEdl, autoCutSummary)
finalRender = await runRender(finalEdl as Edl, renderOptions)
finalRender = await runRender(finalEdl as Edl, { ...renderOptions, allowExisting: true })
}
}

Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,33 @@ export const parseFields = (args: string[]): string[] | null => {
.filter((path) => path.length > 0)
}

// Every global flag a command inherits without declaring it: the four output flags plus the
// two a command answers before parsing anything of its own.
const GLOBAL_FLAGS = ['--json', '--human', '--fields', '--jq', '--help', '--version']

// A flag a command does not read is a question it did not answer. `silences --threshold -80`
// ran, exited 0, and reported `thresholdDb: -30` back — the default, formatted exactly like a
// measurement, with the real flag named --noise. Nothing downstream could tell the difference
// between a threshold that was honoured and one that was never seen (#67).
//
// This is the same silent-acceptance bug requireJson and requireRawOutput each close for one
// flag, widened to the flags a command declares itself. Values are skipped rather than
// validated: a command's own parser owns what its flags mean, and this only asks whether the
// flag exists at all. A bare `-` or a negative number reads as a value, never as a flag, so
// `--noise -30` does not report -30 as unknown.
export const rejectUnknownFlags = (args: string[], known: string[], commandLabel: string): void => {
const allowed = new Set([...known, ...GLOBAL_FLAGS])
const unknown = args.filter(
(arg) => arg.startsWith('--') && !allowed.has(arg.split('=')[0] as string),
)
if (unknown.length > 0) {
const named = unknown.join(', ')
throw new UsageError(
`${commandLabel} does not take ${named}; see \`vcut ${commandLabel} --help\``,
)
}
}

// --jq <expr> alongside --jq=<expr>, the same two forms --fields reads. Only the last flag
// wins when repeated, matching how `value()` helpers across every command read a flag today.
export const parseJqExpr = (args: string[]): string | null => {
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/render-edl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,12 @@ export type RenderOptions = {
// this CLI prints; a test swaps this in to capture the lines instead of the process's own
// stderr.
onProgressLine?: (line: string) => void
// A caller that renders the same round twice on purpose — commit folds its deterministic cuts
// into the EDL and renders again over the same path — is not the accident the existing-output
// guard protects against. The render still writes to a temp sibling and still has to pass
// probeOutput, so the file already on disk survives a failed second attempt: only the final
// rename replaces it.
allowExisting?: boolean
}

export type RenderResult = {
Expand Down Expand Up @@ -1074,7 +1080,7 @@ export const runRender = async (edl: Edl, options: RenderOptions): Promise<Rende
}
// A dry run writes nothing, so refusing it because the output is already there leaves no way
// to inspect the command after a failed attempt — which is exactly when it is needed.
if (!options.dryRun && existsSync(outputPath)) {
if (!options.dryRun && !options.allowExisting && existsSync(outputPath)) {
errors.push('output already exists')
}
if (errors.length > 0) {
Expand Down
Loading
Loading