Files
navi/public/tts.mjs
ztimson 5018311990
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s
Home screen update
2026-03-03 20:16:26 -05:00

177 lines
4.7 KiB
JavaScript

// 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);
});
}
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._emit('onSentenceStart', { sentence });
await this.speak(sentence);
this._emit('onSentenceEnd', { sentence });
});
});
}
buf = buf.replace(rx, '');
},
done: async () => {
if (buf.trim()) {
const sentence = buf.trim();
sentenceQueue = sentenceQueue.then(async () => {
this._emit('onSentenceStart', { sentence });
await this.speak(sentence);
this._emit('onSentenceEnd', { 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, '');
}