Skip to content

Fix Chrome TTS silence: cancel/speak race condition and user activation#2

Draft
Copilot wants to merge 2 commits intomasterfrom
copilot/fix-tts-not-being-heard
Draft

Fix Chrome TTS silence: cancel/speak race condition and user activation#2
Copilot wants to merge 2 commits intomasterfrom
copilot/fix-tts-not-being-heard

Conversation

Copy link
Copy Markdown

Copilot AI commented Mar 19, 2026

Web Speech API (speechSynthesis) produced no audio in Chrome while Web Audio API SFX worked fine. Two Chrome-specific issues in speech-interop.js were the cause.

Root Causes

  • Cancel/speak race: cancel() was called synchronously before speak() in the same frame. Chrome silently drops the new utterance — onend fires immediately with no audio.
  • User activation gap: In Blazor Server, all JS interop travels via SignalR (not a user-gesture context). Chrome requires at least one speak() call from a direct interaction handler before permitting calls from async contexts.

Changes

  • speech-interop.js
    • Added 50ms setTimeout between cancel() and speak() to let Chrome's engine process the cancel before accepting the next utterance
    • Moved SpeechSynthesisUtterance construction and configuration outside the Promise executor (after the delay), keeping everything sequential
    • Added activateSpeechSynthesisOnce() — a self-removing click/keydown document listener that speaks a silent dummy utterance on first user interaction, satisfying Chrome's activation requirement for all subsequent SignalR-driven calls
    • Extracted shared constants (SPEECH_ACTIVATION_TEXT, SPEECH_ACTIVATION_RATE, SPEECH_ACTIVATION_VOLUME) used by both the auto-activation listener and the public prime() API method
// Before: speak() called in same synchronous frame as cancel() → silent failure
window.speechSynthesis.cancel();
return new Promise((resolve, reject) => {
    ...
    window.speechSynthesis.speak(utterance); // Chrome drops this
});

// After: 50ms gap lets Chrome clear its queue
window.speechSynthesis.cancel();
await new Promise(resolve => setTimeout(resolve, 50));
const utterance = new SpeechSynthesisUtterance(text);
// configure utterance...
return new Promise((resolve, reject) => {
    ...
    window.speechSynthesis.speak(utterance); // Chrome accepts this
});

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • v2.jokeapi.dev
    • Triggering command: /usr/share/dotnet/dotnet /usr/share/dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/PoJoker/PoJoker/tests/Po.Joker.Tests.Integration/bin/Debug/net10.0/Po.Joker.Tests.Integration.runtimeconfig.json --depsfile /home/REDACTED/work/PoJoker/PoJoker/tests/Po.Joker.Tests.Integration/bin/Debug/net10.0/Po.Joker.Tests.Integration.deps.json /home/REDACTED/work/PoJoker/PoJoker/tests/Po.Joker.Tests.Integration/bin/Debug/net10.0/testhost.dll --port 46553 --endpoint 127.0.0.1:046553 --role client --parentprocessid 5112 --telemetryoptedin false (dns block)

If you need me to access, download, or install something from one of these locations, you can either:


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Co-authored-by: punkouter26 <121304072+punkouter26@users.noreply.github.com>
// Calling speak() synchronously after cancel() causes Chrome to silently
// drop the new utterance (known Chrome Web Speech API bug).
window.speechSynthesis.cancel();
await new Promise(resolve => setTimeout(resolve, 50));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential Race Condition in Speech Cancellation

The use of a fixed 50ms delay after window.speechSynthesis.cancel() (line 139) is intended to avoid a Chrome bug where a new utterance is dropped if spoken immediately after cancellation. However, this approach is not robust, as the actual time required for cancellation may vary depending on browser and system conditions. This could lead to intermittent failures where utterances are still dropped.

Recommended Solution:
Instead of a fixed delay, consider polling window.speechSynthesis.speaking or using the onend event of a dummy utterance to ensure cancellation is fully processed before proceeding:

window.speechSynthesis.cancel();
await new Promise(resolve => {
    const check = () => {
        if (!window.speechSynthesis.speaking) {
            resolve();
        } else {
            setTimeout(check, 10);
        }
    };
    check();
});

This approach waits until speech synthesis is truly idle before starting the new utterance.

Comment on lines +179 to +184
window.speechSynthesis.resume();
// Speak a silent dummy utterance at high rate so it ends instantly
const dummy = new SpeechSynthesisUtterance(SPEECH_ACTIVATION_TEXT);
dummy.volume = SPEECH_ACTIVATION_VOLUME;
dummy.rate = SPEECH_ACTIVATION_RATE;
window.speechSynthesis.speak(dummy);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary Dummy Utterance in 'prime' Method

The prime method (lines 179-184) always speaks a dummy utterance to activate speech synthesis, regardless of whether speech synthesis is already active. This could result in unnecessary utterances and may interfere with ongoing speech.

Recommended Solution:
Check if speech synthesis is already active before speaking the dummy utterance:

if (!window.speechSynthesis.speaking) {
    const dummy = new SpeechSynthesisUtterance(SPEECH_ACTIVATION_TEXT);
    dummy.volume = SPEECH_ACTIVATION_VOLUME;
    dummy.rate = SPEECH_ACTIVATION_RATE;
    window.speechSynthesis.speak(dummy);
}

This ensures the dummy utterance is only spoken when needed.

Copilot AI changed the title [WIP] Fix text-to-speech not being heard at PoJoker site Fix Chrome TTS silence: cancel/speak race condition and user activation Mar 19, 2026
Copilot AI requested a review from punkouter26 March 19, 2026 22:46
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.

2 participants