| 1 | import { isMobile } from '../../RossAscends-mods.js'; |
| 2 | import { getPreviewString } from './index.js'; |
| 3 | import { saveTtsProviderSettings } from './index.js'; |
| 4 | export { SystemTtsProvider }; |
| 5 | import { t } from '../../i18n.js'; |
| 6 | |
| 7 | /** |
| 8 | * Chunkify |
| 9 | * Google Chrome Speech Synthesis Chunking Pattern |
| 10 | * Fixes inconsistencies with speaking long texts in speechUtterance objects |
| 11 | * Licensed under the MIT License |
| 12 | * |
| 13 | * Peter Woolley and Brett Zamir |
| 14 | * Modified by Haaris for bug fixes |
| 15 | */ |
| 16 | |
| 17 | var speechUtteranceChunker = function (utt, settings, callback) { |
| 18 | settings = settings || {}; |
| 19 | var newUtt; |
| 20 | var txt = (settings && settings.offset !== undefined ? utt.text.substring(settings.offset) : utt.text); |
| 21 | if (utt.voice && utt.voice.voiceURI === 'native') { // Not part of the spec |
| 22 | newUtt = utt; |
| 23 | newUtt.text = txt; |
| 24 | newUtt.addEventListener('end', function () { |
| 25 | if (speechUtteranceChunker.cancel) { |
| 26 | speechUtteranceChunker.cancel = false; |
| 27 | } |
| 28 | if (callback !== undefined) { |
| 29 | callback(); |
| 30 | } |
| 31 | }); |
| 32 | } else { |
| 33 | var chunkLength = (settings && settings.chunkLength) || 160; |
| 34 | var pattRegex = new RegExp('^[\\s\\S]{' + Math.floor(chunkLength / 2) + ',' + chunkLength + '}[.!?,]{1}|^[\\s\\S]{1,' + chunkLength + '}$|^[\\s\\S]{1,' + chunkLength + '} '); |
| 35 | var chunkArr = txt.match(pattRegex); |
| 36 | |
| 37 | if (chunkArr == null || chunkArr[0] === undefined || chunkArr[0].length <= 2) { |
| 38 | //call once all text has been spoken... |
| 39 | if (callback !== undefined) { |
| 40 | callback(); |
| 41 | } |
| 42 | return; |
| 43 | } |
| 44 | var chunk = chunkArr[0]; |
| 45 | newUtt = new SpeechSynthesisUtterance(chunk); |
| 46 | var x; |
| 47 | for (x in utt) { |
| 48 | if (Object.hasOwn(utt, x) && x !== 'text') { |
| 49 | newUtt[x] = utt[x]; |
| 50 | } |
| 51 | } |
| 52 | newUtt.lang = utt.lang; |
| 53 | newUtt.voice = utt.voice; |
| 54 | newUtt.rate = utt.rate; |
| 55 | newUtt.pitch = utt.pitch; |
| 56 | newUtt.addEventListener('end', function () { |
| 57 | if (speechUtteranceChunker.cancel) { |
| 58 | speechUtteranceChunker.cancel = false; |
| 59 | return; |
| 60 | } |
| 61 | settings.offset = settings.offset || 0; |
| 62 | settings.offset += chunk.length; |
| 63 | speechUtteranceChunker(utt, settings, callback); |
| 64 | }); |
| 65 | } |
| 66 | |
| 67 | if (settings.modifier) { |
| 68 | settings.modifier(newUtt); |
| 69 | } |
| 70 | console.log(newUtt); //IMPORTANT!! Do not remove: Logging the object out fixes some onend firing issues. |
| 71 | //placing the speak invocation inside a callback fixes ordering and onend issues. |
| 72 | setTimeout(function () { |
| 73 | speechSynthesis.speak(newUtt); |
| 74 | }, 0); |
| 75 | }; |
| 76 | |
| 77 | class SystemTtsProvider { |
| 78 | //########// |
| 79 | // Config // |
| 80 | //########// |
| 81 | |
| 82 | // Static constants for the simulated default voice |
| 83 | static BROWSER_DEFAULT_VOICE_ID = '__browser_default__'; |
| 84 | static BROWSER_DEFAULT_VOICE_NAME = 'System Default Voice'; |
| 85 | |
| 86 | settings; |
| 87 | ready = false; |
| 88 | voices = []; |
| 89 | separator = ' ... '; |
| 90 | |
| 91 | defaultSettings = { |
| 92 | voiceMap: {}, |
| 93 | rate: 1, |
| 94 | pitch: 1, |
| 95 | }; |
| 96 | |
| 97 | get settingsHtml() { |
| 98 | if (!('speechSynthesis' in window)) { |
| 99 | return t`Your browser or operating system doesn't support speech synthesis`; |
| 100 | } |
| 101 | |
| 102 | return '<p>' + t`Uses the voices provided by your operating system` + `</p> |
| 103 | <label for="system_tts_rate">` + t`Rate:` + ` <span id="system_tts_rate_output"></span></label> |
| 104 | <input id="system_tts_rate" type="range" value="${this.defaultSettings.rate}" min="0.1" max="2" step="0.01" /> |
| 105 | <label for="system_tts_pitch">` + t`Pitch:` + ` <span id="system_tts_pitch_output"></span></label> |
| 106 | <input id="system_tts_pitch" type="range" value="${this.defaultSettings.pitch}" min="0" max="2" step="0.01" />`; |
| 107 | } |
| 108 | |
| 109 | onSettingsChange() { |
| 110 | this.settings.rate = Number($('#system_tts_rate').val()); |
| 111 | this.settings.pitch = Number($('#system_tts_pitch').val()); |
| 112 | $('#system_tts_pitch_output').text(this.settings.pitch); |
| 113 | $('#system_tts_rate_output').text(this.settings.rate); |
| 114 | saveTtsProviderSettings(); |
| 115 | } |
| 116 | |
| 117 | async loadSettings(settings) { |
| 118 | // Populate Provider UI given input settings |
| 119 | if (Object.keys(settings).length == 0) { |
| 120 | console.info('Using default TTS Provider settings'); |
| 121 | } |
| 122 | |
| 123 | // iOS should only allows speech synthesis trigged by user interaction |
| 124 | if (isMobile()) { |
| 125 | let hasEnabledVoice = false; |
| 126 | |
| 127 | document.addEventListener('click', () => { |
| 128 | if (hasEnabledVoice) { |
| 129 | return; |
| 130 | } |
| 131 | const utterance = new SpeechSynthesisUtterance(' . '); |
| 132 | utterance.volume = 0; |
| 133 | speechSynthesis.speak(utterance); |
| 134 | hasEnabledVoice = true; |
| 135 | }); |
| 136 | } |
| 137 | |
| 138 | // Only accept keys defined in defaultSettings |
| 139 | this.settings = this.defaultSettings; |
| 140 | |
| 141 | for (const key in settings) { |
| 142 | if (key in this.settings) { |
| 143 | this.settings[key] = settings[key]; |
| 144 | } else { |
| 145 | throw `Invalid setting passed to TTS Provider: ${key}`; |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | $('#system_tts_rate').val(this.settings.rate || this.defaultSettings.rate); |
| 150 | $('#system_tts_pitch').val(this.settings.pitch || this.defaultSettings.pitch); |
| 151 | |
| 152 | // Trigger updates |
| 153 | $('#system_tts_rate').on('input', () => { this.onSettingsChange(); }); |
| 154 | $('#system_tts_pitch').on('input', () => { this.onSettingsChange(); }); |
| 155 | |
| 156 | $('#system_tts_pitch_output').text(this.settings.pitch); |
| 157 | $('#system_tts_rate_output').text(this.settings.rate); |
| 158 | console.debug('SystemTTS: Settings loaded'); |
| 159 | } |
| 160 | |
| 161 | // Perform a simple readiness check by trying to fetch voiceIds |
| 162 | async checkReady() { |
| 163 | await this.fetchTtsVoiceObjects(); |
| 164 | } |
| 165 | |
| 166 | async onRefreshClick() { |
| 167 | return; |
| 168 | } |
| 169 | |
| 170 | //#################// |
| 171 | // TTS Interfaces // |
| 172 | //#################// |
| 173 | fetchTtsVoiceObjects() { |
| 174 | if (!('speechSynthesis' in window)) { |
| 175 | return Promise.resolve([]); |
| 176 | } |
| 177 | |
| 178 | return new Promise((resolve) => { |
| 179 | setTimeout(() => { |
| 180 | let voices = speechSynthesis.getVoices(); |
| 181 | |
| 182 | if (voices.length === 0) { |
| 183 | // Edge compat: Provide default when voices empty |
| 184 | console.warn('SystemTTS: getVoices() returned empty list. Providing browser default option.'); |
| 185 | const defaultVoice = { |
| 186 | name: SystemTtsProvider.BROWSER_DEFAULT_VOICE_NAME, |
| 187 | voice_id: SystemTtsProvider.BROWSER_DEFAULT_VOICE_ID, |
| 188 | preview_url: false, |
| 189 | lang: navigator.language || 'en-US', |
| 190 | }; |
| 191 | resolve([defaultVoice]); |
| 192 | } else { |
| 193 | const mappedVoices = voices |
| 194 | .sort((a, b) => a.lang.localeCompare(b.lang) || a.name.localeCompare(b.name)) |
| 195 | .map(x => ({ name: x.name, voice_id: x.voiceURI, preview_url: false, lang: x.lang })); |
| 196 | resolve(mappedVoices); |
| 197 | } |
| 198 | }, 50); |
| 199 | }); |
| 200 | } |
| 201 | |
| 202 | previewTtsVoice(voiceId) { |
| 203 | if (!('speechSynthesis' in window)) { |
| 204 | throw new Error('Speech synthesis API is not supported'); |
| 205 | } |
| 206 | |
| 207 | let voice = null; |
| 208 | if (voiceId !== SystemTtsProvider.BROWSER_DEFAULT_VOICE_ID) { |
| 209 | const voices = speechSynthesis.getVoices(); |
| 210 | voice = voices.find(x => x.voiceURI === voiceId); |
| 211 | |
| 212 | if (!voice && voices.length > 0) { |
| 213 | console.warn(`SystemTTS Preview: Voice ID "${voiceId}" not found among available voices. Using browser default.`); |
| 214 | } else if (!voice && voices.length === 0) { |
| 215 | console.warn('SystemTTS Preview: Voice list is empty. Using browser default.'); |
| 216 | } |
| 217 | } else { |
| 218 | console.log('SystemTTS Preview: Using browser default voice as requested.'); |
| 219 | } |
| 220 | |
| 221 | speechSynthesis.cancel(); |
| 222 | const langForPreview = voice ? voice.lang : (navigator.language || 'en-US'); |
| 223 | const text = getPreviewString(langForPreview); |
| 224 | const utterance = new SpeechSynthesisUtterance(text); |
| 225 | |
| 226 | if (voice) { |
| 227 | utterance.voice = voice; |
| 228 | } |
| 229 | |
| 230 | utterance.rate = this.settings.rate || 1; |
| 231 | utterance.pitch = this.settings.pitch || 1; |
| 232 | |
| 233 | utterance.onerror = (event) => { |
| 234 | console.error(`SystemTTS Preview Error: ${event.error}`, event); |
| 235 | }; |
| 236 | |
| 237 | speechSynthesis.speak(utterance); |
| 238 | } |
| 239 | |
| 240 | async getVoice(voiceName) { |
| 241 | if (!('speechSynthesis' in window)) { |
| 242 | return { voice_id: null, name: 'API Not Supported' }; |
| 243 | } |
| 244 | |
| 245 | if (voiceName === SystemTtsProvider.BROWSER_DEFAULT_VOICE_NAME) { |
| 246 | return { |
| 247 | voice_id: SystemTtsProvider.BROWSER_DEFAULT_VOICE_ID, |
| 248 | name: SystemTtsProvider.BROWSER_DEFAULT_VOICE_NAME, |
| 249 | }; |
| 250 | } |
| 251 | |
| 252 | const voices = speechSynthesis.getVoices(); |
| 253 | |
| 254 | if (voices.length === 0) { |
| 255 | console.warn('SystemTTS: Empty voice list, using default fallback'); |
| 256 | return { |
| 257 | voice_id: SystemTtsProvider.BROWSER_DEFAULT_VOICE_ID, |
| 258 | name: SystemTtsProvider.BROWSER_DEFAULT_VOICE_NAME, |
| 259 | }; |
| 260 | } |
| 261 | |
| 262 | const match = voices.find(x => x.name == voiceName); |
| 263 | |
| 264 | if (!match) { |
| 265 | throw new Error(`SystemTTS getVoice: TTS Voice name "${voiceName}" not found`); |
| 266 | } |
| 267 | |
| 268 | return { voice_id: match.voiceURI, name: match.name }; |
| 269 | } |
| 270 | |
| 271 | async generateTts(text, voiceId) { |
| 272 | if (!('speechSynthesis' in window)) { |
| 273 | throw 'Speech synthesis API is not supported'; |
| 274 | } |
| 275 | |
| 276 | const silence = await fetch('/sounds/silence.mp3'); |
| 277 | |
| 278 | return new Promise((resolve, reject) => { |
| 279 | const voices = speechSynthesis.getVoices(); |
| 280 | const voice = voices.find(x => x.voiceURI === voiceId); |
| 281 | const utterance = new SpeechSynthesisUtterance(text); |
| 282 | utterance.voice = voice; |
| 283 | utterance.rate = this.settings.rate || 1; |
| 284 | utterance.pitch = this.settings.pitch || 1; |
| 285 | utterance.onend = () => resolve(silence); |
| 286 | utterance.onerror = () => reject(); |
| 287 | speechUtteranceChunker(utterance, { |
| 288 | chunkLength: 200, |
| 289 | }, function () { |
| 290 | resolve(silence); |
| 291 | console.log('System TTS done'); |
| 292 | }); |
| 293 | }); |
| 294 | } |
| 295 | } |