Skip to content

fix: make getAvailableVoices always settle, and stop rewriting rate 0 to 1 (#1139) - #1144

Open
MOHITKOURAV01 wants to merge 1 commit into
Aditya8369:mainfrom
MOHITKOURAV01:fix/1139-speech-voices-hang
Open

fix: make getAvailableVoices always settle, and stop rewriting rate 0 to 1 (#1139)#1144
MOHITKOURAV01 wants to merge 1 commit into
Aditya8369:mainfrom
MOHITKOURAV01:fix/1139-speech-voices-hang

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Description

getAvailableVoices() returned a promise that, on the most common browser path, could
never settle.

speechSynthesis.getVoices() returning [] on the first call is the normal case in
Chrome and Edge — the voice list is populated asynchronously — so the else-branch was the
branch that ran, and it waited on voiceschanged alone:

} else {
    window.speechSynthesis.onvoiceschanged = () => {
        voices = window.speechSynthesis.getVoices();
        resolve(voices);
    };
}

That event is not guaranteed to fire. Not in Firefox when the list is already final, not in
headless Chrome with no speech engine installed, not in jsdom. When it doesn't, the promise
stays pending, useVoiceSynthesis's getAvailableVoices().then(setVoices) never resolves,
and the voice dropdown in VoiceAlertManager sits permanently empty — no error, no
fallback, nothing to retry, and indistinguishable from "this device has no voices".

Two more problems in the same six lines: onvoiceschanged is a single assignment slot, not
a listener list, so two concurrent callers means the first one's resolve is overwritten
and never called; and nothing ever cleared the handler, so it outlived the page's own and
held its closure alive.

Separately, rate: 0 and pitch: 0 were silently rewritten to 1.


Related Issue

Closes #1139


Type of Change

  • Bug Fix
  • New Feature
  • Documentation
  • UI/UX Improvement
  • Refactoring
  • Performance Improvement
  • Accessibility

Changes Made

getAvailableVoices(timeoutMs = 2000) — always settles

Resolves from whichever comes first: an already-populated list, a voiceschanged carrying
one, a 100 ms poll, or the timeout. The poll matters because some engines populate the list
without ever firing the event; the timeout is the backstop that makes "always settles"
true. It never rejects — an empty list is a usable answer, never answering is not.

  • Registers via addEventListener instead of the onvoiceschanged slot, so concurrent
    callers don't overwrite each other and the page's own handler is left exactly as it was
    found. There's a test asserting that specifically.
  • A voiceschanged that arrives before the list is ready no longer resolves []. Some
    engines fire it more than once as lists load, and the old code took the first one.
  • Removes its listener and clears both timers once it has an answer.
  • A getVoices() that throws is handled rather than escaping.

clampToRangerate: 0 and pitch: 0 are deliberate values

utterance.rate   = config.rate   || 1;   // 0 -> 1
utterance.pitch  = config.pitch  || 1;   // 0 -> 1
utterance.volume = config.volume !== undefined ? config.volume : 1;   // correct

volume had the right guard; the two beside it didn't. 0 is a value the Web Speech API
accepts and the value the settings slider's minimum produces, so dragging rate to the
bottom silently gave normal speed. The same || also let an out-of-range value straight
through, and speak() throws a SyntaxError on those, which surfaced as an unhandled
rejection.

clampToRange holds all three to the ranges already documented in src/types/speech.ts
(rate 0.1–10, pitch 0–2, volume 0–1). Absent is not zero: null and '' take the fallback
rather than clamping to 0, since Number(null) is 0 and that sits inside two of the
three ranges. Exported along with SPEECH_RANGES so the settings UI can show the bounds it
will be held to.

speakText — rejects an empty utterance up front rather than queueing one that some
engines never fire end for, which would wedge the caller's queue on an item that can never
finish. Tolerates a missing config, and a getVoices() that isn't an array.


Testing

  • Tested locally
  • No console errors
  • Existing functionality works as expected

src/services/speechService.test.js is new — this module had no tests. 30 of them, against
a fake speechSynthesis that reproduces the real loading behaviour (getVoices() empty
until the engine is ready, with a voiceschanged that may or may not fire).

$ npx vitest run src/services/speechService.test.js
 ✓ src/services/speechService.test.js (30 tests)
 Test Files  1 passed (1)
      Tests  30 passed (30)

Against the code before this change, 12 fail — and two of them fail by timing out at 5s,
which is the hang itself:

 × getAvailableVoices > settles even when voiceschanged never fires (#1139)   5001ms
 × getAvailableVoices > picks the list up by polling when the event never fires   5001ms
 × getAvailableVoices > answers every concurrent caller (#1139)   5003ms
 × getAvailableVoices > leaves the page's own onvoiceschanged handler alone (#1139)
 × clampToRange > keeps zero where zero is legal, instead of rewriting it to 1 (#1139)
 ...

Scope

Only the service. useVoiceSynthesis and VoiceAlertManager have their own defect
(#1136) and their own PR; this one is deliberately kept to the module the hang lives in, so
the two can merge in either order.

Note on CI

Lint, Build and Playwright are red on main and on every open PR (the 3 parse errors of
#1129). Nothing here touches those files.

… to 1 (Aditya8369#1139)

getVoices() returning [] on the first call is the normal case in Chrome and
Edge -- the list loads asynchronously -- so the else-branch was the branch that
ran, and it waited on `voiceschanged` alone. That event is not guaranteed to
fire: not in Firefox when the list is already final, not in headless Chrome with
no speech engine, not in jsdom. The promise then stayed pending forever, so
useVoiceSynthesis's `.then(setVoices)` never resolved and the voice picker sat
permanently empty with no error and nothing to retry -- indistinguishable from
a device with no voices.

It also assigned `speechSynthesis.onvoiceschanged`, which is one slot rather
than a listener list: two concurrent callers and the first one's resolve was
overwritten and never called. Nothing cleared it afterwards either, so the
handler outlived the page's own and held its closure alive.

getAvailableVoices now resolves from whichever comes first -- an already
populated list, a voiceschanged carrying one, a poll, or a bounded timeout --
registers through addEventListener so callers do not overwrite each other and
the page's handler is untouched, and tears down its listener and timers on the
way out. It never rejects; an empty list is a usable answer, never answering is
not. A voiceschanged that arrives before the list is ready no longer resolves
empty.

Separately, `config.rate || 1` and `config.pitch || 1` rewrote 0 -- a value the
API accepts, and the one the settings slider's minimum produces -- to normal
speed and pitch, while volume next to them had the correct guard. The same ||
let an out-of-range value through, and speak() throws SyntaxError on those,
surfacing as an unhandled rejection. clampToRange holds all three to the ranges
already documented in types/speech.ts. Absent is not zero: null and '' take the
fallback rather than clamping to 0.

speakText also rejects an empty utterance up front, rather than queueing one
that some engines never fire `end` for -- which would wedge the caller's queue
on an item that can never finish.

Tests: 30, the first for this module. Twelve fail against the code before this
change, two of them by timing out at 5s -- which is the hang.
@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

@MOHITKOURAV01 is attempting to deploy a commit to the Aditya Mahajan's projects Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions

Copy link
Copy Markdown

Thank You for Your Contribution! 🎉

Hi @MOHITKOURAV01,

Thank you for opening this Pull Request and contributing to our project. We truly appreciate your efforts.

Please make sure that:

  • Your code follows the project's guidelines.
  • You have linked the appropriate issue (if applicable).
  • Screenshots are added for UI/UX changes.
  • Your PR is ready for review.

The maintainer @Aditya8369 will review your PR shortly!

Happy Contributing! 🚀

@github-actions github-actions Bot added the ECSoC26 Contributions considered under ECSoC'26 label Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ECSoC26 Contributions considered under ECSoC'26

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant