"use client"; import { ChevronDown, Loader2, Search, Volume2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { getVoicesApiV1UserConfigurationsVoicesProviderGet } from "@/client/sdk.gen"; import { VoiceInfo } from "@/client/types.gen"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; // Providers that have MPS voice endpoints type TTSProviderWithVoices = "elevenlabs" | "deepgram" | "sarvam" | "cartesia" | "dograh" | "rime"; const MPS_VOICE_PROVIDERS: TTSProviderWithVoices[] = ["elevenlabs", "deepgram", "sarvam", "cartesia", "dograh", "rime"]; interface VoiceSelectorProps { provider: string; value: string; onChange: (voiceId: string) => void; model?: string; language?: string; className?: string; } export const VoiceSelector: React.FC = ({ provider, value, onChange, model, language, className, }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [isManualInput, setIsManualInput] = useState(false); const [manualVoiceId, setManualVoiceId] = useState(value || ""); const [voices, setVoices] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [playingPreview, setPlayingPreview] = useState(null); const [currentAudio, setCurrentAudio] = useState(null); // Check if provider has MPS voice endpoint const hasMPSVoiceEndpoint = useCallback((providerName: string): boolean => { return MPS_VOICE_PROVIDERS.includes(providerName.toLowerCase() as TTSProviderWithVoices); }, []); // Map provider names to API-compatible provider names const getProviderKey = useCallback((providerName: string): TTSProviderWithVoices | null => { const providerMap: Record = { elevenlabs: "elevenlabs", deepgram: "deepgram", sarvam: "sarvam", cartesia: "cartesia", dograh: "dograh", rime: "rime", }; return providerMap[providerName.toLowerCase()] || null; }, []); const fetchVoices = useCallback(async () => { const providerKey = getProviderKey(provider); if (!providerKey) { setVoices([]); return; } setIsLoading(true); setError(null); try { const query: { model?: string; language?: string } = {}; if (model) query.model = model; if (language) query.language = language; const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({ path: { provider: providerKey }, query: Object.keys(query).length > 0 ? query : undefined, }); if (response.data?.voices) { setVoices(response.data.voices); } } catch (err) { console.error("Failed to fetch voices:", err); setError("Failed to load voices"); setVoices([]); } finally { setIsLoading(false); } }, [provider, model, language, getProviderKey]); useEffect(() => { if (provider) { fetchVoices(); } }, [provider, fetchVoices]); // Check if the current value exists in the voices list useEffect(() => { if (value && voices.length > 0) { const voiceExists = voices.some((v) => v.voice_id === value); if (!voiceExists) { // If the value doesn't exist in the list, switch to manual input mode setIsManualInput(true); setManualVoiceId(value); } } }, [value, voices]); // Cleanup audio on unmount or when popover closes useEffect(() => { if (!isOpen && currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; setCurrentAudio(null); setPlayingPreview(null); } }, [isOpen, currentAudio]); // Cleanup on unmount useEffect(() => { return () => { if (currentAudio) { currentAudio.pause(); } }; }, [currentAudio]); const filteredVoices = voices.filter((voice) => { const searchLower = searchTerm.toLowerCase(); return ( voice.name.toLowerCase().includes(searchLower) || voice.voice_id.toLowerCase().includes(searchLower) || (voice.description?.toLowerCase() || "").includes(searchLower) || (voice.accent?.toLowerCase() || "").includes(searchLower) || (voice.gender?.toLowerCase() || "").includes(searchLower) || (voice.language?.toLowerCase() || "").includes(searchLower) ); }); const handleSelectVoice = (voiceId: string) => { onChange(voiceId); setIsOpen(false); setSearchTerm(""); }; const handleManualInputToggle = (checked: boolean) => { setIsManualInput(checked); if (checked) { setManualVoiceId(value || ""); } else { // When switching back to dropdown, try to find the current value in voices const existingVoice = voices.find((v) => v.voice_id === value); if (!existingVoice && voices.length > 0) { // If current value not in list, select the first voice onChange(voices[0].voice_id); } } }; const handleManualVoiceIdChange = (newValue: string) => { setManualVoiceId(newValue); onChange(newValue); }; const getSelectedVoiceName = () => { if (isManualInput && value) { return value; } const voice = voices.find((v) => v.voice_id === value); return voice?.name || value || "Select a voice"; }; const playPreview = (previewUrl: string, voiceId: string) => { // Stop current audio if playing if (currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; setCurrentAudio(null); } // If clicking the same voice that's playing, just stop it if (playingPreview === voiceId) { setPlayingPreview(null); return; } setPlayingPreview(voiceId); const audio = new Audio(previewUrl); setCurrentAudio(audio); audio.onended = () => { setPlayingPreview(null); setCurrentAudio(null); }; audio.onerror = () => { setPlayingPreview(null); setCurrentAudio(null); }; audio.play().catch(() => { setPlayingPreview(null); setCurrentAudio(null); }); }; // For providers without MPS voice endpoint, show simple input if (!hasMPSVoiceEndpoint(provider)) { return (
onChange(e.target.value)} />
); } if (isManualInput) { return (
handleManualVoiceIdChange(e.target.value)} />
handleManualInputToggle(checked as boolean)} />
); } return (
setSearchTerm(e.target.value)} className="pl-8" />
{error ? (

{error}

) : isLoading ? (
) : filteredVoices.length === 0 ? (

No voices found

) : ( filteredVoices.map((voice) => (
handleSelectVoice(voice.voice_id)} >

{voice.name}

{voice.gender && ( {voice.gender} )}
{voice.description && (

{voice.description}

)}
{voice.accent && ( {voice.accent} )} {voice.language && ( {voice.language} )}
{voice.preview_url && ( )}
)) )}
{ handleManualInputToggle(checked as boolean); if (checked) { setIsOpen(false); } }} />

{voices.length} voices available

); };