Blame Raw
Cohee · e3f41666 · · 295 lines (10.5 KB)
1 contributor
1import { isMobile } from '../../RossAscends-mods.js';
2import { getPreviewString } from './index.js';
3import { saveTtsProviderSettings } from './index.js';
4export { SystemTtsProvider };
5import { 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
17var 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
77class 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}