Skip to content

speechService.getAvailableVoices() can hang forever, leaving the voice picker permanently empty; rate: 0 and pitch: 0 are silently rewritten to 1 #1139

Description

@MOHITKOURAV01

getAvailableVoices() returns a promise that, on the most common browser path, may never
settle:

// src/services/speechService.js
export const getAvailableVoices = () => {
    return new Promise((resolve) => {
        if (!isSpeechSupported()) { resolve([]); return; }

        let voices = window.speechSynthesis.getVoices();
        if (voices.length > 0) {
            resolve(voices);
        } else {
            window.speechSynthesis.onvoiceschanged = () => {
                voices = window.speechSynthesis.getVoices();
                resolve(voices);
            };
        }
    });
};

speechSynthesis.getVoices() returning [] on first call is the normal case in Chrome
and Edge — the voice list is populated asynchronously. So the else-branch is the branch
that runs. Three problems live in it:

It can hang forever. voiceschanged is not guaranteed to fire. It does not fire in
Firefox when the list is already final, it does not fire in headless Chrome with no speech
engine installed, and it does not fire in jsdom. The promise stays pending, and
useVoiceSynthesis's mount effect —

getAvailableVoices().then(setVoices);

— never resolves. The voice dropdown in VoiceAlertManager stays permanently empty with
no error, no fallback, and nothing to retry. Because there is no timeout, this is
indistinguishable from "this device has no voices".

It clobbers a global. window.speechSynthesis.onvoiceschanged = ... is a single
assignment slot, not a listener list. Two concurrent callers and the first one's resolve is
overwritten and never called. Nothing ever clears the handler either, so it survives for
the life of the page and holds its closure — including the resolved promise's resolve
alive.

It can still resolve empty. getVoices() inside the handler is not guaranteed to be
populated on the first voiceschanged; some engines fire it more than once as lists load.
The first fire wins and resolves [].

Second defect: rate: 0 and pitch: 0 are silently rewritten to 1

utterance.rate   = config.rate   || 1;
utterance.pitch  = config.pitch  || 1;
utterance.volume = config.volume !== undefined ? config.volume : 1;

volume gets the correct guard. rate and pitch get ||, so 0 — a value the Web
Speech API accepts, and the value the config slider's minimum produces — falls through to
1. Someone who drags rate to the bottom gets normal speed with no indication why. The
same || also lets an out-of-range value through unchecked: the spec range is 0.1–10 for
rate and 0–2 for pitch, and anything outside that makes speak() throw a
SyntaxError that surfaces as an unhandled rejection.

Reproduce

// any environment where voiceschanged does not fire — jsdom, headless Chrome, Firefox
window.speechSynthesis.getVoices = () => [];
const p = getAvailableVoices();
await Promise.race([p, new Promise(r => setTimeout(() => r('TIMED OUT'), 2000))]);
// -> 'TIMED OUT'

Expected

  • getAvailableVoices() always settles, with the best list it could get, within a bounded
    time.
  • Concurrent callers all get an answer.
  • The global onvoiceschanged slot is left as it was found.
  • rate: 0 / pitch: 0 are handled as deliberate values, and out-of-range values are
    clamped rather than thrown.

src/services/speechService.js has no test file.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions