diff --git a/index.d.ts b/index.d.ts index 0bd00395..d34d943e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -54,6 +54,18 @@ declare module '@lightningjs/blits' { * @default 1 */ volume?: number, + /** + * Whether to cancel previous announcements when adding this one + * + * @default false + */ + cancelPrevious?: boolean, + /** + * Whether to enable utterance keep-alive (prevents pausing on some platforms) + * + * @default undefined + */ + enableUtteranceKeepAlive?: boolean } export interface AnnouncerUtterance extends Promise { diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 11d2f26f..51babfca 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -25,8 +25,13 @@ let isProcessing = false let currentId = null let debounce = null +const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') +const defaultUtteranceKeepAlive = !isAndroid + // Global default utterance options -let globalDefaultOptions = {} +let globalDefaultOptions = { + enableUtteranceKeepAlive: defaultUtteranceKeepAlive, +} const noopAnnouncement = { then() {}, @@ -51,6 +56,11 @@ const toggle = (v) => { const speak = (message, politeness = 'off', options = {}) => { if (active === false) return noopAnnouncement + // if cancelPrevious option is set, clear the queue and stop current speech + if (options.cancelPrevious === true) { + clear() + } + return addToQueue(message, politeness, false, options) } @@ -106,6 +116,8 @@ const addToQueue = (message, politeness, delay = false, options = {}) => { return done } +let currentResolveFn = null + const processQueue = async () => { if (isProcessing === true || queue.length === 0) return isProcessing = true @@ -113,11 +125,13 @@ const processQueue = async () => { const { message, resolveFn, delay, id, options = {} } = queue.shift() currentId = id + currentResolveFn = resolveFn if (delay) { setTimeout(() => { isProcessing = false currentId = null + currentResolveFn = null resolveFn('finished') processQueue() }, delay) @@ -134,23 +148,26 @@ const processQueue = async () => { ...globalDefaultOptions, ...options, }) - .then(() => { + .then((result) => { Log.debug(`Announcer - finished speaking: "${message}" (id: ${id})`) + Log.debug('Announcer - finished result: ', result) currentId = null + currentResolveFn = null isProcessing = false resolveFn('finished') processQueue() }) .catch((e) => { currentId = null + currentResolveFn = null isProcessing = false Log.debug(`Announcer - error ("${e.error}") while speaking: "${message}" (id: ${id})`) resolveFn(e.error) processQueue() }) debounce = null - }, 200) + }, 300) } } @@ -158,13 +175,59 @@ const polite = (message, options = {}) => speak(message, 'polite', options) const assertive = (message, options = {}) => speak(message, 'assertive', options) +// Clear debounce timer +const clearDebounceTimer = () => { + if (debounce !== null) { + clearTimeout(debounce) + debounce = null + } +} + const stop = () => { + Log.debug('Announcer - stop() called') + + // Clear debounce timer if speech hasn't started yet + clearDebounceTimer() + + // Always cancel speech synthesis to ensure clean state speechSynthesis.cancel() + + // Store resolve function before resetting state + const prevResolveFn = currentResolveFn + + // Reset state + currentId = null + currentResolveFn = null + isProcessing = false + + // Resolve promise if there was an active utterance + if (prevResolveFn) { + prevResolveFn('interrupted') + } } const clear = () => { + Log.debug('Announcer - clear() called') + + // Clear debounce timer + clearDebounceTimer() + + // Cancel any active speech synthesis + speechSynthesis.cancel() + + // Resolve all pending items in queue + while (queue.length > 0) { + const item = queue.shift() + if (item.resolveFn) { + Log.debug(`Announcer - clearing queued item: "${item.message}" (id: ${item.id})`) + item.resolveFn('cleared') + } + } + + // Reset state + currentId = null + currentResolveFn = null isProcessing = false - queue.length = 0 } const configure = (options = {}) => { diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 5c454ba4..e464675a 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -19,33 +19,69 @@ import { Log } from '../lib/log.js' const syn = window.speechSynthesis -const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') - -const utterances = new Map() // Strong references with unique keys +const utterances = new Map() // id -> { utterance, timer, ignoreResume } let initialized = false -let infinityTimer = null -const clear = () => { - if (infinityTimer) { - clearTimeout(infinityTimer) - infinityTimer = null +const clear = (id) => { + const state = utterances.get(id) + if (!state) { + return + } + if (state?.timer !== null) { + clearTimeout(state.timer) + state.timer = null } + utterances.delete(id) } -const resumeInfinity = (target) => { - if (!target || infinityTimer) { - return clear() +const startKeepAlive = (id) => { + const state = utterances.get(id) + + // utterance status: utterance was removed (cancelled or finished) + if (!state) { + return + } + + const { utterance } = state + + // utterance check: utterance instance is invalid + if (!(utterance instanceof SpeechSynthesisUtterance)) { + clear(id) + return + } + + // Clear existing timer for this specific utterance + if (state?.timer !== null) { + clearTimeout(state.timer) + state.timer = null + } + + // syn status: syn might be undefined or cancelled + if (!syn) { + clear(id) + return } syn.pause() setTimeout(() => { - syn.resume() + // utterance status: utterance might have been removed during setTimeout + const currentState = utterances.get(id) + if (currentState) { + currentState.ignoreResume = true + syn.resume() + } }, 0) - infinityTimer = setTimeout(() => { - resumeInfinity(target) - }, 5000) + // Check if utterance still exists before scheduling next cycle + if (utterances.has(id)) { + state.timer = setTimeout(() => { + // Double-check utterance still exists before resuming + if (utterances.has(id)) { + startKeepAlive(id) + } + }, 5000) + } } const defaultUtteranceProps = { @@ -57,45 +93,124 @@ const defaultUtteranceProps = { } const initialize = () => { + // syn api check: syn might not have getVoices method + if (!syn || typeof syn.getVoices !== 'function') { + initialized = true + return + } + const voices = syn.getVoices() defaultUtteranceProps.voice = voices[0] || null initialized = true } -const speak = (options) => { - const utterance = new SpeechSynthesisUtterance(options.message) +const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => { + return new Promise((resolve) => { + if (!syn) { + Log.warn('SpeechSynthesis - syn unavailable') + resolve() + return + } + + if (!syn.speaking && !syn.pending) { + Log.warn('SpeechSynthesis - ready immediately') + resolve() + return + } + + Log.warn('SpeechSynthesis - waiting for ready state...') + + const startTime = Date.now() + + const intervalId = window.setInterval(() => { + const elapsed = Date.now() - startTime + const isReady = !syn.speaking && !syn.pending + + if (isReady) { + Log.warn(`SpeechSynthesis - ready after ${elapsed}ms`) + window.clearInterval(intervalId) + resolve() + } else if (elapsed >= timeoutMs) { + Log.warn(`SpeechSynthesis - timeout after ${elapsed}ms, forcing ready`, { + speaking: syn.speaking, + pending: syn.pending, + }) + window.clearInterval(intervalId) + resolve() + } + }, checkIntervalMs) + }) +} + +const speak = async (options) => { + // options check: missing required options + if (!options || !options.message) { + return Promise.reject({ error: 'Missing message' }) + } + + // options check: missing or invalid id const id = options.id + if (id === undefined || id === null) { + return Promise.reject({ error: 'Missing id' }) + } + + // utterance status: utterance with same id already exists + if (utterances.has(id)) { + clear(id) + } + + // Wait for engine to be ready + await waitForSynthReady() + + const utterance = new SpeechSynthesisUtterance(options.message) utterance.lang = options.lang || defaultUtteranceProps.lang utterance.pitch = options.pitch || defaultUtteranceProps.pitch utterance.rate = options.rate || defaultUtteranceProps.rate utterance.voice = options.voice || defaultUtteranceProps.voice utterance.volume = options.volume || defaultUtteranceProps.volume - utterances.set(id, utterance) // Strong reference - - if (isAndroid === false) { - utterance.onstart = () => { - resumeInfinity(utterance) - } - utterance.onresume = () => { - resumeInfinity(utterance) - } - } + utterances.set(id, { utterance, timer: null, ignoreResume: false }) return new Promise((resolve, reject) => { - utterance.onend = () => { - clear() - utterances.delete(id) // Cleanup - resolve() + utterance.onend = (result) => { + clear(id) + resolve(result) } utterance.onerror = (e) => { - clear() - utterances.delete(id) // Cleanup - reject(e) + Log.warn('SpeechSynthesisUtterance error:', e) + clear(id) + resolve() } - syn.speak(utterance) + if (options.enableUtteranceKeepAlive === true) { + utterance.onstart = () => { + // utterances status: check if utterance still exists + if (utterances.has(id)) { + startKeepAlive(id) + } + } + + utterance.onresume = () => { + const state = utterances.get(id) + // utterance status: utterance might have been removed + if (!state) return + + if (state.ignoreResume === true) { + state.ignoreResume = false + return + } + + startKeepAlive(id) + } + } + // handle error: syn.speak might throw + try { + syn.speak(utterance) + } catch (error) { + clear(id) + reject(error) + } }) } @@ -113,8 +228,20 @@ export default { }, cancel() { if (syn !== undefined) { - syn.cancel() - clear() + // timers: clear all timers before cancelling + for (const id of utterances.keys()) { + clear(id) + } + + // handle errors: syn.cancel might throw + try { + syn.cancel() + } catch (error) { + Log.error('Error cancelling speech synthesis:', error) + } + + // utterances status: ensure all utterances are cleaned up + utterances.clear() } }, // @todo