generated from ztimson/template
All checks were successful
Build and publish / Build Container (push) Successful in 1m26s
170 lines
4.6 KiB
JavaScript
170 lines
4.6 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);
|
|
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, '');
|
|
}
|