Skip to content

Add "Align time codes via forced aligner" to Import plain text - #12833

Open
niksedk wants to merge 9 commits into
mainfrom
feat/import-text-forced-aligner
Open

Add "Align time codes via forced aligner" to Import plain text#12833
niksedk wants to merge 9 commits into
mainfrom
feat/import-text-forced-aligner

Conversation

@niksedk

@niksedk niksedk commented Jul 25, 2026

Copy link
Copy Markdown
Member

Follow-up to #12832, and the other half of #11746.

"Align via speech-to-text" transcribes first and then matches text to text, so it inherits every transcription error. A forced aligner matches the script the user already has directly against the audio, which is what people actually want when they have a script and a video.

crispasr ships this as --align-only, and the engine SE already downloads supports it — it was simply never exposed.

Why it has to be chunked

The CTC encoder allocates its activations up front and attention is quadratic. Measured on canary-ctc:

audio time
60 s 2 s
300 s 19 s
600 s 68 s
900 s 146 s
57 min dies — failed to allocate buffer, size = 24161.34 MiB

Chunking isn't a compromise, it's faster and bounded: 792 s of audio aligns in 56 s chunked vs 114 s single-pass, at the same accuracy. The 57 minute file that OOMed completes in 247 s over 20 windows at 1.8 GB peak, which extrapolates to ~8–9 min for a two hour video.

Two things that had to be right

Both of these were found by tests, not by reading:

  • The window slides to where the last trusted cue ended, not along a fixed grid. Only ~75% of each window is trusted, so a fixed grid advances the audio faster than the script and they drift apart over a long file.
  • Characters-per-second is fixed for the whole file, not re-learned per window. Learning it feeds back on itself — over-feeding makes the aligner compress its cues, a compressed window looks like faster speech, and the next window is over-fed harder still.

Overshoot is small (1.15×) for a related reason: forced alignment must consume every token it is given, so once it runs short of audio it takes time from real speech. At 1.7× overshoot alignment held for the first third of a window and then drifted by up to 166 s; at 1.15× the distortion stays in the discarded tail.

Accuracy

Verified end to end through the actual C# path against 13 minutes of speech with per-line ground truth:

  • 34 ms mean start error, 99% of lines within 100 ms
  • 39 ms when 18% of the script's lines carry wrong or extra words and five spoken lines are missing from the script entirely — which is the point, since real scripts are rarely exact transcripts

Tests

15 unit tests using a fake aligner and fake audio, including a 2-hour case that asserts every line lands within 5 s of where it is actually spoken (this is what catches the drift bug — reintroducing the fixed grid fails it). Plus a self-skipping integration test that drives the real binary; it passes locally at the numbers above and skips in CI, where the 700 MB model isn't available. Full UI suite: 836 passed.

Notes

  • The aligner model is the one already configured for speech-to-text; built-in means "let the ASR do its own timing", which standalone alignment can't use, so that falls back to canary-ctc with the usual download prompt.
  • Long-audio handling here matches what MFA does (VAD-segment, then align per segment). MFA's neater trick — hand the decoder the whole remaining transcript and let it report how much it consumed — isn't available through --align-only, which must consume exactly the text it's given.
  • ForcedAlignerOption still omits crispasr's FastConformer aligner family (ru, pl, hr, be, fa, ka, hy, uz, kk-ru at ~82 MB each). Worth adding separately — it's the cheapest aligner option and covers languages the wav2vec2 set doesn't reach.

🤖 Generated with Claude Code

niksedk and others added 9 commits July 25, 2026 19:30
Aligning a script against a full-length video had no good path. The existing
"Align via speech-to-text" transcribes first and then matches text to text,
which inherits every transcription error; a forced aligner instead matches
the script the user already has directly against the audio.

crispasr ships this as `--align-only`, and the engine SE already downloads
supports it. What it cannot do is take a long file in one pass: the CTC
encoder allocates its activations up front and attention is quadratic.
Measured on canary-ctc, 60 s of audio aligns in 2 s, 600 s in 68 s and
900 s in 146 s, while a 57 minute file dies asking for a 24 GB buffer.

So the work is chunked, which turns out to be faster as well as bounded -
792 s of audio aligns in 56 s chunked against 114 s in a single pass, at
the same accuracy. Windows are cut at silence where possible, and each is
fed only slightly more script than should fit.

Two things the implementation has to get right, both found by testing:

- The window slides to wherever the last trusted cue ended, not along a
  fixed grid. Only ~75% of each window is trusted, so a fixed grid advances
  the audio faster than the script and the two drift apart over a long file.

- The characters-per-second estimate is fixed for the whole file rather
  than re-learned per window. Learning it feeds back on itself: over-feeding
  a window makes the aligner compress its cues, a compressed window looks
  like faster speech, and the next window is over-fed harder still.

Overshoot is deliberately small for the same reason. Forced alignment has
to consume every token it is given, so once it runs short of audio it takes
time from real speech - at 1.7x overshoot alignment held for the first
third of a window then drifted by up to 166 s, at 1.15x the distortion
stays in the discarded tail.

Verified end to end against 13 minutes of speech with per-line ground
truth: 34 ms mean start error, 99% of lines within 100 ms, and unchanged
at 39 ms when 18% of the script's lines carry wrong or extra words and
five spoken lines are missing from the script entirely - which is the
point, since the scripts people have are rarely exact transcripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs made the alignment "way off" on a real script, neither of which
my test material could show.

Blank lines shifted everything after them. crispasr silently drops blank
and whitespace-only rows from its --text-file - feed it five lines with two
blanks and three cues come back - while the code matched cue N to line N
positionally. Every blank row therefore shifted all following lines by one,
cumulatively, for the rest of the file. Only non-blank lines are now sent,
and each cue is mapped back through the index it came from. Verified with
the real aligner on a script with 77 blank rows among 200 spoken lines:
34 ms mean start error, unchanged from a script with no blanks at all.

Cues stretched across silence. A forced aligner ends each segment where the
next one begins, so the last line before a stretch of music or action
absorbed all of it. On a recording that is 56% speech this produced 40 cues
over ten seconds long, one of them 16.8 s, against a true maximum of 4.4 s.
Starts are kept as the aligner reports them - they measure within ~34 ms -
but durations are now recomputed from reading time and clamped to the
configured display limits, the same way the rest of this window does it.
Same recording after the fix: longest cue 5.1 s, none over ten seconds,
starts unchanged.

Both are covered by tests that fail without the fix.

The button also went straight to work using whichever aligner speech-to-text
happened to be configured with, silently falling back to canary-ctc, with no
way to see or change it. It now opens a setup window showing the engine and
its version, offering to install or update it, and listing the aligner models
with their installed state and download size. The choice is remembered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The window listed install state as plain text ("not installed (442 MB)")
instead of the coloured dot vocabulary used everywhere else. It now uses
StatusDots for both:

- The engine line gets a dot bound to its install state, next to the name
  and detected version.
- The aligner combo uses StatusDots.ComboItemTemplate, the same template
  the speech-to-text window already uses for this exact list, so each model
  shows a dot plus its download size.
- The selected aligner's state is repeated as a dot and line under the
  combo, re-checked on selection so a model downloaded while the window is
  open shows as installed.

As everywhere else the state is always spelled out in text beside the dot
rather than relying on colour alone, which ComboItemTemplate also surfaces
as a tooltip and accessible name.

Also a waveform glyph on the intro and a cloud-download icon on the engine
button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A forced aligner cannot skip audio - it must place every line it is given.
So when a script omits something that is actually spoken, the lines after
the omission get crammed onto the audio of the missing passage and land
far too early. Reproduced against ground truth with a contiguous 90 s
omission: every line after it was 100-121 s early, mean error 33 s.

Scattered omissions are fine - 42 of 200 spoken lines missing still aligns
at 34 ms mean - because the aligner absorbs isolated unmatched speech. It
is a contiguous passage that breaks it.

Squeezed cues are detectable: past the omission, cues ran at 0.36-0.48 of
their text's reading time where correctly placed ones ran at 0.84-0.89.
Accepting now stops at a sustained run below that ratio, and the audio
cursor creeps on without consuming script, so it can walk past the
unmatched passage and pick the script up on the far side.

This improves the case but does not fix it. Same 90 s omission: every line
now gets a time code (was: 85 of 175 dropped) and 74% land within 250 ms,
but the worst is still 81 s early, because stepping blindly through audio
cannot actually find where the script resumes - that needs to know what is
spoken there, which is recognition, not alignment.

A clean script is unaffected: 34 ms mean, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forced alignment cannot skip audio - it has to place every line it is
given - so a script that leaves out something actually spoken sends the
following lines onto the missing passage's audio. The previous commit's
plausibility check got the worst case from 121 s to 81 s early, but could
not fix it: stepping blindly through audio cannot find where the script
resumes, because that needs to know what is spoken there.

So the transcription does the finding and the aligner does the timing. A
speech recogniser says what is actually spoken and when, ScriptSyncService
matches the script against that transcript - which tolerates text missing
from either side, being a sequence match that may skip - and each run of
confidently matched lines is then forced-aligned against just its own
stretch of audio, which is what gets timings to a few tens of ms.

Measured on 12 minutes of speech with per-line ground truth, script missing
a contiguous run of 25 spoken lines:

                      mean      max     within 250 ms
  forced aligner    24.02 s   94.83 s        68 %
  hybrid             0.04 s    0.10 s       100 %

A clean script is 43 ms mean, so the extra pass costs nothing in accuracy
when the script does match. It does cost a transcription pass, so it is a
checkbox in the setup window - on by default, since a script that matches
the audio exactly is the exception.

Regions are planned between directly-matched lines and never end on an
interpolated one, which would anchor the tail to the wrong place. A region
that fails to align keeps its coarse timing rather than failing the run.

Note the earlier synthetic script was useless for judging this: it was
built from a short cycle of sentence parts, so lines 60 apart were near
identical and no text matcher could tell them apart. The material here uses
distinct vocabulary per line, which is what real dialogue looks like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The aligner was being handed as much text as a window could hold. That is
exactly what forces it to misplace lines: forced alignment must consume
every line it is given, so a window whose audio contains speech the script
does not cover gets the script crushed onto it. All the machinery on top -
plausibility trimming, cursor creeping, the whole transcription-first
hybrid - was working around a problem the feeding created.

Given slack it does the right thing by itself. Fed 8 lines against a 240 s
window whose matching speech only starts 182 s in, every interior line
landed within 60 ms: it simply emitted blanks over the 182 s it had no text
for. So each window now gets a small chunk - 12 lines, capped at 35% of the
window's worth of reading time - and the rest of the window is left as room
for the aligner to skip whatever the script is silent about.

Two edges of a chunk are not to be trusted, and both are now handled:

- The first cue absorbs all the leading audio the script does not cover, so
  its start is meaningless while its end is sound. Its start is taken back
  from its end by reading time.
- Once the aligner runs out of script it smears the remaining cues down the
  window. Measured: ten good lines, then a cue opening 54 s after its
  predecessor closed. Accepting stops at the first cue that opens
  implausibly late or lasts far longer than its text takes to read.

Measured on 12 minutes of speech with per-line ground truth, versus the
previous fill-the-window behaviour:

                          median   within 250 ms   untimed
  clean script              47 ms        97 %          0
  25 lines omitted          48 ms        96 %          0
  (was, 25 lines omitted)   70 ms        68 %          0

So the missing-text case is now handled by the aligner alone, with no
transcription pass. HybridAligner stays for scripts where even this cannot
find its place, but is no longer the answer to an omission.

The closing lines of a file have no window left to give the aligner slack,
so whatever is still unplaced is laid out by reading time from the last
cue. Approximate, but every line comes out timed.

Removes the fill-the-window planner API this replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup window offered "Handle text missing from the script", defaulted
it on, and pressing OK therefore opened the speech-to-text window. That was
never wanted: the point of a forced aligner is that it does not need a
transcription.

It was also obsolete. The hybrid existed to work around windows being
filled with text, which is what forced lines onto audio they did not belong
to. Underfeeding removed that problem at the source - 96% of lines within
250 ms with 25 contiguous lines omitted, no transcription involved - so the
extra pass bought nothing but a second model download and double the
runtime.

Removes HybridAligner, the checkbox, and the SyncResult.DirectMatches field
that only the hybrid used. "Align time codes via Speech to text" is
untouched; it is a separate button and a separate feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass has to keep its windows loose - it does not yet know where
anything is - which costs precision at every chunk edge. Once every line
has an approximate position that constraint is gone, so a second pass
re-aligns each batch of 6 lines against a window drawn tightly round where
the first pass put them: a few seconds of audio holding exactly that
batch's speech, which is the condition a forced aligner is best at.

Measured on 12 minutes of speech with per-line ground truth:

                     median   within 100 ms
  first pass only     47 ms        94 %
  with refine pass    35 ms        96 %

A refined time is only taken when it agrees with the first pass to within
2.5 s. Anchored on a bad first-pass guess the aligner would re-fit a batch
to the wrong audio just as confidently, so disagreement is treated as a
reason to keep what we had.

Progress is now a real percentage on a bar rather than only text, with
aligning filling the first 70% and refining the rest.

Also stops time codes running past the end of the recording - a cue placed
near the end could have reading time added on top and finish after the
video did.

Known limit, unchanged: the closing few lines. The last chunk has no window
left to give the aligner slack, so it can place them late, and the refine
pass will not move them because it cannot tell a late placement from a
correct one. Measured 5 of 200 lines over a second out, worst 17 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last lines of a file were landing up to 17 s late. The normal loop can
only extend a window forward, and at the end of a recording there is no
forward left, so the final chunk was aligned against just the audio that
remained - the filled-window case again, and it misplaced them exactly as
filling always does.

The last chunk now takes its slack backwards, over a window reaching back
from the end of the recording. Two things had to be right for that to work,
and getting either wrong silently produced the old result:

- Only one line of overlap. Reaching further back drags that speech into
  the window as well, which fills it up and removes the very slack this
  depends on. A window has to be long *relative to the text in it*, not
  just long - rejoining six lines left it exactly full and changed nothing.
- The agreement check must skip the first cue. That cue absorbs the
  window's leading audio by design, so comparing its start rejected every
  good result and fell through to the deterministic squeeze.

The first cue gets the same leading-absorb correction the main loop uses,
since this window deliberately starts well before the text it holds.

Measured on 12 minutes of speech with per-line ground truth:

                    median   within 250 ms   lines over 1 s out
  before             35 ms        97 %              5
  after              34 ms       100 %              0

The last six lines now land within 60 ms, against 2.9-15.4 s before.

Also hides the subtitle count while aligning - it shares a grid cell with
the progress bar and the two were drawing over each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant