| 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'
|
|
|
|
|
| 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()
|
| }
|
|
|
|
|
| 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<SetStateAction<MessageTtsSharedState>>
|
| }
|
|
|
| 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<number | null>(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<void> => {
|
| 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 (
|
| <div className="message-tts-bar" role="group" aria-labelledby={labelId}>
|
| <span id={labelId} className="sr-only">
|
| Text-to-speech controls
|
| </span>
|
| <div className="message-tts-buttons">
|
| <button
|
| type="button"
|
| className={`message-tts-btn ${playing || paused ? 'message-tts-btn-active' : ''}`}
|
| onClick={onPrimary}
|
| disabled={primaryDisabled}
|
| title={
|
| paused
|
| ? 'Resume'
|
| : playing
|
| ? 'Pause'
|
| : loading
|
| ? 'Loading speech…'
|
| : 'Read aloud'
|
| }
|
| aria-label={
|
| paused
|
| ? 'Resume'
|
| : playing
|
| ? 'Pause'
|
| : loading
|
| ? 'Loading speech'
|
| : 'Read aloud'
|
| }
|
| >
|
| {paused ? (
|
| <Play size={14} aria-hidden />
|
| ) : loading ? (
|
| <Loader2 size={14} className="message-tts-loading-spin" aria-hidden />
|
| ) : playing ? (
|
| <Pause size={14} aria-hidden />
|
| ) : (
|
| <Volume2 size={14} aria-hidden />
|
| )}
|
| </button>
|
| {(playing || paused) && (
|
| <button
|
| type="button"
|
| className="message-tts-btn"
|
| onClick={onReplay}
|
| title="Skip back"
|
| aria-label="Skip back"
|
| >
|
| <SkipBack size={14} aria-hidden />
|
| </button>
|
| )}
|
| <button
|
| type="button"
|
| className="message-tts-btn message-tts-speed-btn"
|
| onClick={cycleSpeed}
|
| title="Playback speed"
|
| aria-label={`Playback speed: ${playbackSpeed}x`}
|
| >
|
| <span className="message-tts-speed-label">{playbackSpeed}x</span>
|
| </button>
|
| {(playing || paused || (loading && isMine)) && (
|
| <button
|
| type="button"
|
| className="message-tts-btn"
|
| onClick={onStop}
|
| title="Stop reading"
|
| aria-label="Stop reading"
|
| >
|
| <Square size={14} aria-hidden />
|
| </button>
|
| )}
|
| </div>
|
| </div>
|
| )
|
| }
|
|
|