Skip to content

feat(silero-native): precise word timestamps from dur_hat (#145) - #181

Merged
xilec merged 5 commits into
mainfrom
xilec/dur_hat
Aug 2, 2026
Merged

feat(silero-native): precise word timestamps from dur_hat (#145)#181
xilec merged 5 commits into
mainfrom
xilec/dur_hat

Conversation

@xilec

@xilec xilec commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Summary

Replace the char-proportional word-timestamp estimator in the
silero-native engine with exact timestamps derived from the model's own
duration predictor (dur_hat, the 4th output of tts_main.onnx), fixing
highlight drift on long texts (#145). Piper and ttsd keep their existing
approximate estimators.

Changes

  • build_sequence now returns per-symbol char provenance
    (BuiltSequence { ids, chars }, sos/eos included).
  • Engine::synthesize reads dur_hat, converts it to the exact integer
    frame counts the exported graph renders (trunc(dur + 0.5)) and
    returns Vec<SymbolDuration> per chunk; typed error if the output is
    missing.
  • timestamps.rs rewritten: letter-level alignment of the original
    text's words to the symbol stream (ё ≡ е, non-letters skipped,
    attached + stress markers folded into the word's onset, letters the
    frontend dropped are skipped without consuming the stream so one
    unmapped word cannot misalign the chunk, zero-length fallback for
    unspoken words). WordTimestamp contract unchanged.
  • Parity fixtures regenerated with reference sequence + dur_hat;
    new bundle-gated suite: frame counts match the reference and the
    rendered waveform, word onsets and the last word's end within 50 ms
    of the reference, sample-rate invariance (8/24/48 kHz), and a
    playback-emulation regression test asserting highlighting never
    jumps ahead.
  • OpenSpec change archived; specs/silero-native-engine synced.

Bugs found and fixed during the manual pass

  • Standalone literal + ("правило + команда") cascaded following words
    into zero-length timestamps → attached-only + rule.
  • Mixed words with dropped latin letters ("get_variablesслэш") shifted
    the whole chunk by one word → skip-dropped-letter alignment.
  • Unspoken word before a vowel-initial stressed word ("usb яблоко" →
    "+яблоко") stole the marker frames → deferred + fold.

Validation

  • cargo test --manifest-path silero-native/Cargo.toml green (68 unit +
    bundle-gated parity tier)
  • just lint green
  • Full-text playback emulation (1103 s audio, 2412 words, 150 ms
    steps): 0 highlight jumps
  • Manual pass: highlighting tracks narration on the problem text

xilec added 5 commits August 2, 2026 13:12
Replace the char-proportional estimation with timestamps derived from
the model's own duration predictor: tts_main's 4th output dur_hat gives
per-symbol frame durations (12.5 ms @ 48 kHz); the engine converts them
to the exact integer frame counts the graph renders (trunc(dur + 0.5),
sos/eos clamps baked in) and pairs each symbol with the char it was
emitted from (build_sequence provenance).

timestamps.rs aligns the original chunk text's words to that symbol
stream letter-by-letter (ё ≡ е; a leading '+' stress-marker run opens
the word range — the model renders real audio frames for it), so word
boundaries follow the model's own durations and punctuation pauses
become gaps. The outward contract is unchanged (WordTimestamp, ms
rounding, sorted non-overlapping, original_pos via char_mapping).

- bundle-gated parity: per-symbol frames match the Python ONNX
  reference, frame sum reproduces the waveform length, first-word
  onsets within 50 ms of the reference, timestamps invariant across
  48k/24k/8k output rates
- parity fixtures regenerated with reference dur_hat + sequence
- Piper and the Python ttsd engine keep their existing estimation

Closes #145
…ymbol stream

A word letter that never reached the model stream (latin chars, digits —
the frontend drops them) used to break the alignment loop on the first
mismatch, shifting every following word by one: a mixed word like
"get_variablesслэш" cascaded ~70 words into zero-length timestamps and
the highlight raced ahead. Skip the dropped letter instead of stopping;
the stream cursor now only advances on matches. Also treat standalone
digits/'_' inside words as skippable rather than a stop.

Covered by unit tests, a playback-emulation regression case in the
bundle-gated parity suite, and a full-text emulation run (0 violations).
Sync the delta spec into specs/silero-native-engine (Word Timestamps
requirement rewritten for dur_hat-derived timestamps, six scenarios)
and move the change to changes/archive/.
Archive commits no longer require draft approval; all other
GitHub-bound texts still do.
An attached '+' run was folded into a word's range before its first
letter was checked, so an unspoken word ahead of it (latin "usb" before
the accentor's "+яблоко") stole the marker frames and orphaned them in
a gap. Commit the fold only when the letter after the run matches the
word; otherwise leave the cursor for the next word. Pinned by a
regression test.

Also from the pre-PR review: anchor the parity suite on both timeline
ends (last word end vs the reference cumsum up to the last letter
symbol), drop the per-chunk ids clone via BuiltSequence destructuring,
and clamp ts_start to the chunk duration so end <= duration_sec holds
by construction.
@xilec
xilec merged commit c6bfe2b into main Aug 2, 2026
6 checks passed
@xilec
xilec deleted the xilec/dur_hat branch August 2, 2026 18:33
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