Spaces:
Sleeping
Sleeping
Download frontend/src/components/MessageTtsControls.tsx from BrainForge/AdvisoryBuilderWorkshop: direct link, hf CLI and curl.
- Browser
- Download file 7.94 kB
-
https://huggingface.co/spaces/BrainForge/AdvisoryBuilderWorkshop/resolve/main/frontend/src/components/MessageTtsControls.tsx
- Command line
-
hf download hf://spaces/BrainForge/AdvisoryBuilderWorkshop/frontend/src/components/MessageTtsControls.tsx
-
curl -L -o MessageTtsControls.tsx https://huggingface.co/spaces/BrainForge/AdvisoryBuilderWorkshop/resolve/main/frontend/src/components/MessageTtsControls.tsx
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> | |
| ) | |
| } | |