Skip to content

fix(web): stop TTS dying permanently after a speechSynthesis stall - #236

Open
ddfreiling wants to merge 3 commits into
Notalib:mainfrom
ddfreiling:fix/web-tts-stall-watchdog
Open

ddfreiling wants to merge 3 commits into
Notalib:mainfrom
ddfreiling:fix/web-tts-stall-watchdog

Conversation

@ddfreiling

Copy link
Copy Markdown
Member

Problem

Web TTS could get stuck and never come back. The only way out was a reload.

The stall watchdog in FlutterTTSNavigator decided the engine was wedged when speaking === true and onstart had not fired within 1500 ms. That is also exactly what a perfectly normal queued utterance looks like — speaking flips to true synchronously inside speak() and says nothing about progress. On the second trip the watchdog emitted failure and stopped, leaving the engine still stuck, with nothing in Dart listening for failure to recover.

What I measured

Ran a probe in real Chrome on macOS (201 voices, Google network voices present):

Condition Start latency
Local Danish voice (Sara) 1–99 ms
macOS Enhanced voice 3–8 ms
Google network voice 214–489 ms

Also checked, all clean, no watchdog trip: the plugin's own play() prime/cancel/speak sequence, rapid next() (cancel-then-speak with no gap), cancel+prime+speak in one tick, a hidden background tab, and long paragraphs (no 15 s cutoff — onend fired 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 fire onboundary at all, so word-level highlighting silently degrades to paragraph level on those voices. Noted in the file header; worth its own issue.

Changes

  • The trigger no longer uses speaking. It waits for real silence — no onstart, no onerror — for 8 s.
  • Defers while paused. A paused engine is legitimately silent.
  • No more silent give-up. speaking === false with no events used to return with no log and no state, hanging playback with nobody told. It now recovers.
  • Timers live on the instance behind a generation counter. A superseded utterance can no longer cancel its replacement, and a pending recovery can no longer re-speak after stop() — previously stop() during the 200 ms recovery window started speech again.
  • Reset before failure. The engine is cancelled back to idle, so a later play() works instead of inheriting a stuck speaking: true.
  • Diagnostics on every trip — voice name, 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 fake speechSynthesis: normal start, slow start, single recovery, speaking: false recovery, paused deferral, failure plus engine reset, stop() during the recovery delay, and a superseded utterance not touching the current one.

Verification

  • bin/typecheck — clean
  • Full web suite — 291 passed, 19 suites
  • bin/update_web_example — bundle rebuilt
  • bin/format, bin/analyze — clean across all three packages

Not 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 accepts speak() but never dispatches onstart and leaves speaking: true forever, which is indistinguishable from the stall this watchdog hunts for. Any TTS conclusion drawn there is worthless, so the web CLAUDE.md now says to use a real browser.

🤖 Generated with Claude Code

ddfreiling and others added 3 commits September 18, 2026 00:01
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

No deployments
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