Skip to content

export: cue segmentation for --format srt|vtt #10

Description

@fyang0507

Context

audio export <transcript.json> --format srt|vtt is specified in
TRANSCRIBE_CONTRACT.md §4. Its contract is settled: it
is deterministic post-processing, it has no plan/run split, and it fails
closed with exit 2 when the transcript has no word_bounds
rather than
inventing cue bounds from container_bounds or turn_bounds.

What is not settled is cue segmentation — turning timed words into cues. This
issue holds that design so it does not block the v1 transcription work.

A working reference exists: export_captions.py in the fred-agent repo's
video-editing skill (stdlib-only, ~690 lines, plus a second consumer that
reuses its functions for an edited timeline). The parameters and traps below come
from reading it. It has no diarization, so its speaker_id is always null —
every speaker-related item below is new work for us, not solved there.

Parameters to expose

Their defaults, as a starting point rather than a conclusion:

Parameter Latin CJK
max_cue_s 6.0 6.0
max_chars 84 (42 × 2) 36 (18 × 2)
max_line_chars 42 18
max_lines 2 1 (no spaces to wrap on)
max_words 24 unlimited — sub-word units make counts meaningless
max_gap_s 0.8 0.8
min_clause_chars 20 20
hold_out_s 0.5 0.5
MIN_CUE_S 0.8 (floor) 0.8 (floor)

Note two different things both get called "minimum gap": max_gap_s is input
silence that forces a break, MIN_CUE_S is minimum on-screen time. There is no
minimum gap between rendered cues; adjacent cues may butt exactly.

Break priority — the main quality lever

Strongest first. Getting the order wrong is what makes cues read as flashcards:

  1. speaker change — unconditional. New for us; they had no speaker labels.
    Must outrank sentence-final, since a cue spanning two speakers is wrong even
    mid-sentence.
  2. sentence-final .!?…。!?;‼⁇⁈⁉ — always, after peeling trailing wrappers
    "'”’»)]} so ("done.") still breaks.
  3. clause ,;:,;:、 — only once the cue is already ≥ min_clause_chars.
    Without the threshold, a leading "Well," becomes a one-word cue.
  4. silence gap ≥ max_gap_s.
  5. character / word / duration caps.

Their measured before/after on a 92-word reference: caps-only grouping gave 51
cues at ~1.8 words; full priority order gave 13 cues at 7.1 words.

Accumulator is two-phase deliberately: test caps before appending a word so a
cue never overshoots, apply punctuation breaks after so the break lands past
the mark.

Invariants, not formatting details

  • Millisecond quantization is its own pass. Everything upstream is float
    seconds; writers round to whole ms. A sub-half-ms cue renders
    00:00:00,000 --> 00:00:00,000, which players variously skip, merge, or show
    forever. Rounding can also turn a just-touching pair into an overlap. Snap every
    boundary to the grid, carry a cursor so a start cannot precede the previous end,
    force end_ms >= start_ms + 1, and drop a cue with no room rather than
    emit a zero-length one. The timestamp formatter and the seconds→ms converter
    must share the same rounding or the guarantee does not hold.
  • De-overlap by moving ends, never starts. Starts are word-accurate and drive
    perceived sync. Same rule for the MIN_CUE_S floor and the hold-out.
  • Wrap last. Line breaking runs after every length, gap, and invariant rule,
    so all of them reason about joined text and the newline stays presentational.
  • Balanced wrap, not greedy. For two lines, try every word boundary and
    minimise abs(len(head) - len(tail)). Greedy leaves a runt second line. No
    usable boundary means one over-long line plus a warning — never a mid-token
    split.
  • Clamp to decoded audio duration, not container duration. A screen
    recording's picture can outlast its audio, and the container value lets the
    final cue hang past the last sample.
  • Encoding. Pin utf-8 and newline="\n" explicitly and write atomically.
    Decide on a BOM: they emit none, some SRT players want one.

Measured win worth reproducing

hold_out_s extends each cue past its last word into following silence only,
ceilinged by the next cue's start. Their 0.0 → 0.5 s change moved median reading
speed from 20.8 to 16.8 chars/sec and cues above the 20 c/s comfort line from 9
of 13 to 4. Saturates around 0.8 s, and is a no-op on continuous speech by
construction.

Open decisions

  • Punctuation must stay attached to its word in the transcript schema
    ("Fred,", not "Fred" + ","), or break rule 1 cannot work. This is a
    constraint on word_bounds in transcribe, not on export — needs
    asserting at the adapter boundary for every stack.
  • Width-aware character counting. Their honest gap: counting is
    len(str), i.e. codepoints, so a fullwidth char and a Latin char both
    count 1, which under-counts a mixed Han/Latin line. The 18-vs-42 split is a
    blunt stand-in. Consider unicodedata.east_asian_width (W/F = 2) instead.
    Relevant because Mandarin/English code-switching is a core requirement here.
  • Is VTT a real format or SRT-with-dots? They chose the latter — same
    cues, WEBVTT header, . for ,, no voice tags, no STYLE/REGION/
    NOTE. TRANSCRIBE_CONTRACT.md currently promises voice tags when
    speaker_attribution is present, which means committing to the former.
  • Per-backend gap tuning. Their docstring records the gap rule as nearly
    inert on Whisper-style output: word spans butt end-to-start, median
    inter-word gap 0.000 s, only 9 of 91 above 0.3 s and those already land on
    sentence ends. Our aligner-derived word_bounds may behave the same way,
    while FireRed's native times may emit real pauses. Verify per stack before
    tuning max_gap_s.
  • Timeline remapping stays out of export. Their exporter refuses to
    remap onto an edited timeline and the renderer owns that arithmetic alone,
    to avoid two copies drifting. Same rule here if audition grows trim or
    concat. Related: they group per segment and never across a cut, and remap
    to output time before grouping, because inside a 4× ramp a 1 s pause is a
    0.25 s pause.

Not blocking v1

export --format srt|vtt can ship with these defaults hard-coded and no flags at
all; the refusal path already prevents the dangerous failure mode. Timing quality
is separately unvalidated — boundary MAE/P95 is unmeasured for both FireRed's
native word times and the forced aligner — so these files are producible but not
yet claimed broadcast-acceptable.

Reference

  • fred-agent: .agents/skills/video-editing/scripts/export_captions.py, second
    consumer at scripts/render_local.py (_build_master_srt_natural), tests at
    tests/test_export_captions.py and tests/test_schema_contract.py. The latter
    pushes real recorded ASR output through the real pipeline instead of
    hand-written fixture JSON, which is the pattern worth mirroring.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions