// tts.service.mjs export class TTS { static QUALITY_PATTERNS = ['Google', 'Microsoft', 'Samantha', 'Premium', 'Natural', 'Neural']; static _errorHandlerInstalled = false; static _instance = null; _currentUtterance = null; _voicesLoaded; _stoppedUtterances = new WeakSet(); _rate = 1; _pitch = 1; _volume = 1; _voice; _hooks = { onSentenceStart: [], onSentenceEnd: [], onChunk: [], onComplete: [] }; constructor({ rate, pitch, volume, voice } = {}) { TTS.installErrorHandler(); this._voicesLoaded = this.initializeVoices(); if (rate !== undefined) this._rate = rate; if (pitch !== undefined) this._pitch = pitch; if (volume !== undefined) this._volume = volume; this._voice = voice === null ? undefined : voice; } static getInstance(config) { if(!this._instance) this._instance = new TTS(config); return this._instance; } static installErrorHandler() { if (this._errorHandlerInstalled) return; window.addEventListener('unhandledrejection', e => { if (e.reason?.error === 'interrupted' && e.reason instanceof SpeechSynthesisErrorEvent) e.preventDefault(); }); this._errorHandlerInstalled = true; } initializeVoices() { return new Promise(res => { const voices = window.speechSynthesis.getVoices(); if (voices.length) { if (!this._voice) this._voice = TTS.bestVoice(); res(); } else { const h = () => { window.speechSynthesis.removeEventListener('voiceschanged', h); if (!this._voice) this._voice = TTS.bestVoice(); res(); }; window.speechSynthesis.addEventListener('voiceschanged', h); } }); } static bestVoice(lang = 'en') { const voices = window.speechSynthesis.getVoices(); for (const p of this.QUALITY_PATTERNS) { const v = voices.find(v => v.name.includes(p) && v.lang.startsWith(lang)); if (v) return v; } return voices.find(v => v.lang.startsWith(lang)); } static cleanText(t) { return removeEmojis(t).replace(/```[\s\S]*?```/g, ' code block ').replace(/[#*_~`]/g, ''); } on(event, callback) { if(this._hooks[event]) this._hooks[event].push(callback); } off(event, callback) { if(this._hooks[event]) this._hooks[event] = this._hooks[event].filter(cb => cb !== callback); } _emit(event, data) { if (this._hooks[event]) { this._hooks[event].forEach(cb => { try { cb(data); } catch (err) { console.error(`❌ Error in ${event} hook:`, err); } }); } } createUtterance(t) { const u = new SpeechSynthesisUtterance(TTS.cleanText(t)); const v = this._voice || TTS.bestVoice(); if (v) u.voice = v; u.rate = this._rate; u.pitch = this._pitch; u.volume = this._volume; return u; } async speak(t) { if(!t.trim()) return; await this._voicesLoaded; if(this._currentUtterance && !this._isStreaming) this.stop(); return new Promise((res, rej) => { this._currentUtterance = this.createUtterance(t); const u = this._currentUtterance; u.onend = () => { this._currentUtterance = null; res(); }; u.onerror = e => { console.error('❌ Utterance error:', e); this._currentUtterance = null; if (this._stoppedUtterances.has(u) && e.error === 'interrupted') res(); else rej(e); }; window.speechSynthesis.speak(u); this._emit('onSentenceStart', {sentence: t}); }).finally(() => this._emit('onSentenceEnd', {sentence: t})); } stop() { if (this._currentUtterance) this._stoppedUtterances.add(this._currentUtterance); window.speechSynthesis.cancel(); this._currentUtterance = null; this._isStreaming = false; this._emit('onComplete', {}); } speakStream() { this._isStreaming = true; let buf = ''; let sentenceQueue = Promise.resolve(); const rx = /[^.!?\n]+[.!?\n]+/g; return { next: t => { buf += t; this._emit('onChunk', { text: t }); const ss = buf.match(rx); if(ss) { ss.forEach(s => { const sentence = s.trim(); sentenceQueue = sentenceQueue.then(async () => this.speak(sentence)); }); } buf = buf.replace(rx, ''); }, done: async () => { if (buf.trim()) { const sentence = buf.trim(); sentenceQueue = sentenceQueue.then(async () => this.speak(sentence)); buf = ''; } await sentenceQueue; this._isStreaming = false; this._emit('onComplete', {}); } }; } } export function removeEmojis(str) { const emojiRegex = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud83c[\udde6-\uddff]|[\ud83d[\ude00-\ude4f]|[\ud83d[\ude80-\udeff]|[\ud83c[\udd00-\uddff]|[\ud83d[\ude50-\ude7f]|[\u2600-\u26ff]|[\u2700-\u27bf]|[\ud83e[\udd00-\uddff]|[\ud83c[\udf00-\uffff]|[\ud83d[\ude00-\udeff]|[\ud83c[\udde6-\uddff])/g; return str.replace(emojiRegex, ''); }