From 3170c736e9e7a7c842c19d39a19e306dd194ddf8 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 26 Nov 2025 15:51:34 +0000 Subject: [PATCH 01/22] fix: resumeInfinity/onresume interaction --- src/announcer/speechSynthesis.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 5c454ba4..494ea358 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -33,13 +33,24 @@ const clear = () => { } } +let resumeFromKeepAlive = false + const resumeInfinity = (target) => { - if (!target || infinityTimer) { + // If the utterance is gone, just stop the keep-alive loop. + if (!target) { return clear() } + // We only ever want ONE keep-alive timer running per utterance. + // If there's an existing timer, cancel it and start a fresh one below. + if (infinityTimer) { + clearTimeout(infinityTimer) + infinityTimer = null + } + syn.pause() setTimeout(() => { + resumeFromKeepAlive = true syn.resume() }, 0) @@ -78,6 +89,13 @@ const speak = (options) => { } utterance.onresume = () => { + // Ignore resume events that we *know* came from our own keep-alive (the pause()/resume() in resumeInfinity). + if (resumeFromKeepAlive) { + resumeFromKeepAlive = false + return + } + + // For any other real resume event (e.g. user or platform resuming a previously paused utterance). resumeInfinity(utterance) } } From 15614f14278aa8d1bfa93f3185cc951e4b3ab710 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Tue, 2 Dec 2025 10:22:54 +0000 Subject: [PATCH 02/22] chore: ensure target is a SpeechSynthesisUtterance in resumeInfinity function --- src/announcer/speechSynthesis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 494ea358..bebf5d7a 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -37,7 +37,7 @@ let resumeFromKeepAlive = false const resumeInfinity = (target) => { // If the utterance is gone, just stop the keep-alive loop. - if (!target) { + if (!(target instanceof SpeechSynthesisUtterance)) { return clear() } From f35ab5e5f769670d37d38f89d283c89ed1a16b10 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Tue, 2 Dec 2025 10:26:02 +0000 Subject: [PATCH 03/22] fix: ensure infinityTimer is checked against null in clear and resumeInfinity functions --- src/announcer/speechSynthesis.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index bebf5d7a..a7db0bfe 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -27,7 +27,7 @@ let initialized = false let infinityTimer = null const clear = () => { - if (infinityTimer) { + if (infinityTimer !== null) { clearTimeout(infinityTimer) infinityTimer = null } @@ -43,7 +43,7 @@ const resumeInfinity = (target) => { // We only ever want ONE keep-alive timer running per utterance. // If there's an existing timer, cancel it and start a fresh one below. - if (infinityTimer) { + if (infinityTimer !== null) { clearTimeout(infinityTimer) infinityTimer = null } From 910df0c6fb47f04ae2640f57baa0636ac4a282d2 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Tue, 2 Dec 2025 10:27:30 +0000 Subject: [PATCH 04/22] fix: ensure resumeFromKeepAlive is strictly checked for true in onresume handler --- src/announcer/speechSynthesis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index a7db0bfe..fe70960b 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -90,7 +90,7 @@ const speak = (options) => { utterance.onresume = () => { // Ignore resume events that we *know* came from our own keep-alive (the pause()/resume() in resumeInfinity). - if (resumeFromKeepAlive) { + if (resumeFromKeepAlive === true) { resumeFromKeepAlive = false return } From ecb2ddc7bf8991d5d6f7c2608208dee982a1e367 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 12:17:06 +0000 Subject: [PATCH 05/22] fix: improve utterance management and error handling in speech synthesis --- src/announcer/speechSynthesis.js | 149 ++++++++++++++++++++++--------- 1 file changed, 109 insertions(+), 40 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index fe70960b..fdc722fe 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -21,41 +21,60 @@ 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 !== null) { - clearTimeout(infinityTimer) - infinityTimer = null +const clear = (id) => { + const state = utterances.get(id) + if (state?.timer !== null) { + clearTimeout(state.timer) + state.timer = null } } -let resumeFromKeepAlive = false +const resumeInfinity = (id) => { + const state = utterances.get(id) -const resumeInfinity = (target) => { - // If the utterance is gone, just stop the keep-alive loop. - if (!(target instanceof SpeechSynthesisUtterance)) { - return clear() + // utterance status: utterance was removed (cancelled or finished) + if (!state) { + return } - // We only ever want ONE keep-alive timer running per utterance. - // If there's an existing timer, cancel it and start a fresh one below. - if (infinityTimer !== null) { - clearTimeout(infinityTimer) - infinityTimer = null + const { utterance } = state + + // utterance check: utterance instance is invalid + if (!(utterance instanceof SpeechSynthesisUtterance)) { + clear(id) + utterances.delete(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) + utterances.delete(id) + return } syn.pause() setTimeout(() => { - resumeFromKeepAlive = true - 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) + state.timer = setTimeout(() => { + resumeInfinity(id) }, 5000) } @@ -68,52 +87,94 @@ 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) + // 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) + utterances.delete(id) + } + + 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 + + utterances.set(id, { utterance, timer: null, ignoreResume: false }) if (isAndroid === false) { utterance.onstart = () => { - resumeInfinity(utterance) + // utterances status: check if utterance still exists + if (utterances.has(id)) { + resumeInfinity(id) + } } utterance.onresume = () => { - // Ignore resume events that we *know* came from our own keep-alive (the pause()/resume() in resumeInfinity). - if (resumeFromKeepAlive === true) { - resumeFromKeepAlive = false + const state = utterances.get(id) + // utterance status: utterance might have been removed + if (!state) return + + if (state.ignoreResume === true) { + state.ignoreResume = false return } - // For any other real resume event (e.g. user or platform resuming a previously paused utterance). - resumeInfinity(utterance) + resumeInfinity(id) + } + + // pause events: handle pause events + utterance.onpause = () => { + // Stop keep-alive when manually paused + clear(id) } } return new Promise((resolve, reject) => { utterance.onend = () => { - clear() - utterances.delete(id) // Cleanup + clear(id) + utterances.delete(id) resolve() } utterance.onerror = (e) => { - clear() - utterances.delete(id) // Cleanup - reject(e) + clear(id) + utterances.delete(id) + // handle error: provide more context in error + reject(e || { error: 'Speech synthesis error' }) } - syn.speak(utterance) + // handle error: syn.speak might throw + try { + syn.speak(utterance) + } catch (error) { + clear(id) + utterances.delete(id) + reject(error) + } }) } @@ -131,12 +192,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 - // getVoices() { - // return syn.getVoices() - // }, } From d43ee7223a37c85547b535e6bea926a1372025f2 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 12:19:55 +0000 Subject: [PATCH 06/22] chore: add placeholder for getVoices method in speech synthesis --- src/announcer/speechSynthesis.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index fdc722fe..518baad5 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -208,4 +208,8 @@ export default { utterances.clear() } }, + // @todo + // getVoices() { + // return syn.getVoices() + // }, } From 3e56c9df9d7f34ffa3dc8df6ea57da97c9afb773 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 14:37:32 +0000 Subject: [PATCH 07/22] chore: improve error handling in speak function of speech synthesis --- src/announcer/speechSynthesis.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 518baad5..a8f4a5c1 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -161,10 +161,10 @@ const speak = (options) => { } utterance.onerror = (e) => { + Log.warn('SpeechSynthesisUtterance error:', e) clear(id) utterances.delete(id) - // handle error: provide more context in error - reject(e || { error: 'Speech synthesis error' }) + resolve() } // handle error: syn.speak might throw From d2721b6490c6ea2cf8f6ec5b526c55e0ec494749 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 14:37:45 +0000 Subject: [PATCH 08/22] chore: enhance queue management and debounce handling in announcer --- src/announcer/announcer.js | 43 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 11d2f26f..a13d5ad5 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -106,6 +106,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 +115,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) @@ -138,12 +142,14 @@ const processQueue = async () => { Log.debug(`Announcer - finished speaking: "${message}" (id: ${id})`) 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) @@ -158,13 +164,46 @@ 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 = () => { - speechSynthesis.cancel() + Log.debug('Announcer - stop() called') + + // Clear debounce timer if speech hasn't started yet + clearDebounceTimer() + + if (currentId !== null && currentResolveFn) { + speechSynthesis.cancel() + const resolveFn = currentResolveFn + currentId = null + currentResolveFn = null + isProcessing = false + resolveFn('interrupted') + } } const clear = () => { + Log.debug('Announcer - clear() called') + + // Clear debounce timer + clearDebounceTimer() + + // 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') + } + } + isProcessing = false - queue.length = 0 } const configure = (options = {}) => { From 6aa4a1958cdaa52d310350b2b2840c1d8abe268e Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 15:05:29 +0000 Subject: [PATCH 09/22] fix: add optional chaining to safely check timer state in resumeInfinity function --- src/announcer/speechSynthesis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index a8f4a5c1..9752e6f7 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -51,7 +51,7 @@ const resumeInfinity = (id) => { } // Clear existing timer for this specific utterance - if (state.timer !== null) { + if (state?.timer !== null) { clearTimeout(state.timer) state.timer = null } From a1ebc9430839ed1f6470257a7020d705f49b29f9 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 17:02:13 +0000 Subject: [PATCH 10/22] fix: enhance stop and clear functions to ensure clean state in speech synthesis --- src/announcer/announcer.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index a13d5ad5..d3cbd123 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -178,13 +178,20 @@ const stop = () => { // Clear debounce timer if speech hasn't started yet clearDebounceTimer() + // Always cancel speech synthesis to ensure clean state + speechSynthesis.cancel() + if (currentId !== null && currentResolveFn) { - speechSynthesis.cancel() const resolveFn = currentResolveFn currentId = null currentResolveFn = null isProcessing = false resolveFn('interrupted') + } else { + // Reset state even if no current utterance + currentId = null + currentResolveFn = null + isProcessing = false } } @@ -194,6 +201,9 @@ const clear = () => { // 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() @@ -203,6 +213,9 @@ const clear = () => { } } + // Reset state + currentId = null + currentResolveFn = null isProcessing = false } From 9910a08c2ea356f743e1d20f6c76ac29af271fc9 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 17:13:34 +0000 Subject: [PATCH 11/22] fix: add double-check for utterance existence before resuming in resumeInfinity function --- src/announcer/speechSynthesis.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 9752e6f7..5934ac57 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -73,9 +73,15 @@ const resumeInfinity = (id) => { } }, 0) - state.timer = setTimeout(() => { - resumeInfinity(id) - }, 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)) { + resumeInfinity(id) + } + }, 5000) + } } const defaultUtteranceProps = { From 624604fa18e6a1cca6c964e8ce2c0647af58c9dc Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 01:00:17 +0000 Subject: [PATCH 12/22] fix: add early return in clear function to handle non-existent state --- src/announcer/speechSynthesis.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 5934ac57..5fc673cd 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -27,6 +27,9 @@ let initialized = false const clear = (id) => { const state = utterances.get(id) + if (!state) { + return + } if (state?.timer !== null) { clearTimeout(state.timer) state.timer = null @@ -153,10 +156,10 @@ const speak = (options) => { } // pause events: handle pause events - utterance.onpause = () => { - // Stop keep-alive when manually paused - clear(id) - } + // utterance.onpause = () => { + // // Stop keep-alive when manually paused + // clear(id) + // } } return new Promise((resolve, reject) => { From fa88731eb7d870e66cc8538b7f50dad03e534123 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 09:00:05 +0000 Subject: [PATCH 13/22] chore: remove unused pause event handling in speak function --- src/announcer/speechSynthesis.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 5fc673cd..0158508e 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -154,12 +154,6 @@ const speak = (options) => { resumeInfinity(id) } - - // pause events: handle pause events - // utterance.onpause = () => { - // // Stop keep-alive when manually paused - // clear(id) - // } } return new Promise((resolve, reject) => { From 4fb38de2216d512f01ac7697a9defee3dbc3b28c Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 09:00:27 +0000 Subject: [PATCH 14/22] fix: add cancelPrevious option to AnnouncerUtterance for managing speech queue --- index.d.ts | 6 ++++++ src/announcer/announcer.js | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/index.d.ts b/index.d.ts index 0bd00395..acce8946 100644 --- a/index.d.ts +++ b/index.d.ts @@ -54,6 +54,12 @@ declare module '@lightningjs/blits' { * @default 1 */ volume?: number, + /** + * Whether to cancel previous announcements when adding this one + * + * @default false + */ + cancelPrevious?: boolean } export interface AnnouncerUtterance extends Promise { diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index d3cbd123..11692fce 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -51,6 +51,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) } From cc30b5e6bb660590d33ed6bafbb93eaa98e481f2 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 09:46:18 +0000 Subject: [PATCH 15/22] chore: simplify stop api --- src/announcer/announcer.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 11692fce..6a8e3393 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -186,17 +186,17 @@ const stop = () => { // Always cancel speech synthesis to ensure clean state speechSynthesis.cancel() - if (currentId !== null && currentResolveFn) { - const resolveFn = currentResolveFn - currentId = null - currentResolveFn = null - isProcessing = false - resolveFn('interrupted') - } else { - // Reset state even if no current utterance - currentId = null - currentResolveFn = null - isProcessing = false + // 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') } } From 3a1a66ede22a333871a1e9bcea1d400db1044bd4 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 5 Dec 2025 10:55:26 +0000 Subject: [PATCH 16/22] fix: increase debounce duration in processQueue to improve performance --- src/announcer/announcer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 6a8e3393..7e387e05 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -161,7 +161,7 @@ const processQueue = async () => { processQueue() }) debounce = null - }, 200) + }, 300) } } From 7d842ee75ccd0b224f2718809a1a63b6d58f5193 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 5 Dec 2025 10:56:02 +0000 Subject: [PATCH 17/22] fix: add waitForSynthReady function to ensure speech synthesis engine is ready before speaking --- src/announcer/speechSynthesis.js | 86 +++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 0158508e..2e172593 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -107,7 +107,45 @@ const initialize = () => { initialized = true } -const speak = (options) => { +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' }) @@ -125,6 +163,9 @@ const speak = (options) => { utterances.delete(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 @@ -134,28 +175,6 @@ const speak = (options) => { utterances.set(id, { utterance, timer: null, ignoreResume: false }) - if (isAndroid === false) { - utterance.onstart = () => { - // utterances status: check if utterance still exists - if (utterances.has(id)) { - resumeInfinity(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 - } - - resumeInfinity(id) - } - } - return new Promise((resolve, reject) => { utterance.onend = () => { clear(id) @@ -170,6 +189,27 @@ const speak = (options) => { resolve() } + if (isAndroid === false) { + utterance.onstart = () => { + // utterances status: check if utterance still exists + if (utterances.has(id)) { + resumeInfinity(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 + } + + resumeInfinity(id) + } + } // handle error: syn.speak might throw try { syn.speak(utterance) From 41aa28995a14ccc94c711c3450575be7459a4d39 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 5 Dec 2025 17:23:14 +0000 Subject: [PATCH 18/22] fix: add enableUtteranceKeepAlive option to improve speech synthesis handling --- index.d.ts | 8 +++++++- src/announcer/announcer.js | 13 ++++++++++++- src/announcer/speechSynthesis.js | 12 +++++------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/index.d.ts b/index.d.ts index acce8946..d34d943e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -59,7 +59,13 @@ declare module '@lightningjs/blits' { * * @default false */ - cancelPrevious?: boolean + 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 7e387e05..f0a6114c 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() {}, @@ -206,6 +211,8 @@ const clear = () => { // Clear debounce timer clearDebounceTimer() + const prevResolveFn = currentResolveFn + // Cancel any active speech synthesis speechSynthesis.cancel() @@ -222,6 +229,10 @@ const clear = () => { currentId = null currentResolveFn = null isProcessing = false + + if (prevResolveFn) { + prevResolveFn('cleared') + } } const configure = (options = {}) => { diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 2e172593..4411dab4 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -19,8 +19,6 @@ import { Log } from '../lib/log.js' const syn = window.speechSynthesis -const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') - const utterances = new Map() // id -> { utterance, timer, ignoreResume } let initialized = false @@ -36,7 +34,7 @@ const clear = (id) => { } } -const resumeInfinity = (id) => { +const startKeepAlive = (id) => { const state = utterances.get(id) // utterance status: utterance was removed (cancelled or finished) @@ -81,7 +79,7 @@ const resumeInfinity = (id) => { state.timer = setTimeout(() => { // Double-check utterance still exists before resuming if (utterances.has(id)) { - resumeInfinity(id) + startKeepAlive(id) } }, 5000) } @@ -189,11 +187,11 @@ const speak = async (options) => { resolve() } - if (isAndroid === false) { + if (options.enableUtteranceKeepAlive === true) { utterance.onstart = () => { // utterances status: check if utterance still exists if (utterances.has(id)) { - resumeInfinity(id) + startKeepAlive(id) } } @@ -207,7 +205,7 @@ const speak = async (options) => { return } - resumeInfinity(id) + startKeepAlive(id) } } // handle error: syn.speak might throw From 6f3a1aea637d5f94cb79d297dd30a10e933c00c8 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 8 Dec 2025 14:13:53 +0000 Subject: [PATCH 19/22] fix: remove previous resolve function in clear method --- src/announcer/announcer.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index f0a6114c..88329bdd 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -211,8 +211,6 @@ const clear = () => { // Clear debounce timer clearDebounceTimer() - const prevResolveFn = currentResolveFn - // Cancel any active speech synthesis speechSynthesis.cancel() @@ -229,10 +227,6 @@ const clear = () => { currentId = null currentResolveFn = null isProcessing = false - - if (prevResolveFn) { - prevResolveFn('cleared') - } } const configure = (options = {}) => { From 2c5255f22f1cd9cb8280676b2a6a4fddbc597540 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 8 Dec 2025 14:35:34 +0000 Subject: [PATCH 20/22] fix: remove redundant utterances.delete calls in clear and startKeepAlive functions --- src/announcer/speechSynthesis.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 4411dab4..d308d10c 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -32,6 +32,7 @@ const clear = (id) => { clearTimeout(state.timer) state.timer = null } + utterances.delete(id) } const startKeepAlive = (id) => { @@ -47,7 +48,6 @@ const startKeepAlive = (id) => { // utterance check: utterance instance is invalid if (!(utterance instanceof SpeechSynthesisUtterance)) { clear(id) - utterances.delete(id) return } @@ -60,7 +60,6 @@ const startKeepAlive = (id) => { // syn status: syn might be undefined or cancelled if (!syn) { clear(id) - utterances.delete(id) return } @@ -158,7 +157,6 @@ const speak = async (options) => { // utterance status: utterance with same id already exists if (utterances.has(id)) { clear(id) - utterances.delete(id) } // Wait for engine to be ready @@ -176,14 +174,12 @@ const speak = async (options) => { return new Promise((resolve, reject) => { utterance.onend = () => { clear(id) - utterances.delete(id) resolve() } utterance.onerror = (e) => { Log.warn('SpeechSynthesisUtterance error:', e) clear(id) - utterances.delete(id) resolve() } @@ -213,7 +209,6 @@ const speak = async (options) => { syn.speak(utterance) } catch (error) { clear(id) - utterances.delete(id) reject(error) } }) From 8e9d10955b008cb5da42afc45a0142349dce0e70 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 8 Dec 2025 14:37:19 +0000 Subject: [PATCH 21/22] fix: log result of speaking in processQueue for better debugging --- src/announcer/announcer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 88329bdd..51babfca 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -148,8 +148,9 @@ 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 From 4dc14df47add7aa3e0bf3aa917d944c47165627f Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 8 Dec 2025 14:52:02 +0000 Subject: [PATCH 22/22] fix: include result in onend callback of speak function for better handling --- src/announcer/speechSynthesis.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index d308d10c..e464675a 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -172,9 +172,9 @@ const speak = async (options) => { utterances.set(id, { utterance, timer: null, ignoreResume: false }) return new Promise((resolve, reject) => { - utterance.onend = () => { + utterance.onend = (result) => { clear(id) - resolve() + resolve(result) } utterance.onerror = (e) => {