tts-read: speed no longer erases consonants — synthesize at 1.0, time-stretch pitch-preserving - #35
Open
bddap-bot wants to merge 6 commits into
Open
tts-read: speed no longer erases consonants — synthesize at 1.0, time-stretch pitch-preserving#35bddap-bot wants to merge 6 commits into
bddap-bot wants to merge 6 commits into
Conversation
added 6 commits
September 2, 2026 16:45
…speed no longer erases consonants
…k is pushed and located
… pin the short-audio length contract
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes: "Our tts affordance skips some consonants when speed is increased >1."
Mechanism. Kokoro divides each phoneme's predicted duration by
speedbefore rounding it to whole vocoder frames withclamp(min=1); a 2–3-frame consonant becomes one frame and the vocoder never renders its closure or burst. The spectrogram shows the bursts absent from the waveform itself, so the app's sentence chunking and the GStreamer chain are not involved — the bare pipeline degrades the same way (tables below).Fix. Synthesize at speed 1.0 and time-stretch the audio pitch-preserving (WSOLA, ~20 lines of numpy,
stretch). Chunks hold the 1.0 audio and word times; the speed is applied where a chunk is consumed:_pumppushes the stretched samples andlocatescales the chunk duration and word lookup. A speed change therefore no longer re-runs the model over the document —set_speedjust re-seeks. Spin range 0.5–3.0 andDEFAULT_SPEED = 2.0unchanged. Speed is now linear: 2.0 really halves the duration, where the old 2.0 gave 1.83× and the old 3.0 gave 2.06× (the clamp floor). The new 2.0 plays in the time the old 2.5 took; the new 3.0 is much faster than the old 3.0.Ruler. A 140-word passage dense in consonant clusters (passage), rendered through the app path end to end —
Player→ appsrc → audioconvert → audioresample → a clockedidentityin place ofautoaudiosink→ wav (ruler.py) — transcribed with whisper.cpp small.en (-sns), scored by word alignment against the source (score.py). "garbled" = source words not transcribed verbatim; init/final = garbled words whose word-initial / word-final consonant cluster is missing from the transcript. Whisper's own floor on this passage is 5–6 garbled at 1.0.Whisper returns only music tags for genuine 3× speech (also with a 1.0-speed spoken lead-in), so the 3.0 AFTER cell has no transcript; the equal-duration comparison is old 3.0 (30.1 s: 87 garbled) vs new 2.0 (31.4 s: 13). The bare pipeline column and the two rejected candidates are in ruler-scores.txt; per-word garble lists in garble-detail.txt; all transcripts under transcripts/.
Listening (spectrogram, sentence 1 "Strict scripts crumbled through twelve splintered planks"): at 1.0 the stop closures and bursts are distinct dark gaps + onsets; at the old 3.0 the "str-/scr-" clusters are one smeared noise band with no closures; after the fix at 3.0 the closures and bursts are present, compressed. Clips: before 2.0 · after 2.0 · before 3.0 · after 3.0.
Rejected. (a) A per-phoneme frame floor inside the model (2 or 3 frames): consonants survive (14–20 garbled) but the passage only shrinks to 1.1–1.2× at speed 3 — the frame grid is too coarse to shorten speech without erasing consonants, and it means overriding kokoro internals. (b) GStreamer
scaletempowith a rate seek: appsrc is not seekable, instant-rate seeks are refused, and the seekable variant needs seek-data plumbing plus rate-aware position math — more code than the numpy stretch. No TSM library is in the nix python env.Cost. The model now renders full-length audio for every sentence: first-sentence time-to-audio for a 3 s sentence went 0.86 s → 1.3 s on bothouse CPU (8 threads); the stretch itself is ~4 ms per sentence.
Tests.
Stretch: length =int(len/speed)for 0.5–3.0 at an odd length, pitch of a 440 Hz tone preserved (a naive resample fails this), the last samples reach the output, sub-window audio keeps the length/dtype contract, 1.0 is the identity.Locate: speed scales chunk duration and word lookup.Synthesisruns the real model. mypy--strictclean; 16 tests OK innix-build;./home-manager-buildon this tree: exit 0 (bothouse).Live-tested: the full app playback path rendered to file at every speed (the ruler above), on bothouse, headless. Not tested in this PR: a real audio device or the GTK window (untouched apart from
set_speed).Review loop. Correctness round 1: the last output frame was cut, losing up to 50 ms of each sentence tail → the final frame is end-aligned. Premise/design: the short-audio guard crashed for <30 ms audio at speed <1 and the un-stretched early return contradicted the divided word times → one length contract for all inputs; re-synthesis on every speed nudge was structure whose reason this change deleted → speed moved to consumption. Correctness round 2: a speed change after playback ended left stale chunk starts → cleared. Mutation pass in a scratch checkout: 12 mutants (drop the correlation search, drop normalization, drop the end-aligned frame, float64 / wrong length in the short branch, naive resample, drop /speed or *speed in locate, int→round, drop the 1.0 fast path, wrong WSOLA reference frame, hop/4); the int→round survivor is now killed by the odd test length; the wrong-reference-frame mutant survives a pure-tone test by construction and is covered by the ruler, not a unit test.