diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index d8d5bea0..d68a0957 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -715,7 +715,7 @@ const AssistantMessageBody: React.FC> = ({ if (isTTSPlaying) { return 'Stop speaking'; } - const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : 'Say'; + const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : voiceProvider === 'openai-compatible' ? 'Custom' : 'Say'; return `Read aloud (${providerLabel} voice)`; }, [isTTSPlaying, voiceProvider]); diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index 698ae1d2..81816e2c 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -18,7 +18,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { browserVoiceService } from '@/lib/voice/browserVoiceService'; import { audioStreamService } from '@/lib/voice/audioStreamService'; import { cn } from '@/lib/utils'; - const LANGUAGE_OPTIONS = [ { value: 'en-US', label: 'English' }, { value: 'es-ES', label: 'Español' }, @@ -71,6 +70,12 @@ export const VoiceSettings: React.FC = () => { const setOpenaiVoice = useConfigStore((state) => state.setOpenaiVoice); const openaiApiKey = useConfigStore((state) => state.openaiApiKey); const setOpenaiApiKey = useConfigStore((state) => state.setOpenaiApiKey); + const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl); + const setOpenaiCompatibleUrl = useConfigStore((state) => state.setOpenaiCompatibleUrl); + const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice); + const setOpenaiCompatibleVoice = useConfigStore((state) => state.setOpenaiCompatibleVoice); + const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel); + const setOpenaiCompatibleTtsModel = useConfigStore((state) => state.setOpenaiCompatibleTtsModel); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); // STT settings const sttProvider = useConfigStore((state) => state.sttProvider); @@ -106,6 +111,9 @@ export const VoiceSettings: React.FC = () => { const [isOpenAIPreviewPlaying, setIsOpenAIPreviewPlaying] = useState(false); const [openaiPreviewAudio, setOpenaiPreviewAudio] = useState(null); + const [isCompatiblePreviewPlaying, setIsCompatiblePreviewPlaying] = useState(false); + const [compatiblePreviewAudio, setCompatiblePreviewAudio] = useState(null); + const [browserVoices, setBrowserVoices] = useState([]); const [isBrowserPreviewPlaying, setIsBrowserPreviewPlaying] = useState(false); @@ -182,7 +190,7 @@ export const VoiceSettings: React.FC = () => { }, [isBrowserPreviewPlaying]); useEffect(() => { - if (!voiceModeEnabled || voiceProvider !== 'openai') { + if (!voiceModeEnabled || (voiceProvider !== 'openai' && voiceProvider !== 'openai-compatible')) { setIsOpenAIAvailable(openaiApiKey.trim().length > 0); return; } @@ -339,6 +347,67 @@ export const VoiceSettings: React.FC = () => { }; }, [openaiPreviewAudio]); + const previewCompatibleVoice = useCallback(async () => { + if (compatiblePreviewAudio) { + compatiblePreviewAudio.pause(); + compatiblePreviewAudio.currentTime = 0; + setCompatiblePreviewAudio(null); + setIsCompatiblePreviewPlaying(false); + return; + } + + if (!openaiCompatibleUrl.trim()) return; + + setIsCompatiblePreviewPlaying(true); + try { + const response = await fetch('/api/tts/speak', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `Hello! This is a preview of the custom TTS server.`, + voice: openaiCompatibleVoice, + model: openaiCompatibleTtsModel || undefined, + speed: speechRate, + baseURL: openaiCompatibleUrl, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + + audio.onended = () => { + URL.revokeObjectURL(url); + setCompatiblePreviewAudio(null); + setIsCompatiblePreviewPlaying(false); + }; + + audio.onerror = () => { + URL.revokeObjectURL(url); + setCompatiblePreviewAudio(null); + setIsCompatiblePreviewPlaying(false); + }; + + setCompatiblePreviewAudio(audio); + await audio.play(); + } catch { + setIsCompatiblePreviewPlaying(false); + } + }, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio]); + + useEffect(() => { + return () => { + if (compatiblePreviewAudio) { + compatiblePreviewAudio.pause(); + } + }; + }, [compatiblePreviewAudio]); + const sliderClass = "flex-1 min-w-0 h-1.5 bg-[var(--interactive-border)] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:border-0 disabled:opacity-50"; return ( @@ -380,6 +449,7 @@ export const VoiceSettings: React.FC = () => {
  • Browser: Free, offline, limited mobile support.
  • OpenAI: High quality, mobile ready, needs API key.
  • +
  • Custom: OpenAI-compatible server (e.g. Kokoro).
  • Say: macOS native. Fast, free, offline.
@@ -412,6 +482,19 @@ export const VoiceSettings: React.FC = () => { > OpenAI + {isSayAvailable && ( + )} + + +
+ Model +
+ setOpenaiCompatibleTtsModel(e.target.value)} + placeholder="speaches-ai/Kokoro-82M-v1.0-ONNX" + className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70" + /> +
+
+
+ Voice + + Voice identifier supported by the server + +
+
+ setOpenaiCompatibleVoice(e.target.value)} + placeholder="af_sky" + className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70" + /> +
+ +
+
+ + )} + {/* Voice Selection */}
Voice @@ -484,6 +631,10 @@ export const VoiceSettings: React.FC = () => { )} + {voiceProvider === 'openai-compatible' && ( + Configured above + )} + {voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && ( <>