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.
getAvailableVoices()returns a promise that, on the most common browser path, may neversettle:
speechSynthesis.getVoices()returning[]on first call is the normal case in Chromeand 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.
voiceschangedis not guaranteed to fire. It does not fire inFirefox 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 —— never resolves. The voice dropdown in
VoiceAlertManagerstays permanently empty withno 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 singleassignment 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 bepopulated on the first
voiceschanged; some engines fire it more than once as lists load.The first fire wins and resolves
[].Second defect:
rate: 0andpitch: 0are silently rewritten to 1volumegets the correct guard.rateandpitchget||, so0— a value the WebSpeech 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. Thesame
||also lets an out-of-range value through unchecked: the spec range is 0.1–10 forrate and 0–2 for pitch, and anything outside that makes
speak()throw aSyntaxErrorthat surfaces as an unhandled rejection.Reproduce
Expected
getAvailableVoices()always settles, with the best list it could get, within a boundedtime.
onvoiceschangedslot is left as it was found.rate: 0/pitch: 0are handled as deliberate values, and out-of-range values areclamped rather than thrown.
src/services/speechService.jshas no test file.