Asma-F's picture
Deploy: French Coach app (MiniCPM4.1-8B ZeroGPU + React frontend)
4fd1234 verified
Raw
History Blame Contribute Delete
2.28 kB
// Shared browser text-to-speech helper (French voice).
//
// `getVoices()` is unreliable on first call (especially Chrome): the voice
// list often loads asynchronously, so an early call returns `[]`. We cache
// the list, refresh it on the `voiceschanged` event, and pick a French voice
// once it's available — `lang` is always set to 'fr-FR' regardless, so the
// browser still defaults sanely if no matching voice is found.
//
// Beginners need speech slowed down, and we prefer a female voice (common
// names across Chrome/macOS/Edge French voice packs).
const SPEECH_RATE = 0.85
const FEMALE_VOICE_NAMES = [
'amélie', 'amelie', 'audrey', 'marie', 'julie', 'hortense',
'céline', 'celine', 'virginie', 'chantal', 'aurélie', 'aurelie',
'google français', 'female',
]
let cachedVoices = []
function refreshVoices() {
if (!window.speechSynthesis) return
const voices = window.speechSynthesis.getVoices()
if (voices.length) cachedVoices = voices
}
if (window.speechSynthesis) {
refreshVoices()
window.speechSynthesis.addEventListener('voiceschanged', refreshVoices)
}
function isFemaleVoice(voice) {
const name = voice.name.toLowerCase()
return FEMALE_VOICE_NAMES.some((n) => name.includes(n))
}
function frenchVoice() {
refreshVoices()
const frFR = cachedVoices.filter((v) => v.lang === 'fr-FR')
const frAny = cachedVoices.filter((v) => v.lang && v.lang.startsWith('fr'))
return (
frFR.find(isFemaleVoice) ||
frAny.find(isFemaleVoice) ||
frFR[0] ||
frAny[0] ||
null
)
}
function makeUtterance(text) {
const u = new SpeechSynthesisUtterance(text)
u.lang = 'fr-FR'
u.rate = SPEECH_RATE
const voice = frenchVoice()
if (voice) u.voice = voice
return u
}
export function speak(text) {
if (!text || !window.speechSynthesis) return
window.speechSynthesis.cancel()
window.speechSynthesis.speak(makeUtterance(text))
}
// Speak several lines back-to-back (e.g. consecutive dialogue turns from the
// same speaker) without one cancelling the next.
export function speakAll(texts) {
const list = (texts || []).filter(Boolean)
if (!list.length || !window.speechSynthesis) return
window.speechSynthesis.cancel()
for (const text of list) {
window.speechSynthesis.speak(makeUtterance(text))
}
}