From a7879d60566ca9fb328ea1625bef6cfcfdde1614 Mon Sep 17 00:00:00 2001 From: Drew Peifer Date: Sat, 14 Feb 2026 03:28:32 -0500 Subject: [PATCH] added text-to-speech options for briefing and associated env vars --- claudash/.env.example | 3 + claudash/src/components/ChatInterface.jsx | 19 +- .../src/components/widgets/BriefingWidget.jsx | 312 +++++++++++++++++- .../src/components/widgets/GenericWidget.jsx | 7 +- 4 files changed, 337 insertions(+), 4 deletions(-) diff --git a/claudash/.env.example b/claudash/.env.example index ba30936..621da7f 100644 --- a/claudash/.env.example +++ b/claudash/.env.example @@ -36,3 +36,6 @@ REACT_APP_DEBUG_MODE=false # Optional: Date format for charts (MM/DD/YYYY, DD/MM/YYYY, MM/DD, DD/MM) REACT_APP_CHART_DATE_FORMAT=MM/DD/YYYY + +# Optional: Default text-to-speech voice (must match exact voice name from browser) +REACT_APP_TEXT_VOICE=Google UK English Female diff --git a/claudash/src/components/ChatInterface.jsx b/claudash/src/components/ChatInterface.jsx index ce5dc6f..932d931 100644 --- a/claudash/src/components/ChatInterface.jsx +++ b/claudash/src/components/ChatInterface.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { Box, TextField, IconButton, Paper } from '@mui/material'; +import { Box, TextField, IconButton, Paper, CircularProgress } from '@mui/material'; import { Send, ExpandLess, ExpandMore } from '@mui/icons-material'; import { sendChatMessage } from '../services/claudeService'; @@ -90,6 +90,23 @@ function ChatInterface() { ))} + {/* Loading indicator when Claude is thinking */} + {loading && ( + + + + Claude is thinking... + + + )}
)} diff --git a/claudash/src/components/widgets/BriefingWidget.jsx b/claudash/src/components/widgets/BriefingWidget.jsx index bd661db..a70cc50 100644 --- a/claudash/src/components/widgets/BriefingWidget.jsx +++ b/claudash/src/components/widgets/BriefingWidget.jsx @@ -1,12 +1,322 @@ -import { Skeleton, Box } from '@mui/material'; +import { useState, useEffect, useRef } from 'react'; +import { Skeleton, Box, IconButton, Collapse, FormControlLabel, Switch, ToggleButtonGroup, ToggleButton, Select, MenuItem, FormControl, InputLabel, Typography } from '@mui/material'; +import { VolumeUp, PlayArrow, Pause, Settings, Stop } from '@mui/icons-material'; import GenericWidget from './GenericWidget'; function BriefingWidget({ briefing, sx }) { + const [showControls, setShowControls] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [autoPlay, setAutoPlay] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [voices, setVoices] = useState([]); + const [selectedVoice, setSelectedVoice] = useState(''); + const utteranceRef = useRef(null); + + // Load available voices + useEffect(() => { + const loadVoices = () => { + const availableVoices = window.speechSynthesis.getVoices(); + setVoices(availableVoices); + + // Set default voice if not already set + if (!selectedVoice && availableVoices.length > 0) { + // First priority: Environment variable + const envVoice = import.meta.env.REACT_APP_TEXT_VOICE; + if (envVoice && availableVoices.find(v => v.name === envVoice)) { + setSelectedVoice(envVoice); + } else { + // Second priority: Saved voice in localStorage + const savedVoice = localStorage.getItem('ttsSelectedVoice'); + if (savedVoice && availableVoices.find(v => v.name === savedVoice)) { + setSelectedVoice(savedVoice); + } else { + // Third priority: Try to find an English voice as default + const englishVoice = availableVoices.find(v => v.lang.startsWith('en')); + setSelectedVoice(englishVoice ? englishVoice.name : availableVoices[0].name); + } + } + } + }; + + loadVoices(); + // Some browsers load voices asynchronously + window.speechSynthesis.addEventListener('voiceschanged', loadVoices); + + return () => { + window.speechSynthesis.removeEventListener('voiceschanged', loadVoices); + }; + }, [selectedVoice]); + + // Load saved preferences + useEffect(() => { + const savedPrefs = localStorage.getItem('ttsPreferences'); + if (savedPrefs) { + const prefs = JSON.parse(savedPrefs); + setAutoPlay(prefs.autoPlay || false); + setPlaybackSpeed(prefs.speed || 1); + } + }, []); + + // Save preferences when they change + useEffect(() => { + localStorage.setItem('ttsPreferences', JSON.stringify({ + autoPlay, + speed: playbackSpeed + })); + if (selectedVoice) { + localStorage.setItem('ttsSelectedVoice', selectedVoice); + } + }, [autoPlay, playbackSpeed, selectedVoice]); + + // Auto-play on load if enabled and briefing is available + useEffect(() => { + if (autoPlay && briefing && !isPlaying) { + // Small delay to ensure the component is fully mounted + const timer = setTimeout(() => handlePlay(), 500); + return () => clearTimeout(timer); + } + }, [autoPlay, briefing]); + + const handlePlay = () => { + if (!briefing) return; + + // Stop any existing speech + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(briefing); + utterance.rate = playbackSpeed; + + // Set the selected voice + if (selectedVoice && voices.length > 0) { + const voice = voices.find(v => v.name === selectedVoice); + if (voice) { + utterance.voice = voice; + } + } + + utterance.onstart = () => { + setIsPlaying(true); + setIsPaused(false); + }; + + utterance.onend = () => { + setIsPlaying(false); + setIsPaused(false); + utteranceRef.current = null; + }; + + utterance.onerror = (event) => { + console.error('Speech synthesis error:', event); + setIsPlaying(false); + setIsPaused(false); + utteranceRef.current = null; + }; + + utteranceRef.current = utterance; + window.speechSynthesis.speak(utterance); + }; + + const handlePause = () => { + if (window.speechSynthesis.speaking && !isPaused) { + window.speechSynthesis.pause(); + setIsPaused(true); + } + }; + + const handleResume = () => { + if (isPaused) { + window.speechSynthesis.resume(); + setIsPaused(false); + } + }; + + const handleStop = () => { + window.speechSynthesis.cancel(); + setIsPlaying(false); + setIsPaused(false); + utteranceRef.current = null; + }; + + const handleSpeedChange = (event, newSpeed) => { + if (newSpeed !== null) { + setPlaybackSpeed(newSpeed); + // If currently playing, restart with new speed + if (isPlaying) { + handleStop(); + setTimeout(() => handlePlay(), 100); + } + } + }; + + const toggleControls = () => { + setShowControls(!showControls); + }; + + // Cleanup on unmount + useEffect(() => { + return () => { + window.speechSynthesis.cancel(); + }; + }, []); + return ( + {!showControls && !isPlaying && ( + + + + )} + {isPlaying && !showControls && ( + + {isPaused ? : } + + )} + + + + + ) + } > + {/* Audio Controls Panel */} + {briefing && ( + + + {/* Play/Pause Button */} + + {!isPlaying ? ( + + + + ) : ( + <> + + {isPaused ? : } + + + + + + )} + + + {/* Voice Selection */} + + + Voice + + + + + {/* Speed Control */} + + Speed: + + + 1x + + + 1.5x + + + 2x + + + + + {/* Auto-play Toggle */} + setAutoPlay(e.target.checked)} + size="small" + /> + } + label="Auto-play" + sx={{ fontSize: '0.875rem' }} + /> + + + )} + + {/* Briefing Content */} {briefing ? (

{briefing}

diff --git a/claudash/src/components/widgets/GenericWidget.jsx b/claudash/src/components/widgets/GenericWidget.jsx index 389e7f4..215d28d 100644 --- a/claudash/src/components/widgets/GenericWidget.jsx +++ b/claudash/src/components/widgets/GenericWidget.jsx @@ -1,6 +1,6 @@ import { Paper, Box } from '@mui/material'; -function GenericWidget({ title, data, children, sx, ...props }) { +function GenericWidget({ title, data, children, sx, headerAction, ...props }) { return ( -

{title}

+ +

{title}

+ {headerAction} +
{children ||

{JSON.stringify(data)}

}