main
js 532 lines 13.8 KB
Raw
1 import { sleep } from "/js/sleep.js";
2 import * as shortcuts from "/js/shortcuts.js";
3
4 class TtsService extends EventTarget {
5 constructor() {
6 super();
7 this.providers = new Map();
8 this.synth = window.speechSynthesis;
9 this.browserUtterance = null;
10 this.audioEl = null;
11 this.currentAudio = null;
12 this.audioContext = null;
13 this.userHasInteracted = false;
14 this.ttsStream = null;
15 this._isSpeaking = false;
16
17 this.setupUserInteractionHandling();
18 }
19
20 registerProvider(id, provider) {
21 if (!id || !provider || typeof provider.synthesize !== "function") {
22 throw new Error("TTS providers must define an id and synthesize(text).");
23 }
24
25 this.providers.set(id, provider);
26 this.emitProvidersChange();
27
28 return () => this.unregisterProvider(id);
29 }
30
31 unregisterProvider(id) {
32 if (!this.providers.has(id)) return;
33
34 const activeProviderId = this.getActiveProviderId();
35 this.providers.delete(id);
36
37 if (activeProviderId === id) {
38 this.stop();
39 }
40
41 this.emitProvidersChange();
42 }
43
44 getActiveProviderId() {
45 const next = this.providers.keys().next();
46 return next.done ? "" : String(next.value || "");
47 }
48
49 getActiveProvider() {
50 const providerId = this.getActiveProviderId();
51 return providerId ? this.providers.get(providerId) || null : null;
52 }
53
54 hasProvider() {
55 return !!this.getActiveProvider();
56 }
57
58 isSpeaking() {
59 return this._isSpeaking;
60 }
61
62 getState() {
63 return {
64 activeProviderId: this.getActiveProviderId(),
65 isSpeaking: this.isSpeaking(),
66 userHasInteracted: this.userHasInteracted,
67 };
68 }
69
70 emitProvidersChange() {
71 this.dispatchEvent(
72 new CustomEvent("providerschange", {
73 detail: {
74 activeProviderId: this.getActiveProviderId(),
75 providerIds: Array.from(this.providers.keys()),
76 },
77 }),
78 );
79 this.emitStateChange();
80 }
81
82 emitStateChange() {
83 this.dispatchEvent(
84 new CustomEvent("statechange", {
85 detail: this.getState(),
86 }),
87 );
88 }
89
90 setSpeaking(value) {
91 const next = !!value;
92 if (this._isSpeaking === next) return;
93 this._isSpeaking = next;
94 this.emitStateChange();
95 }
96
97 setupUserInteractionHandling() {
98 const enableAudio = () => {
99 if (this.userHasInteracted) return;
100
101 this.userHasInteracted = true;
102 try {
103 this.audioContext = new (window.AudioContext ||
104 window.webkitAudioContext)();
105 this.audioContext.resume();
106 } catch (_error) {
107 // AudioContext is unavailable in some browsers/modes.
108 }
109
110 this.emitStateChange();
111 };
112
113 const events = ["click", "touchstart", "keydown", "mousedown"];
114 events.forEach((eventName) => {
115 document.addEventListener(eventName, enableAudio, {
116 once: true,
117 passive: true,
118 });
119 });
120 }
121
122 showAudioPermissionPrompt() {
123 shortcuts.frontendNotification({
124 type: "info",
125 message: "Click anywhere to enable audio playback",
126 displayTime: 5000,
127 frontendOnly: true,
128 });
129 }
130
131 async speak(text) {
132 const id = Math.random();
133 return await this.speakStream(id, text, true);
134 }
135
136 async speakStream(id, text, finished = false) {
137 if (
138 this.ttsStream &&
139 this.ttsStream.id === id &&
140 this.ttsStream.text === text &&
141 this.ttsStream.finished === finished
142 ) {
143 return;
144 }
145
146 if (!this.userHasInteracted) {
147 this.showAudioPermissionPrompt();
148 return;
149 }
150
151 if (!this.ttsStream || this.ttsStream.id !== id) {
152 this.ttsStream = {
153 id,
154 text,
155 finished,
156 running: false,
157 lastChunkIndex: -1,
158 stopped: false,
159 chunks: [],
160 };
161 } else {
162 this.ttsStream.finished = finished;
163 this.ttsStream.text = text;
164 }
165
166 const cleanText = this.cleanText(text);
167 if (!cleanText.trim()) return;
168
169 this.ttsStream.chunks = this.chunkText(cleanText);
170 if (this.ttsStream.chunks.length === 0) return;
171
172 if (this.ttsStream.running) return;
173 this.ttsStream.running = true;
174
175 const terminator = () =>
176 this.ttsStream?.id !== id || this.ttsStream?.stopped;
177
178 while (true) {
179 if (terminator()) break;
180
181 const nextIndex = this.ttsStream.lastChunkIndex + 1;
182 if (nextIndex >= this.ttsStream.chunks.length) {
183 if (this.ttsStream.finished) break;
184 await new Promise((resolve) => setTimeout(resolve, 50));
185 continue;
186 }
187
188 if (
189 nextIndex === this.ttsStream.chunks.length - 1 &&
190 !this.ttsStream.finished
191 ) {
192 await new Promise((resolve) => setTimeout(resolve, 50));
193 continue;
194 }
195
196 this.ttsStream.lastChunkIndex = nextIndex;
197 const chunk = this.ttsStream.chunks[nextIndex];
198 await this.speakChunk(chunk, nextIndex > 0, terminator);
199 }
200
201 this.ttsStream.running = false;
202 }
203
204 async speakChunk(text, waitForPrevious = false, terminator = null) {
205 const provider = this.getActiveProvider();
206
207 if (provider) {
208 try {
209 return await this.speakWithProvider(
210 provider,
211 text,
212 waitForPrevious,
213 terminator,
214 );
215 } catch (error) {
216 console.error("TTS provider failed, falling back to browser TTS", error);
217 }
218 }
219
220 return await this.speakWithBrowser(text, waitForPrevious, terminator);
221 }
222
223 async speakWithProvider(provider, text, waitForPrevious = false, terminator = null) {
224 const payload = await provider.synthesize(text, {
225 providerId: this.getActiveProviderId(),
226 });
227
228 while (waitForPrevious && this.isSpeaking()) {
229 await sleep(25);
230 }
231 if (terminator && terminator()) return;
232
233 if (!waitForPrevious) {
234 this.stopAudio();
235 }
236
237 if (!payload) return;
238
239 if (Array.isArray(payload.audioParts)) {
240 for (const part of payload.audioParts) {
241 if (terminator && terminator()) return;
242 await this.playAudioBase64(part, payload.mimeType);
243 await sleep(100);
244 }
245 return;
246 }
247
248 const audioBase64 = payload.audioBase64 || payload.audio;
249 if (audioBase64) {
250 await this.playAudioBase64(audioBase64, payload.mimeType);
251 }
252 }
253
254 async speakWithBrowser(text, waitForPrevious = false, terminator = null) {
255 while (waitForPrevious && this.isSpeaking()) {
256 await sleep(25);
257 }
258 if (terminator && terminator()) return;
259
260 if (!waitForPrevious) {
261 this.stopAudio();
262 }
263
264 return await new Promise((resolve, reject) => {
265 const utterance = new SpeechSynthesisUtterance(text);
266 this.browserUtterance = utterance;
267
268 utterance.onstart = () => {
269 this.setSpeaking(true);
270 };
271 utterance.onend = () => {
272 if (this.browserUtterance === utterance) {
273 this.browserUtterance = null;
274 }
275 this.setSpeaking(false);
276 resolve();
277 };
278 utterance.onerror = (error) => {
279 if (this.browserUtterance === utterance) {
280 this.browserUtterance = null;
281 }
282 this.setSpeaking(false);
283 reject(error);
284 };
285
286 this.synth.speak(utterance);
287 });
288 }
289
290 async playAudioBase64(base64Audio, mimeType = "audio/wav") {
291 return await new Promise((resolve, reject) => {
292 const audio = this.audioEl ? this.audioEl : (this.audioEl = new Audio());
293
294 audio.pause();
295 audio.currentTime = 0;
296
297 audio.onplay = () => {
298 this.setSpeaking(true);
299 };
300 audio.onended = () => {
301 this.setSpeaking(false);
302 this.currentAudio = null;
303 resolve();
304 };
305 audio.onerror = (error) => {
306 this.setSpeaking(false);
307 this.currentAudio = null;
308 reject(error);
309 };
310
311 audio.src = `data:${mimeType};base64,${base64Audio}`;
312 this.currentAudio = audio;
313
314 audio.play().catch((error) => {
315 this.setSpeaking(false);
316 this.currentAudio = null;
317 if (error?.name === "NotAllowedError") {
318 this.showAudioPermissionPrompt();
319 this.userHasInteracted = false;
320 this.emitStateChange();
321 }
322 reject(error);
323 });
324 });
325 }
326
327 stop() {
328 this.stopAudio();
329 if (this.ttsStream) {
330 this.ttsStream.stopped = true;
331 }
332
333 const provider = this.getActiveProvider();
334 try {
335 provider?.stop?.();
336 } catch (error) {
337 console.error("Failed to stop TTS provider cleanly", error);
338 }
339 }
340
341 stopAudio() {
342 if (this.synth?.speaking) {
343 this.synth.cancel();
344 }
345
346 if (this.audioEl) {
347 this.audioEl.pause();
348 this.audioEl.currentTime = 0;
349 }
350
351 this.currentAudio = null;
352 this.setSpeaking(false);
353 }
354
355 chunkText(text, { maxChunkLength = 135, lineSeparator = "..." } = {}) {
356 const INC_LIMIT = maxChunkLength * 2;
357 const MIN_CHUNK_LENGTH = 20;
358
359 const splitDeep = (segment) => {
360 if (segment.length <= INC_LIMIT) return [segment];
361 const byComma = segment.match(/[^,]+(?:,|$)/g);
362 if (byComma.length > 1) {
363 return byComma.flatMap((part, index) =>
364 splitDeep(
365 index < byComma.length - 1 ? part : part.replace(/,$/, ""),
366 ),
367 );
368 }
369
370 const out = [];
371 let part = "";
372 for (const word of segment.split(/\s+/)) {
373 const need = part ? part.length + 1 + word.length : word.length;
374 if (need <= maxChunkLength) {
375 part += (part ? " " : "") + word;
376 } else {
377 if (part) out.push(part);
378 if (word.length > maxChunkLength) {
379 for (let index = 0; index < word.length; index += maxChunkLength) {
380 out.push(word.slice(index, index + maxChunkLength));
381 }
382 part = "";
383 } else {
384 part = word;
385 }
386 }
387 }
388 if (part) out.push(part);
389 return out;
390 };
391
392 const sentenceTokens = (line) => {
393 const tokens = [];
394 let start = 0;
395 for (let index = 0; index < line.length; index++) {
396 const character = line[index];
397 if (
398 (character === "." || character === "!" || character === "?") &&
399 /\s/.test(line[index + 1] || "")
400 ) {
401 tokens.push(line.slice(start, index + 1));
402 index += 1;
403 start = index + 1;
404 }
405 }
406 if (start < line.length) {
407 tokens.push(line.slice(start));
408 }
409 return tokens.flatMap((token) => splitDeep(token.trim())).filter(Boolean);
410 };
411
412 const initialChunks = [];
413 const lines = text.split(/\n+/).filter((line) => line.trim());
414 for (const line of lines) {
415 initialChunks.push(...sentenceTokens(line.trim()));
416 }
417
418 const finalChunks = [];
419 let currentChunk = "";
420
421 for (let index = 0; index < initialChunks.length; index++) {
422 const chunk = initialChunks[index];
423 if (!currentChunk) {
424 currentChunk = chunk;
425 if (
426 index === initialChunks.length - 1 ||
427 currentChunk.length >= MIN_CHUNK_LENGTH
428 ) {
429 finalChunks.push(currentChunk);
430 currentChunk = "";
431 }
432 continue;
433 }
434
435 if (currentChunk.length < MIN_CHUNK_LENGTH) {
436 const merged = `${currentChunk} ${lineSeparator} ${chunk}`;
437 if (merged.length <= maxChunkLength) {
438 currentChunk = merged;
439 } else {
440 finalChunks.push(currentChunk);
441 currentChunk = chunk;
442 }
443 } else {
444 finalChunks.push(currentChunk);
445 currentChunk = chunk;
446 }
447
448 if (index === initialChunks.length - 1 && currentChunk) {
449 finalChunks.push(currentChunk);
450 }
451 }
452
453 return finalChunks.map((chunk) => chunk.trimEnd());
454 }
455
456 cleanText(text) {
457 const SUB = "\x1A";
458 const codePlaceholder = `${SUB}code${SUB}`;
459 const tablePlaceholder = `${SUB}table${SUB}`;
460
461 text = text.replace(
462 /```(?:[a-zA-Z0-9]*\n)?[\s\S]*?```/g,
463 codePlaceholder,
464 );
465 text = text.replace(/```(?:[a-zA-Z0-9]*\n)?[\s\S]*$/g, codePlaceholder);
466 text = text.replace(/`([^`]*)`/g, "$1");
467
468 try {
469 const parser = new DOMParser();
470 const doc = parser.parseFromString(`<div>${text}</div>`, "text/html");
471 doc.querySelectorAll("pre, code").forEach((element) => {
472 element.textContent = codePlaceholder;
473 });
474 text = doc.body.textContent || "";
475 } catch (_error) {
476 text = text.replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, codePlaceholder);
477 text = text.replace(/<code[^>]*>[\s\S]*?<\/code>/gi, codePlaceholder);
478 text = text.replace(/<[^>]+>/g, "");
479 }
480
481 text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
482 text = text.replace(/[*_#]+/g, "");
483
484 if (text.includes("|")) {
485 const tableLines = text
486 .split("\n")
487 .filter((line) => line.includes("|") && line.trim().startsWith("|"));
488 if (tableLines.length > 0) {
489 for (const line of tableLines) {
490 text = text.replace(line, tablePlaceholder);
491 }
492 } else {
493 text = text.replace(/\|[^\n]*\|/g, tablePlaceholder);
494 }
495 }
496
497 text = text.replace(
498 /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
499 "",
500 );
501
502 text = text.replace(/https?:\/\/[^\s]+/g, (match) => {
503 try {
504 return new URL(match).hostname;
505 } catch {
506 return "";
507 }
508 });
509
510 text = text.replace(
511 /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
512 "UUID",
513 );
514 text = text.replace(/[ \t]+/g, " ");
515
516 const mergePlaceholders = (value, placeholder, replacement) => {
517 const pattern = new RegExp(`${placeholder}\\s*${placeholder}`, "g");
518 while (pattern.test(value)) {
519 value = value.replace(pattern, placeholder);
520 }
521 return value.replace(new RegExp(placeholder, "g"), replacement);
522 };
523
524 text = mergePlaceholders(text, codePlaceholder, "See code attached ...");
525 text = mergePlaceholders(text, tablePlaceholder, "See table attached ...");
526
527 return text.trim();
528 }
529 }
530
531 export const ttsService = new TtsService();
532 globalThis.ttsService = ttsService;