dograh/ui/src/components/VoiceSelector.tsx
2026-06-23 18:33:04 +05:30

465 lines
21 KiB
TypeScript

"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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"];
const ALL_FILTER_VALUE = "__all__";
interface VoiceSelectorProps {
provider: string;
value: string;
onChange: (voiceId: string) => void;
model?: string;
language?: string;
showFilters?: boolean;
allowManualInput?: boolean;
className?: string;
}
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
provider,
value,
onChange,
model,
language,
showFilters = false,
allowManualInput = true,
className,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [genderFilter, setGenderFilter] = useState(ALL_FILTER_VALUE);
const [languageFilter, setLanguageFilter] = useState(ALL_FILTER_VALUE);
const [accentFilter, setAccentFilter] = useState(ALL_FILTER_VALUE);
const [isManualInput, setIsManualInput] = useState(false);
const [manualVoiceId, setManualVoiceId] = useState(value || "");
const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(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<string, TTSProviderWithVoices> = {
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 && allowManualInput) {
// If the value doesn't exist in the list, switch to manual input mode
setIsManualInput(true);
setManualVoiceId(value);
} else if (voiceExists) {
setIsManualInput(false);
}
}
}, [value, voices, allowManualInput]);
// 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();
const matchesSearch = (
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)
);
if (!matchesSearch) return false;
if (genderFilter !== ALL_FILTER_VALUE && (voice.gender || "").toLowerCase() !== genderFilter) return false;
if (languageFilter !== ALL_FILTER_VALUE && (voice.language || "").toLowerCase() !== languageFilter) return false;
if (accentFilter !== ALL_FILTER_VALUE && (voice.accent || "").toLowerCase() !== accentFilter) return false;
return true;
});
const genderOptions = Array.from(
new Set(voices.map((voice) => voice.gender?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const languageOptions = Array.from(
new Set(voices.map((voice) => voice.language?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const accentOptions = Array.from(
new Set(voices.map((voice) => voice.accent?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const handleSelectVoice = (voiceId: string) => {
onChange(voiceId);
setIsOpen(false);
setSearchTerm("");
};
const handleManualInputToggle = (checked: boolean) => {
if (!allowManualInput) return;
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 (
<div className={cn("space-y-2", className)}>
<Input
type="text"
placeholder="Enter voice ID"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
if (isManualInput && allowManualInput) {
return (
<div className={cn("space-y-2", className)}>
<Input
type="text"
placeholder="Enter voice ID"
value={manualVoiceId}
onChange={(e) => handleManualVoiceIdChange(e.target.value)}
/>
<div className="flex items-center space-x-2">
<Checkbox
id="manual-voice-input"
checked={isManualInput}
onCheckedChange={(checked) => handleManualInputToggle(checked as boolean)}
/>
<Label
htmlFor="manual-voice-input"
className="text-sm font-normal cursor-pointer"
>
Add Voice ID Manually
</Label>
</div>
</div>
);
}
return (
<div className={cn("space-y-2", className)}>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className={cn(
"w-full justify-between",
!value && "text-muted-foreground"
)}
disabled={isLoading}
>
<span className="truncate">
{isLoading ? "Loading voices..." : getSelectedVoiceName()}
</span>
{isLoading ? (
<Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin" />
) : (
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<div className="p-2 space-y-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search voices..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
{showFilters && (
<div className="grid gap-2 sm:grid-cols-3">
<Select value={genderFilter} onValueChange={setGenderFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All genders</SelectItem>
{genderOptions.map((gender) => (
<SelectItem key={gender} value={gender} className="capitalize">
{gender}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={languageFilter} onValueChange={setLanguageFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All languages</SelectItem>
{languageOptions.map((voiceLanguage) => (
<SelectItem key={voiceLanguage} value={voiceLanguage} className="uppercase">
{voiceLanguage}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={accentFilter} onValueChange={setAccentFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Accent" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All accents</SelectItem>
{accentOptions.map((accent) => (
<SelectItem key={accent} value={accent} className="uppercase">
{accent}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="max-h-[300px] overflow-auto space-y-1">
{error ? (
<p className="text-sm text-red-500 text-center py-4">
{error}
</p>
) : isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredVoices.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No voices found
</p>
) : (
filteredVoices.map((voice) => (
<div
key={voice.voice_id}
className={cn(
"flex items-start space-x-3 p-2 hover:bg-accent rounded-sm cursor-pointer",
value === voice.voice_id && "bg-accent"
)}
onClick={() => handleSelectVoice(voice.voice_id)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{voice.name}
</p>
{voice.gender && (
<span className="text-xs text-muted-foreground capitalize">
{voice.gender}
</span>
)}
</div>
{voice.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{voice.description}
</p>
)}
<div className="flex items-center gap-2 mt-1">
{voice.accent && (
<span className="text-xs bg-secondary px-1.5 py-0.5 rounded capitalize">
{voice.accent}
</span>
)}
{voice.language && (
<span className="text-xs bg-secondary px-1.5 py-0.5 rounded uppercase">
{voice.language}
</span>
)}
</div>
</div>
{voice.preview_url && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 shrink-0"
onClick={(e) => {
e.stopPropagation();
playPreview(voice.preview_url!, voice.voice_id);
}}
>
<Volume2
className={cn(
"h-4 w-4",
playingPreview === voice.voice_id &&
"text-primary animate-pulse"
)}
/>
</Button>
)}
</div>
))
)}
</div>
<div className="pt-2 border-t flex items-center justify-between">
{allowManualInput ? (
<div className="flex items-center space-x-2">
<Checkbox
id="manual-voice-input-popup"
checked={isManualInput}
onCheckedChange={(checked) => {
handleManualInputToggle(checked as boolean);
if (checked) {
setIsOpen(false);
}
}}
/>
<Label
htmlFor="manual-voice-input-popup"
className="text-sm font-normal cursor-pointer"
>
Add Voice ID Manually
</Label>
</div>
) : (
<span />
)}
<p className="text-xs text-muted-foreground">
{filteredVoices.length} of {voices.length} voices
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
};