generated from ztimson/template
Home screen update
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s
This commit is contained in:
176
public/tts.mjs
Normal file
176
public/tts.mjs
Normal file
@@ -0,0 +1,176 @@
|
||||
// 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, '');
|
||||
}
|
||||
Reference in New Issue
Block a user