diff --git a/src/services/speechService.js b/src/services/speechService.js index 6133501..16b0c8d 100644 --- a/src/services/speechService.js +++ b/src/services/speechService.js @@ -2,35 +2,142 @@ * @fileoverview Service layer wrapping the Web Speech API for text-to-speech alert generation. */ +/** + * How long to wait for the voice list before giving up and answering with whatever the + * engine has. + * + * `getVoices()` returning `[]` on the first call is the normal case in Chrome and Edge — + * the list is populated asynchronously — so the wait is the usual path, not the edge case. + * Two seconds is well past how long a local engine takes and short enough that a device + * with no voices at all doesn't leave the picker looking merely slow. + */ +const VOICES_TIMEOUT_MS = 2000; + +/** + * `voiceschanged` 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. Some engines fire it + * more than once as lists load in, and the first fire can still hand back an empty list. + * Polling alongside the event covers both, and is cheap over a two-second window. + */ +const VOICES_POLL_MS = 100; + +/** @see {@link https://developer.mozilla.org/docs/Web/API/SpeechSynthesisUtterance} */ +const RATE_RANGE = { min: 0.1, max: 10, fallback: 1 }; +const PITCH_RANGE = { min: 0, max: 2, fallback: 1 }; +const VOLUME_RANGE = { min: 0, max: 1, fallback: 1 }; + /** * Checks if the browser supports the Web Speech API. * @returns {boolean} */ export const isSpeechSupported = () => { - return 'speechSynthesis' in window; + return typeof window !== 'undefined' && 'speechSynthesis' in window; }; +/** + * Keeps a configured value inside the range the Web Speech API accepts. + * + * `config.rate || 1` was wrong twice over. `0` is a value the API accepts and the value the + * settings slider's minimum produces, and `||` rewrote it to `1` — so dragging rate to the + * bottom silently gave normal speed. It also let an out-of-range value straight through, + * and `speak()` throws a `SyntaxError` on those, which surfaced as an unhandled rejection. + * + * Exported so the settings UI can apply the same bounds it will be held to. + * + * @param {unknown} value + * @param {{min: number, max: number, fallback: number}} range + * @returns {number} + */ +export function clampToRange(value, range) { + let numeric; + if (typeof value === 'number') { + numeric = value; + } else if (typeof value === 'string' && value.trim() !== '') { + // A range input hands back a string, and it is the settings form's own value. + numeric = Number(value); + } else { + // `Number(null)` and `Number('')` are both 0, which is inside two of these three + // ranges. Absent is not zero, so neither reaches the clamp. + return range.fallback; + } + + if (!Number.isFinite(numeric)) return range.fallback; + return Math.min(range.max, Math.max(range.min, numeric)); +} + +/** The bounds `speakText` applies, for callers that want to show them. */ +export const SPEECH_RANGES = { rate: RATE_RANGE, pitch: PITCH_RANGE, volume: VOLUME_RANGE }; + /** * Fetches available voices from the browser. - * @returns {Promise} + * + * Always settles. The previous implementation assigned `speechSynthesis.onvoiceschanged` + * and resolved from it, which meant: the promise never settled at all where that event + * does not fire, leaving `useVoiceSynthesis`'s `.then(setVoices)` hanging and the voice + * picker permanently and inexplicably empty; a second concurrent caller overwrote the + * first one's handler, so the first never resolved; and the global handler slot was + * clobbered and never restored. See #1139. + * + * @param {number} [timeoutMs] - How long to wait before answering with what is available. + * @returns {Promise} Never rejects; resolves `[]` if there are none. */ -export const getAvailableVoices = () => { +export const getAvailableVoices = (timeoutMs = VOICES_TIMEOUT_MS) => { return new Promise((resolve) => { if (!isSpeechSupported()) { resolve([]); return; } - let voices = window.speechSynthesis.getVoices(); - if (voices.length > 0) { + const synth = window.speechSynthesis; + + /** @returns {SpeechSynthesisVoice[]} */ + const read = () => { + try { + const voices = synth.getVoices(); + return Array.isArray(voices) ? voices : []; + } catch { + return []; + } + }; + + const immediate = read(); + if (immediate.length > 0) { + resolve(immediate); + return; + } + + let settled = false; + let pollId; + let timeoutId; + + const finish = (voices) => { + if (settled) return; + settled = true; + clearInterval(pollId); + clearTimeout(timeoutId); + // addEventListener rather than the onvoiceschanged slot, so concurrent callers + // do not overwrite each other and the page's own handler is left alone. + synth.removeEventListener?.('voiceschanged', onVoicesChanged); resolve(voices); - } else { - // Wait for voices to be loaded - window.speechSynthesis.onvoiceschanged = () => { - voices = window.speechSynthesis.getVoices(); - resolve(voices); - }; + }; + + function onVoicesChanged() { + const voices = read(); + // Some engines fire this before the list is populated; wait for the next one + // (or the poll, or the timeout) rather than resolving empty on the first. + if (voices.length > 0) finish(voices); } + + synth.addEventListener?.('voiceschanged', onVoicesChanged); + + pollId = setInterval(() => { + const voices = read(); + if (voices.length > 0) finish(voices); + }, VOICES_POLL_MS); + + // The backstop that makes "always settles" true. Answering with an empty list is + // a usable answer; never answering is not. + timeoutId = setTimeout(() => finish(read()), timeoutMs); }); }; @@ -47,23 +154,34 @@ export const speakText = (text, config) => { return; } + if (typeof text !== 'string' || text.trim() === '') { + // An empty utterance never fires `end` in some engines, which would wedge the + // caller's queue on an item that can never finish. + reject(new Error('Nothing to speak')); + return; + } + + const settings = config || {}; + // Cancel any ongoing speech to prevent queue buildup window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); - if (config.voiceUri) { + if (settings.voiceUri) { const voices = window.speechSynthesis.getVoices(); - const selectedVoice = voices.find(v => v.voiceURI === config.voiceUri); + const selectedVoice = Array.isArray(voices) + ? voices.find(v => v.voiceURI === settings.voiceUri) + : undefined; if (selectedVoice) { utterance.voice = selectedVoice; } } - utterance.lang = config.language || 'en-US'; - utterance.rate = config.rate || 1; - utterance.pitch = config.pitch || 1; - utterance.volume = config.volume !== undefined ? config.volume : 1; + utterance.lang = settings.language || 'en-US'; + utterance.rate = clampToRange(settings.rate, RATE_RANGE); + utterance.pitch = clampToRange(settings.pitch, PITCH_RANGE); + utterance.volume = clampToRange(settings.volume, VOLUME_RANGE); utterance.onend = () => resolve(); utterance.onerror = (event) => reject(new Error(`Speech error: ${event.error}`)); diff --git a/src/services/speechService.test.js b/src/services/speechService.test.js new file mode 100644 index 0000000..a9e6daa --- /dev/null +++ b/src/services/speechService.test.js @@ -0,0 +1,362 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + SPEECH_RANGES, + clampToRange, + getAvailableVoices, + isSpeechSupported, + speakText, + stopSpeech, +} from './speechService'; + +/** + * jsdom implements neither `speechSynthesis` nor `SpeechSynthesisUtterance`, so both are + * stood up here. The fake mirrors the parts of the real API these tests depend on, + * including the one that caused #1139: `getVoices()` returning `[]` until the engine has + * loaded its list, with `voiceschanged` that may or may not ever fire. + */ +function makeSynth({ voices = [], supportsListeners = true } = {}) { + const listeners = new Set(); + const synth = { + spoken: [], + cancelled: 0, + getVoices: vi.fn(() => voices), + cancel: vi.fn(function cancel() { this.cancelled += 1; }), + speak: vi.fn(function speak(utterance) { this.spoken.push(utterance); }), + onvoiceschanged: null, + /** Fires the event, the way an engine does once its list is ready. */ + emitVoicesChanged() { + for (const listener of [...listeners]) listener(); + if (typeof this.onvoiceschanged === 'function') this.onvoiceschanged(); + }, + /** Replaces the list the engine reports. */ + setVoices(next) { voices = next; synth.getVoices.mockImplementation(() => next); }, + listenerCount: () => listeners.size, + }; + + if (supportsListeners) { + synth.addEventListener = vi.fn((type, listener) => { + if (type === 'voiceschanged') listeners.add(listener); + }); + synth.removeEventListener = vi.fn((type, listener) => { + if (type === 'voiceschanged') listeners.delete(listener); + }); + } + + return synth; +} + +class FakeUtterance { + constructor(text) { + this.text = text; + this.voice = null; + this.lang = ''; + this.rate = 1; + this.pitch = 1; + this.volume = 1; + this.onend = null; + this.onerror = null; + } +} + +const VOICES = [ + { voiceURI: 'uri-a', name: 'Aditi', lang: 'en-IN' }, + { voiceURI: 'uri-b', name: 'Brian', lang: 'en-GB' }, +]; + +let synth; + +function install(options) { + synth = makeSynth(options); + vi.stubGlobal('speechSynthesis', synth); + vi.stubGlobal('SpeechSynthesisUtterance', FakeUtterance); + // `isSpeechSupported` checks `'speechSynthesis' in window`, and stubGlobal in jsdom + // assigns onto the same object window aliases. + window.speechSynthesis = synth; + window.SpeechSynthesisUtterance = FakeUtterance; + return synth; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + delete window.speechSynthesis; + delete window.SpeechSynthesisUtterance; + vi.restoreAllMocks(); +}); + +describe('clampToRange', () => { + it('keeps a value already inside the range', () => { + expect(clampToRange(1.5, SPEECH_RANGES.rate)).toBe(1.5); + expect(clampToRange(0.5, SPEECH_RANGES.volume)).toBe(0.5); + }); + + it('keeps zero where zero is legal, instead of rewriting it to 1 (#1139)', () => { + // `config.pitch || 1` turned the slider's minimum into normal pitch. + expect(clampToRange(0, SPEECH_RANGES.pitch)).toBe(0); + expect(clampToRange(0, SPEECH_RANGES.volume)).toBe(0); + }); + + it('clamps to the documented bounds rather than letting speak() throw', () => { + expect(clampToRange(0, SPEECH_RANGES.rate)).toBe(0.1); + expect(clampToRange(99, SPEECH_RANGES.rate)).toBe(10); + expect(clampToRange(-3, SPEECH_RANGES.pitch)).toBe(0); + expect(clampToRange(5, SPEECH_RANGES.pitch)).toBe(2); + expect(clampToRange(2, SPEECH_RANGES.volume)).toBe(1); + }); + + it('falls back for a value that is not a number at all', () => { + expect(clampToRange(undefined, SPEECH_RANGES.rate)).toBe(1); + expect(clampToRange(null, SPEECH_RANGES.rate)).toBe(1); + expect(clampToRange(NaN, SPEECH_RANGES.rate)).toBe(1); + expect(clampToRange('fast', SPEECH_RANGES.rate)).toBe(1); + }); + + it('accepts a numeric string, which is what a range input produces', () => { + expect(clampToRange('0.5', SPEECH_RANGES.rate)).toBe(0.5); + }); +}); + +describe('isSpeechSupported', () => { + it('is true when the browser has speechSynthesis', () => { + install(); + expect(isSpeechSupported()).toBe(true); + }); + + it('is false when it does not', () => { + expect(isSpeechSupported()).toBe(false); + }); +}); + +describe('getAvailableVoices', () => { + it('resolves immediately when the list is already populated', async () => { + install({ voices: VOICES }); + await expect(getAvailableVoices()).resolves.toEqual(VOICES); + expect(synth.addEventListener).not.toHaveBeenCalled(); + }); + + it('resolves when voiceschanged arrives with a populated list', async () => { + install({ voices: [] }); + const pending = getAvailableVoices(); + + synth.setVoices(VOICES); + synth.emitVoicesChanged(); + + await expect(pending).resolves.toEqual(VOICES); + }); + + it('settles even when voiceschanged never fires (#1139)', async () => { + // The old implementation waited on that event alone, so this promise stayed + // pending forever and the voice picker sat empty with nothing to retry. + install({ voices: [] }); + const pending = getAvailableVoices(2000); + + await vi.advanceTimersByTimeAsync(2000); + + await expect(pending).resolves.toEqual([]); + }); + + it('picks the list up by polling when the event never fires', async () => { + install({ voices: [] }); + const pending = getAvailableVoices(2000); + + synth.setVoices(VOICES); + await vi.advanceTimersByTimeAsync(200); + + await expect(pending).resolves.toEqual(VOICES); + }); + + it('ignores a voiceschanged that arrives before the list is ready', async () => { + install({ voices: [] }); + const pending = getAvailableVoices(2000); + + // Some engines fire this more than once; the first can still report nothing. + synth.emitVoicesChanged(); + synth.setVoices(VOICES); + synth.emitVoicesChanged(); + + await expect(pending).resolves.toEqual(VOICES); + }); + + it('answers every concurrent caller (#1139)', async () => { + // The old code assigned `onvoiceschanged`, a single slot: a second caller + // overwrote the first one's resolve and the first never settled. + install({ voices: [] }); + const first = getAvailableVoices(); + const second = getAvailableVoices(); + const third = getAvailableVoices(); + + synth.setVoices(VOICES); + synth.emitVoicesChanged(); + + await expect(Promise.all([first, second, third])).resolves.toEqual([VOICES, VOICES, VOICES]); + }); + + it('leaves the page\'s own onvoiceschanged handler alone (#1139)', async () => { + install({ voices: [] }); + const pageHandler = vi.fn(); + synth.onvoiceschanged = pageHandler; + + const pending = getAvailableVoices(); + synth.setVoices(VOICES); + synth.emitVoicesChanged(); + await pending; + + expect(synth.onvoiceschanged).toBe(pageHandler); + expect(pageHandler).toHaveBeenCalled(); + }); + + it('removes its listener and timers once it has an answer', async () => { + install({ voices: [] }); + const pending = getAvailableVoices(); + + synth.setVoices(VOICES); + synth.emitVoicesChanged(); + await pending; + + expect(synth.listenerCount()).toBe(0); + // Nothing left to fire: advancing past the timeout must not throw or re-resolve. + await vi.advanceTimersByTimeAsync(5000); + expect(synth.removeEventListener).toHaveBeenCalled(); + }); + + it('resolves empty rather than throwing where there is no speech support', async () => { + await expect(getAvailableVoices()).resolves.toEqual([]); + }); + + it('survives a getVoices() that throws', async () => { + install({ voices: [] }); + synth.getVoices.mockImplementation(() => { throw new Error('engine unavailable'); }); + + const pending = getAvailableVoices(2000); + await vi.advanceTimersByTimeAsync(2000); + + await expect(pending).resolves.toEqual([]); + }); + + it('still settles on an engine with no addEventListener', async () => { + install({ voices: [], supportsListeners: false }); + const pending = getAvailableVoices(2000); + + synth.setVoices(VOICES); + await vi.advanceTimersByTimeAsync(200); + + await expect(pending).resolves.toEqual(VOICES); + }); +}); + +describe('speakText', () => { + it('speaks the text and resolves when the utterance ends', async () => { + install({ voices: VOICES }); + const pending = speakText('hello', { language: 'en-GB', rate: 1, pitch: 1, volume: 1 }); + + expect(synth.speak).toHaveBeenCalledTimes(1); + const [utterance] = synth.spoken; + expect(utterance.text).toBe('hello'); + expect(utterance.lang).toBe('en-GB'); + + utterance.onend(); + await expect(pending).resolves.toBeUndefined(); + }); + + it('honours rate 0 and pitch 0 as deliberate values (#1139)', async () => { + install({ voices: VOICES }); + const pending = speakText('hello', { rate: 0, pitch: 0, volume: 0 }); + + const [utterance] = synth.spoken; + expect(utterance.rate).toBe(0.1); // clamped to the API minimum, not reset to 1 + expect(utterance.pitch).toBe(0); // 0 is legal for pitch + expect(utterance.volume).toBe(0); // and for volume + + utterance.onend(); + await pending; + }); + + it('clamps an out-of-range value instead of letting speak() throw', async () => { + install({ voices: VOICES }); + const pending = speakText('hello', { rate: 50, pitch: -1, volume: 9 }); + + const [utterance] = synth.spoken; + expect(utterance.rate).toBe(10); + expect(utterance.pitch).toBe(0); + expect(utterance.volume).toBe(1); + + utterance.onend(); + await pending; + }); + + it('selects the configured voice when it exists', async () => { + install({ voices: VOICES }); + const pending = speakText('hello', { voiceUri: 'uri-b' }); + + expect(synth.spoken[0].voice).toEqual(VOICES[1]); + synth.spoken[0].onend(); + await pending; + }); + + it('falls back to the default voice when the configured one is gone', async () => { + install({ voices: VOICES }); + const pending = speakText('hello', { voiceUri: 'uri-that-was-uninstalled' }); + + expect(synth.spoken[0].voice).toBeNull(); + synth.spoken[0].onend(); + await pending; + }); + + it('cancels whatever is speaking before starting', async () => { + install({ voices: VOICES }); + const pending = speakText('hello', {}); + + expect(synth.cancel).toHaveBeenCalled(); + synth.spoken[0].onend(); + await pending; + }); + + it('rejects rather than queueing an utterance that can never end', async () => { + install({ voices: VOICES }); + await expect(speakText('', {})).rejects.toThrow('Nothing to speak'); + await expect(speakText(' ', {})).rejects.toThrow('Nothing to speak'); + await expect(speakText(null, {})).rejects.toThrow('Nothing to speak'); + expect(synth.speak).not.toHaveBeenCalled(); + }); + + it('rejects with the engine error', async () => { + install({ voices: VOICES }); + const pending = speakText('hello', {}); + + synth.spoken[0].onerror({ error: 'synthesis-failed' }); + await expect(pending).rejects.toThrow('Speech error: synthesis-failed'); + }); + + it('rejects where the browser has no speech synthesis', async () => { + await expect(speakText('hello', {})).rejects.toThrow('Speech synthesis not supported'); + }); + + it('tolerates a missing config', async () => { + install({ voices: VOICES }); + const pending = speakText('hello'); + + const [utterance] = synth.spoken; + expect(utterance.lang).toBe('en-US'); + expect(utterance.rate).toBe(1); + + utterance.onend(); + await pending; + }); +}); + +describe('stopSpeech', () => { + it('cancels the engine', () => { + install({ voices: VOICES }); + stopSpeech(); + expect(synth.cancel).toHaveBeenCalled(); + }); + + it('is a no-op where there is no speech synthesis', () => { + expect(() => stopSpeech()).not.toThrow(); + }); +});