import { useCallback, useId, useRef, type Dispatch, type SetStateAction, } from 'react' import { Loader2, Pause, Play, SkipBack, Square, Volume2 } from 'lucide-react' import { useTtsPreferences } from '../context/TtsPreferencesContext' import { extractTtsSentences } from '../lib/ttsSentenceSplit' import { getTtsPitch, getTtsVoiceUri, TTS_SPEED_OPTIONS, } from '../lib/ttsSettings' /** Strip rough markdown / noise so speech sounds sane (aligned with phd-advisor MessageBubble idea). */ export function stripForSpeech(raw: string): string { if (!raw) return '' return raw .replace(/```[\s\S]*?```/g, ' ') .replace(/`([^`]+)`/g, '$1') .replace(/#{1,6}\s?/g, '') .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') .replace(/^\s*[-*+]\s/gm, '') .replace(/\n{2,}/g, '. ') .replace(/\n/g, ' ') .replace(/\s+/g, ' ') .trim() } /** Shared across all message rows so starting playback in one row invalidates others (AskJerry-style). */ let ttsGen = 0 function bumpTtsGen(): number { ttsGen += 1 return ttsGen } export type MessageTtsSharedState = { index: number | null phase: 'idle' | 'loading' | 'playing' | 'paused' } export function createInitialMessageTtsState(): MessageTtsSharedState { return { index: null, phase: 'idle' } } type Props = { text: string messageIndex: number tts: MessageTtsSharedState setTts: Dispatch> } export function MessageTtsControls({ text, messageIndex, tts, setTts, }: Props) { const labelId = useId() const { playbackSpeed, setPlaybackSpeed } = useTtsPreferences() const speedRef = useRef(playbackSpeed) speedRef.current = playbackSpeed const skipRef = useRef(false) const replayDeltaRef = useRef(null) const isMine = tts.index === messageIndex && tts.phase !== 'idle' const loading = isMine && tts.phase === 'loading' const playing = isMine && tts.phase === 'playing' const paused = isMine && tts.phase === 'paused' const speakSentence = useCallback((sentence: string, myGen: number): Promise => { return new Promise((resolve) => { const synth = window.speechSynthesis if (!synth || myGen !== ttsGen) { resolve() return } const u = new SpeechSynthesisUtterance(sentence) u.rate = speedRef.current u.pitch = getTtsPitch() const vUri = getTtsVoiceUri() if (vUri) { const v = synth.getVoices().find((x) => x.voiceURI === vUri) if (v) u.voice = v } let settled = false const finish = () => { if (settled) return settled = true resolve() } const pollId = window.setInterval(() => { if (myGen !== ttsGen) { synth.cancel() finish() } if (skipRef.current) { synth.cancel() finish() } }, 120) u.onend = () => { window.clearInterval(pollId) finish() } u.onerror = () => { window.clearInterval(pollId) finish() } synth.speak(u) }) }, []) const runPlayback = useCallback( async (myGen: number) => { const plain = stripForSpeech(text) const sentences = extractTtsSentences(plain, true) if (!sentences.length) { if (myGen === ttsGen) { setTts({ index: null, phase: 'idle' }) } return } let playedCount = 0 setTts({ index: messageIndex, phase: 'loading' }) await Promise.resolve() if (myGen !== ttsGen) return setTts({ index: messageIndex, phase: 'playing' }) while (playedCount < sentences.length) { if (myGen !== ttsGen) return await speakSentence(sentences[playedCount], myGen) if (myGen !== ttsGen) return if (skipRef.current) { skipRef.current = false const delta = replayDeltaRef.current ?? 0 replayDeltaRef.current = null playedCount = Math.max(0, playedCount + delta) continue } playedCount++ } if (myGen === ttsGen) { setTts({ index: null, phase: 'idle' }) } }, [messageIndex, setTts, speakSentence, text], ) const onPrimary = () => { if (paused) { window.speechSynthesis?.resume() setTts({ index: messageIndex, phase: 'playing' }) return } if (playing) { window.speechSynthesis?.pause() setTts({ index: messageIndex, phase: 'paused' }) return } if (loading) return window.speechSynthesis?.cancel() const myGen = bumpTtsGen() skipRef.current = false replayDeltaRef.current = null void runPlayback(myGen) } const onReplay = () => { replayDeltaRef.current = -1 skipRef.current = true window.speechSynthesis?.cancel() } const onStop = () => { bumpTtsGen() skipRef.current = false replayDeltaRef.current = null window.speechSynthesis?.cancel() setTts({ index: null, phase: 'idle' }) } const cycleSpeed = () => { const i = TTS_SPEED_OPTIONS.indexOf(playbackSpeed) setPlaybackSpeed(TTS_SPEED_OPTIONS[(i + 1) % TTS_SPEED_OPTIONS.length]) } const noText = !(text || '').trim() const primaryDisabled = loading || (noText && tts.index !== messageIndex) if (!window.speechSynthesis) { return null } return (
Text-to-speech controls
{(playing || paused) && ( )} {(playing || paused || (loading && isMine)) && ( )}
) }