fix(web): stop TTS dying permanently after a speechSynthesis stall - #236
Open
ddfreiling wants to merge 3 commits into
Open
ddfreiling wants to merge 3 commits into
ddfreiling wants to merge 3 commits into
Conversation
The stall watchdog treated `speaking: true` with no `onstart` yet as a wedged engine. That is also what a normal queued utterance looks like: `speaking` flips synchronously inside `speak()` and says nothing about progress. Measured start latency in Chrome is 1-500 ms for local and Google network voices alike, so the 1500 ms threshold left little margin, and two trips ended in a `failure` state with the engine still stuck — playback only returned after a reload. The watchdog now waits 8 s for real silence (no `onstart`, no `onerror`), defers while the engine is paused, and recovers from the previously silent `speaking: false` give-up. Timers moved onto the instance behind a generation counter, so a superseded utterance can no longer cancel its replacement or re-speak after `stop()`. The engine is cancelled back to idle before failure is reported, so a later `play()` works. Every trip logs the voice, its `localService` flag and the engine flags, since the trigger is not reproducible on healthy hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The in-app preview browser accepts `speak()` but never dispatches `onstart`, and leaves `speaking: true` indefinitely — indistinguishable from the engine stall the TTS watchdog looks for, so any TTS conclusion drawn there is worthless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch has not been deployed
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.
Problem
Web TTS could get stuck and never come back. The only way out was a reload.
The stall watchdog in
FlutterTTSNavigatordecided the engine was wedged whenspeaking === trueandonstarthad not fired within 1500 ms. That is also exactly what a perfectly normal queued utterance looks like —speakingflips totruesynchronously insidespeak()and says nothing about progress. On the second trip the watchdog emittedfailureand stopped, leaving the engine still stuck, with nothing in Dart listening forfailureto recover.What I measured
Ran a probe in real Chrome on macOS (201 voices, Google network voices present):
Sara)Also checked, all clean, no watchdog trip: the plugin's own
play()prime/cancel/speak sequence, rapidnext()(cancel-then-speak with no gap), cancel+prime+speak in one tick, a hidden background tab, and long paragraphs (no 15 s cutoff —onendfired at 56.8 s on a network voice).So the stall is rare and I could not reproduce it. That shifts the fix: the problem is not the threshold, it is that the aftermath is unrecoverable.
Side finding: Chrome's remote
Google *voices never fireonboundaryat all, so word-level highlighting silently degrades to paragraph level on those voices. Noted in the file header; worth its own issue.Changes
speaking. It waits for real silence — noonstart, noonerror— for 8 s.speaking === falsewith no events used toreturnwith no log and no state, hanging playback with nobody told. It now recovers.stop()— previouslystop()during the 200 ms recovery window started speech again.play()works instead of inheriting a stuckspeaking: true.localService,speaking/pending/paused, elapsed. The trigger is not reproducible on healthy hardware, so the next real occurrence needs to leave evidence.Tests
New
FlutterTTSNavigator.test.ts, 8 cases on a fakespeechSynthesis: normal start, slow start, single recovery,speaking: falserecovery, paused deferral, failure plus engine reset,stop()during the recovery delay, and a superseded utterance not touching the current one.Verification
bin/typecheck— cleanbin/update_web_example— bundle rebuiltbin/format,bin/analyze— clean across all three packagesNot verified: I never made a real stall happen, so the recovery path is covered by unit tests only, not by a live stall. That was true of the previous implementation too.
Second commit
docs(web): require a real browser for web TTS testing— the in-app preview browser acceptsspeak()but never dispatchesonstartand leavesspeaking: trueforever, which is indistinguishable from the stall this watchdog hunts for. Any TTS conclusion drawn there is worthless, so the webCLAUDE.mdnow says to use a real browser.🤖 Generated with Claude Code