You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
sentence-final .!?…。!?;‼⁇⁈⁉ — always, after peeling trailing wrappers "'”’»)]} so ("done.") still breaks.
clause ,;:,;:、 — only once the cue is already ≥ min_clause_chars.
Without the threshold, a leading "Well," becomes a one-word cue.
silence gap ≥ max_gap_s.
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.
Context
audio export <transcript.json> --format srt|vttis specified inTRANSCRIBE_CONTRACT.md §4. Its contract is settled: it
is deterministic post-processing, it has no
plan/runsplit, and it failsclosed with exit 2 when the transcript has no
word_boundsrather thaninventing cue bounds from
container_boundsorturn_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.pyin thefred-agentrepo'svideo-editingskill (stdlib-only, ~690 lines, plus a second consumer thatreuses its functions for an edited timeline). The parameters and traps below come
from reading it. It has no diarization, so its
speaker_idis 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:
max_cue_smax_charsmax_line_charsmax_linesmax_wordsmax_gap_smin_clause_charshold_out_sMIN_CUE_SNote two different things both get called "minimum gap":
max_gap_sis inputsilence that forces a break,
MIN_CUE_Sis minimum on-screen time. There is nominimum 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:
Must outrank sentence-final, since a cue spanning two speakers is wrong even
mid-sentence.
.!?…。!?;‼⁇⁈⁉— always, after peeling trailing wrappers"'”’»)]}so("done.")still breaks.,;:,;:、— only once the cue is already ≥min_clause_chars.Without the threshold, a leading "Well," becomes a one-word cue.
max_gap_s.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
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 showforever. 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 thanemit a zero-length one. The timestamp formatter and the seconds→ms converter
must share the same rounding or the guarantee does not hold.
perceived sync. Same rule for the
MIN_CUE_Sfloor and the hold-out.so all of them reason about joined text and the newline stays presentational.
minimise
abs(len(head) - len(tail)). Greedy leaves a runt second line. Nousable boundary means one over-long line plus a warning — never a mid-token
split.
recording's picture can outlast its audio, and the container value lets the
final cue hang past the last sample.
utf-8andnewline="\n"explicitly and write atomically.Decide on a BOM: they emit none, some SRT players want one.
Measured win worth reproducing
hold_out_sextends 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
(
"Fred,", not"Fred"+","), or break rule 1 cannot work. This is aconstraint on
word_boundsintranscribe, not onexport— needsasserting at the adapter boundary for every stack.
len(str), i.e. codepoints, so a fullwidth char and a Latin char bothcount 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.
cues,
WEBVTTheader,.for,, no voice tags, noSTYLE/REGION/NOTE. TRANSCRIBE_CONTRACT.md currently promises voice tags whenspeaker_attributionis present, which means committing to the former.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_boundsmay behave the same way,while FireRed's native times may emit real pauses. Verify per stack before
tuning
max_gap_s.export. Their exporter refuses toremap onto an edited timeline and the renderer owns that arithmetic alone,
to avoid two copies drifting. Same rule here if
auditiongrows trim orconcat. 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|vttcan ship with these defaults hard-coded and no flags atall; 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, secondconsumer at
scripts/render_local.py(_build_master_srt_natural), tests attests/test_export_captions.pyandtests/test_schema_contract.py. The latterpushes real recorded ASR output through the real pipeline instead of
hand-written fixture JSON, which is the pattern worth mirroring.