Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3170c73
fix: resumeInfinity/onresume interaction
tuncayuk Nov 26, 2025
15614f1
chore: ensure target is a SpeechSynthesisUtterance in resumeInfinity …
tuncayuk Dec 2, 2025
f35ab5e
fix: ensure infinityTimer is checked against null in clear and resume…
tuncayuk Dec 2, 2025
910df0c
fix: ensure resumeFromKeepAlive is strictly checked for true in onres…
tuncayuk Dec 2, 2025
ecb2ddc
fix: improve utterance management and error handling in speech synthesis
tuncayuk Dec 3, 2025
d43ee72
chore: add placeholder for getVoices method in speech synthesis
tuncayuk Dec 3, 2025
3e56c9d
chore: improve error handling in speak function of speech synthesis
tuncayuk Dec 3, 2025
d2721b6
chore: enhance queue management and debounce handling in announcer
tuncayuk Dec 3, 2025
6aa4a19
fix: add optional chaining to safely check timer state in resumeInfin…
tuncayuk Dec 3, 2025
a1ebc94
fix: enhance stop and clear functions to ensure clean state in speech…
tuncayuk Dec 3, 2025
9910a08
fix: add double-check for utterance existence before resuming in resu…
tuncayuk Dec 3, 2025
624604f
fix: add early return in clear function to handle non-existent state
tuncayuk Dec 4, 2025
fa88731
chore: remove unused pause event handling in speak function
tuncayuk Dec 4, 2025
4fb38de
fix: add cancelPrevious option to AnnouncerUtterance for managing spe…
tuncayuk Dec 4, 2025
cc30b5e
chore: simplify stop api
tuncayuk Dec 4, 2025
3a1a66e
fix: increase debounce duration in processQueue to improve performance
tuncayuk Dec 5, 2025
7d842ee
fix: add waitForSynthReady function to ensure speech synthesis engine…
tuncayuk Dec 5, 2025
41aa289
fix: add enableUtteranceKeepAlive option to improve speech synthesis …
tuncayuk Dec 5, 2025
6f3a1ae
fix: remove previous resolve function in clear method
tuncayuk Dec 8, 2025
2c5255f
fix: remove redundant utterances.delete calls in clear and startKeepA…
tuncayuk Dec 8, 2025
8e9d109
fix: log result of speaking in processQueue for better debugging
tuncayuk Dec 8, 2025
4dc14df
fix: include result in onend callback of speak function for better ha…
tuncayuk Dec 8, 2025
8441944
Removed unneeded variable assignments.
michielvandergeest Dec 23, 2025
9959d92
Removed debug log.
michielvandergeest Dec 23, 2025
1d4884e
Removed debug log and renamed variable.
michielvandergeest Dec 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = any> extends Promise<T> {
Expand Down
61 changes: 58 additions & 3 deletions src/announcer/announcer.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ let currentId = null
let debounce = null

// Global default utterance options
let globalDefaultOptions = {}
let globalDefaultOptions = {
enableUtteranceKeepAlive: /android/i.test((window.navigator || {}).userAgent || ''),
}

const noopAnnouncement = {
then() {},
Expand All @@ -51,6 +53,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)
}

Expand Down Expand Up @@ -106,18 +113,22 @@ const addToQueue = (message, politeness, delay = false, options = {}) => {
return done
}

let currentResolveFn = null

const processQueue = async () => {
if (isProcessing === true || queue.length === 0) return
isProcessing = true

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)
Expand All @@ -138,33 +149,77 @@ 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)
processQueue()
})
debounce = null
}, 200)
}, 300)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there a specific reason for increasing the timeout from 200 to 300?

}
}

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 = () => {
// 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 resolveFn = currentResolveFn

// Reset state
currentId = null
currentResolveFn = null
isProcessing = false

// Resolve promise if there was an active utterance
if (resolveFn !== null) {
resolveFn('interrupted')
}
}

const clear = () => {
// Clear debounce timer
clearDebounceTimer()

// Cancel any active speech synthesis
speechSynthesis.cancel()
Comment on lines +207 to +208
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does clearing the queue also mean that we want to cancel the current message being spoken out? I'm not sure that it should always be so ..


// 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 = {}) => {
Expand Down
201 changes: 164 additions & 37 deletions src/announcer/speechSynthesis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer not to use optional chaining (as it doesn't transpile to very optimal es5 code)

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)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this instanceof check? would there ever be a case where it's not an instance of SpeechSynthesisUtterance? instanceof is a relatively expensive operation

clear(id)
return
}

// Clear existing timer for this specific utterance
if (state?.timer !== null) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the optional chaining syntax

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 = {
Expand All @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this correct? if syn is falsy, do we want to set initialized to 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(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why use window.setInterval instead of setInterval?

const elapsed = Date.now() - startTime
const isReady = !syn.speaking && !syn.pending

if (isReady) {
Log.warn(`SpeechSynthesis - ready after ${elapsed}ms`)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should probably be Log.debug statements and not Log.warn?

window.clearInterval(intervalId)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why use window.clearInterval instead of clearInterval?

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this not necessary anymore?

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)
}
})
}

Expand All @@ -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
Expand Down