// Plays an audio lesson's narration script with the device's own voice (Web Speech API). // The engine has no audio output for agents yet, so there is no MP3: the script is the deliverable, // read aloud here and downloadable as text for recording or a TTS tool. import { useEffect, useMemo, useRef, useState } from 'react' import { t } from '../i18n' import { Icon } from './Icon' // Long utterances stop silently in some browsers (Chrome after ~15 s), so the script is read // one sentence at a time. export function sentencesOf(text: string): string[] { return text .replace(/^\s*(?:#{1,6}|[-*•>]|\d+[.)])\s+/gm, '') // headings, bullets, numbered items, quotes .replace(/[*_`]+/g, '') // Chinese ends sentences with 。!? and no space after them. .split(/(?<=[.!?…])\s+|(?<=[。!?])|\n+/) .map(s => s.replace(/\s+/g, ' ').trim()) .filter(s => /\p{L}/u.test(s)) } // The script's language decides the voice; the UI language may differ from the content's. const ID_WORDS = /\b(yang|dan|dengan|untuk|adalah|ini|itu|kita|anda|tidak|dalam|akan|bisa|dari)\b/gi const EN_WORDS = /\b(the|and|with|for|is|this|that|we|you|not|in|will|can|from)\b/gi // Chinese has no spaces, so it is told apart by its characters before the word counts. export function scriptLang(text: string): 'id' | 'en' | 'zh' { if ((text.match(/\p{Script=Han}/gu)?.length ?? 0) > (text.match(/[a-z]/gi)?.length ?? 0)) return 'zh' return (text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en' } const SPEECH_LANG = { id: 'id-ID', en: 'en-US', zh: 'zh-CN' } const supported = typeof window !== 'undefined' && 'speechSynthesis' in window const BARS = [16, 29, 45, 22, 37, 56, 31, 19, 43] export function AudioPlayer({ title, text }: { title: string; text: string }) { const sentences = useMemo(() => sentencesOf(text), [text]) const lang = useMemo(() => scriptLang(text), [text]) const [state, setState] = useState<'idle' | 'playing' | 'paused'>('idle') const [index, setIndex] = useState(0) const [rate, setRate] = useState(1) // The sentence to speak next; a ref so the utterance callbacks see the latest value. const at = useRef(0) const playing = useRef(false) // The utterance being spoken: cancel() also fires "end" on the one it stops, which must not advance. const current = useRef(null) const voice = () => speechSynthesis.getVoices().find(v => v.lang.toLowerCase().startsWith(lang)) ?? null function speak(i: number) { if (i >= sentences.length) { stop(); return } at.current = i setIndex(i) const u = new SpeechSynthesisUtterance(sentences[i]) u.lang = SPEECH_LANG[lang] u.voice = voice() u.rate = rate u.onend = () => { if (playing.current && current.current === u) speak(at.current + 1) } current.current = u speechSynthesis.speak(u) } function play() { if (state === 'paused') { speechSynthesis.resume(); setState('playing'); playing.current = true; return } current.current = null speechSynthesis.cancel() playing.current = true setState('playing') speak(at.current) } function pause() { speechSynthesis.pause(); playing.current = false; setState('paused') } function stop() { playing.current = false current.current = null speechSynthesis.cancel() at.current = 0 setIndex(0) setState('idle') } // Leaving the preview must not leave the device talking. useEffect(() => () => { playing.current = false; if (supported) speechSynthesis.cancel() }, []) // A new speed applies from the current sentence. useEffect(() => { if (!playing.current) return current.current = null speechSynthesis.cancel() speak(at.current) }, [rate]) const progress = sentences.length ? Math.round(((index + (state === 'idle' ? 0 : 1)) / sentences.length) * 100) : 0 return (

{title}

{supported ? t('audio.copy') : t('audio.unsupported')}

{supported && sentences.length > 0 && ( <>
{state === 'playing' ? : }
{state === 'idle' ? t('audio.ready', { n: sentences.length }) : t('audio.sentence', { n: index + 1, total: sentences.length })} {state !== 'idle' &&
{sentences[index]}
} )}
) }