AdvisoryBuilderWorkshop / frontend /src /components /MessageTtsControls.tsx
NeonClary's picture
Deploy tutorial feature and recent fixes
d8808e7 verified
Raw
History Blame Contribute Delete
7.94 kB
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<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>
)
}