The Instruction agent now confirms the output language before the plan is
ready: Bahasa Indonesia, English or 中文, the languages the engine writes
in. It can also be named in the instruction ("pakai bahasa Inggris"), is
read from short replies, and can be changed in the plan. Research alone
is not asked about.
The engine's content production has no language field, so the choice
goes into the agent's instructions; video schedules also set
video_options.language. ruleOf reads it back, so editing a topic keeps
it. The audio player recognises Chinese scripts and uses a zh-CN voice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
5.4 KiB
TypeScript
121 lines
5.4 KiB
TypeScript
// 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<SpeechSynthesisUtterance | null>(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 (
|
||
<div className="audio-preview">
|
||
<span className="audio-orb"><Icon name="audio" /></span>
|
||
<div className={`waveform${state === 'playing' ? ' playing' : ''}`} aria-hidden="true">
|
||
{Array.from({ length: 36 }, (_, i) => <i key={i} style={{ height: BARS[i % BARS.length], animationDelay: `${(i % 9) * 0.08}s` }} />)}
|
||
</div>
|
||
<h2>{title}</h2>
|
||
<p>{supported ? t('audio.copy') : t('audio.unsupported')}</p>
|
||
{supported && sentences.length > 0 && (
|
||
<>
|
||
<div className="head-actions">
|
||
{state === 'playing'
|
||
? <button className="btn primary" onClick={pause}>{t('audio.pause')}</button>
|
||
: <button className="btn primary" onClick={play}><Icon name="audio" />{state === 'paused' ? t('audio.resume') : t('audio.play')}</button>}
|
||
<button className="btn" disabled={state === 'idle'} onClick={stop}><Icon name="close" />{t('audio.stop')}</button>
|
||
<select aria-label={t('audio.speed')} value={rate} onChange={e => setRate(Number(e.target.value))} className="audio-rate">
|
||
{[0.85, 1, 1.15, 1.3].map(r => <option key={r} value={r}>{r}×</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="audio-progress"><i style={{ width: `${progress}%` }} /></div>
|
||
<small role="status">
|
||
{state === 'idle' ? t('audio.ready', { n: sentences.length }) : t('audio.sentence', { n: index + 1, total: sentences.length })}
|
||
</small>
|
||
{state !== 'idle' && <blockquote className="audio-now">{sentences[index]}</blockquote>}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|