106 lines
3.2 KiB
JavaScript
106 lines
3.2 KiB
JavaScript
var speechVoices = [];
|
|
var speechVoicesLoaded = false;
|
|
var speechTimer = null;
|
|
var piperFallbackTimer = null;
|
|
|
|
function refreshSpeechVoices() {
|
|
if (!window.speechSynthesis) return;
|
|
var voices = window.speechSynthesis.getVoices();
|
|
if (voices && voices.length) {
|
|
speechVoices = voices;
|
|
speechVoicesLoaded = true;
|
|
}
|
|
return speechVoices;
|
|
}
|
|
|
|
function pickSpeechVoice(lang) {
|
|
refreshSpeechVoices();
|
|
var target = lang.toLowerCase();
|
|
var exact = speechVoices.find(function(voice) {
|
|
return String(voice.lang || '').toLowerCase() === target;
|
|
});
|
|
if (exact) return exact;
|
|
var prefix = target.split('-')[0];
|
|
return speechVoices.find(function(voice) {
|
|
return String(voice.lang || '').toLowerCase().split('-')[0] === prefix;
|
|
}) || null;
|
|
}
|
|
|
|
function buildSpeechUtterance(text, lang) {
|
|
var utterance = new SpeechSynthesisUtterance(text);
|
|
var langMap = { 'es': 'es-ES', 'en': 'en-US', 'zh': 'zh-CN' };
|
|
utterance.lang = langMap[lang] || lang;
|
|
var voice = pickSpeechVoice(utterance.lang);
|
|
if (voice) utterance.voice = voice;
|
|
utterance.rate = 0.9;
|
|
utterance.pitch = 1;
|
|
return utterance;
|
|
}
|
|
|
|
function doSpeak(text, lang) {
|
|
if (!window.speechSynthesis || !window.SpeechSynthesisUtterance || !text) return;
|
|
window.speechSynthesis.cancel();
|
|
if (speechTimer) clearTimeout(speechTimer);
|
|
var utterance = buildSpeechUtterance(text, lang);
|
|
speechTimer = setTimeout(function() {
|
|
window.speechSynthesis.speak(utterance);
|
|
if (window.speechSynthesis.paused) window.speechSynthesis.resume();
|
|
}, 60);
|
|
}
|
|
|
|
function speakText(text, lang) {
|
|
if (!text) return;
|
|
if (piperFallbackTimer) clearTimeout(piperFallbackTimer);
|
|
if (!window.speechSynthesis || !window.SpeechSynthesisUtterance) {
|
|
playPiperAudio(text, lang);
|
|
return;
|
|
}
|
|
refreshSpeechVoices();
|
|
doSpeak(text, lang);
|
|
window.speechSynthesis.getVoices();
|
|
if (!speechVoicesLoaded) {
|
|
if (piperFallbackTimer) clearTimeout(piperFallbackTimer);
|
|
piperFallbackTimer = setTimeout(function() {
|
|
refreshSpeechVoices();
|
|
if (!speechVoicesLoaded) playPiperAudio(text, lang);
|
|
}, 900);
|
|
}
|
|
}
|
|
|
|
function playPiperAudio(text, lang) {
|
|
if (!text) return;
|
|
fetch('/api/tts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: text, lang: lang || 'es' })
|
|
}).then(function(res) {
|
|
if (!res.ok) throw new Error('tts failed');
|
|
return res.blob();
|
|
}).then(function(blob) {
|
|
var url = URL.createObjectURL(blob);
|
|
var audio = new Audio(url);
|
|
audio.onended = function() { URL.revokeObjectURL(url); };
|
|
audio.play();
|
|
}).catch(function() {});
|
|
}
|
|
|
|
function initSpeech() {
|
|
if (!window.speechSynthesis) return;
|
|
refreshSpeechVoices();
|
|
window.speechSynthesis.onvoiceschanged = function() {
|
|
refreshSpeechVoices();
|
|
};
|
|
}
|
|
|
|
if (document.readyState === 'complete') initSpeech();
|
|
else document.addEventListener('DOMContentLoaded', initSpeech);
|
|
|
|
function warmSpeechVoices() {
|
|
if (!window.speechSynthesis) return;
|
|
window.speechSynthesis.getVoices();
|
|
refreshSpeechVoices();
|
|
}
|
|
|
|
document.addEventListener('touchstart', warmSpeechVoices, { once: true, passive: true });
|
|
document.addEventListener('pointerdown', warmSpeechVoices, { once: true, passive: true });
|