-
Notifications
You must be signed in to change notification settings - Fork 34
fix: resumeInfinity/onresume interaction #544
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
3170c73
15614f1
f35ab5e
910df0c
ecb2ddc
d43ee72
3e56c9d
d2721b6
6aa4a19
a1ebc94
9910a08
624604f
fa88731
4fb38de
cc30b5e
3a1a66e
7d842ee
41aa289
6f3a1ae
2c5255f
8e9d109
4dc14df
8441944
9959d92
1d4884e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() {}, | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
|
|
@@ -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) | ||
|
|
@@ -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) | ||
| } | ||
| } | ||
|
|
||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = {}) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| clear(id) | ||
| return | ||
| } | ||
|
|
||
| // Clear existing timer for this specific utterance | ||
| if (state?.timer !== null) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = { | ||
|
|
@@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this correct? if |
||
| 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(() => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why use |
||
| const elapsed = Date.now() - startTime | ||
| const isReady = !syn.speaking && !syn.pending | ||
|
|
||
| if (isReady) { | ||
| Log.warn(`SpeechSynthesis - ready after ${elapsed}ms`) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These should probably be |
||
| window.clearInterval(intervalId) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why use |
||
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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?