mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
feat: add voice selectors in elevenlabs (#88)
This commit is contained in:
parent
480e8a5f60
commit
45c5b7c304
22 changed files with 978 additions and 166 deletions
|
|
@ -6,10 +6,12 @@ import { useForm } from "react-hook-form";
|
|||
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.gen';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VoiceSelector } from "@/components/VoiceSelector";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
type ServiceSegment = "llm" | "tts" | "stt";
|
||||
|
|
@ -18,6 +20,7 @@ interface SchemaProperty {
|
|||
type?: string;
|
||||
default?: string | number | boolean;
|
||||
enum?: string[];
|
||||
examples?: string[];
|
||||
$ref?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
|
|
@ -40,6 +43,65 @@ const TAB_CONFIG: { key: ServiceSegment; label: string }[] = [
|
|||
{ key: "stt", label: "Transcriber" },
|
||||
];
|
||||
|
||||
// Display names for language codes (Deepgram + Sarvam)
|
||||
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
|
||||
// Deepgram languages
|
||||
"multi": "Multilingual (Auto-detect)",
|
||||
"en": "English",
|
||||
"en-US": "English (US)",
|
||||
"en-GB": "English (UK)",
|
||||
"en-AU": "English (Australia)",
|
||||
"en-IN": "English (India)",
|
||||
"es": "Spanish",
|
||||
"es-419": "Spanish (Latin America)",
|
||||
"fr": "French",
|
||||
"fr-CA": "French (Canada)",
|
||||
"de": "German",
|
||||
"it": "Italian",
|
||||
"pt": "Portuguese",
|
||||
"pt-BR": "Portuguese (Brazil)",
|
||||
"nl": "Dutch",
|
||||
"hi": "Hindi",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"zh-TW": "Chinese (Traditional)",
|
||||
"ru": "Russian",
|
||||
"pl": "Polish",
|
||||
"tr": "Turkish",
|
||||
"uk": "Ukrainian",
|
||||
"vi": "Vietnamese",
|
||||
"sv": "Swedish",
|
||||
"da": "Danish",
|
||||
"no": "Norwegian",
|
||||
"fi": "Finnish",
|
||||
"id": "Indonesian",
|
||||
"th": "Thai",
|
||||
// Sarvam Indian languages
|
||||
"bn-IN": "Bengali",
|
||||
"gu-IN": "Gujarati",
|
||||
"hi-IN": "Hindi",
|
||||
"kn-IN": "Kannada",
|
||||
"ml-IN": "Malayalam",
|
||||
"mr-IN": "Marathi",
|
||||
"od-IN": "Odia",
|
||||
"pa-IN": "Punjabi",
|
||||
"ta-IN": "Tamil",
|
||||
"te-IN": "Telugu",
|
||||
"as-IN": "Assamese",
|
||||
};
|
||||
|
||||
// Display names for Sarvam voices
|
||||
const VOICE_DISPLAY_NAMES: Record<string, string> = {
|
||||
"anushka": "Anushka (Female)",
|
||||
"manisha": "Manisha (Female)",
|
||||
"vidya": "Vidya (Female)",
|
||||
"arya": "Arya (Female)",
|
||||
"abhilash": "Abhilash (Male)",
|
||||
"karun": "Karun (Male)",
|
||||
"hitesh": "Hitesh (Male)",
|
||||
};
|
||||
|
||||
export default function ServiceConfiguration() {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
|
@ -54,6 +116,8 @@ export default function ServiceConfiguration() {
|
|||
tts: "",
|
||||
stt: ""
|
||||
});
|
||||
const [isManualModelInput, setIsManualModelInput] = useState(false);
|
||||
const [hasCheckedManualMode, setHasCheckedManualMode] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
|
|
@ -119,6 +183,29 @@ export default function ServiceConfiguration() {
|
|||
fetchConfigurations();
|
||||
}, [reset, userConfig]);
|
||||
|
||||
// Check if the saved LLM model is not in the suggested options (custom model)
|
||||
useEffect(() => {
|
||||
if (hasCheckedManualMode) return;
|
||||
|
||||
const currentProvider = serviceProviders.llm;
|
||||
const providerSchema = schemas?.llm?.[currentProvider];
|
||||
if (!providerSchema) return;
|
||||
|
||||
const modelSchema = providerSchema.properties.model;
|
||||
const actualModelSchema = modelSchema?.$ref && providerSchema.$defs
|
||||
? providerSchema.$defs[modelSchema.$ref.split('/').pop() || '']
|
||||
: modelSchema;
|
||||
|
||||
if (actualModelSchema?.examples && userConfig?.llm?.model) {
|
||||
const savedModel = userConfig.llm.model as string;
|
||||
const isInOptions = actualModelSchema.examples.includes(savedModel);
|
||||
if (!isInOptions) {
|
||||
setIsManualModelInput(true);
|
||||
}
|
||||
setHasCheckedManualMode(true);
|
||||
}
|
||||
}, [schemas, serviceProviders.llm, userConfig?.llm?.model, hasCheckedManualMode]);
|
||||
|
||||
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
|
||||
if (!providerName) {
|
||||
return;
|
||||
|
|
@ -147,6 +234,11 @@ export default function ServiceConfiguration() {
|
|||
preservedValues[`${service}_provider`] = providerName;
|
||||
reset(preservedValues);
|
||||
setServiceProviders(prev => ({ ...prev, [service]: providerName }));
|
||||
|
||||
// Reset manual model input when LLM provider changes
|
||||
if (service === "llm") {
|
||||
setIsManualModelInput(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -266,7 +358,7 @@ export default function ServiceConfiguration() {
|
|||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
type="text"
|
||||
placeholder="Enter API key"
|
||||
{...register(`${service}_api_key`, {
|
||||
required: providerSchema.required?.includes("api_key"),
|
||||
|
|
@ -291,7 +383,113 @@ export default function ServiceConfiguration() {
|
|||
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
|
||||
: schema;
|
||||
|
||||
// Use VoiceSelector for voice field in TTS service (except Sarvam which uses enum)
|
||||
if (service === "tts" && field === "voice") {
|
||||
const currentProvider = serviceProviders.tts;
|
||||
// Sarvam uses enum-based voice selection, not VoiceSelector
|
||||
if (currentProvider !== "sarvam" && !actualSchema?.enum) {
|
||||
return (
|
||||
<VoiceSelector
|
||||
provider={currentProvider}
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
onChange={(voiceId) => {
|
||||
setValue(`${service}_${field}`, voiceId, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle LLM model field with manual input toggle (uses examples from schema)
|
||||
if (service === "llm" && field === "model" && actualSchema?.examples) {
|
||||
const currentValue = watch(`${service}_${field}`) as string || "";
|
||||
const modelOptions = actualSchema.examples;
|
||||
|
||||
if (isManualModelInput) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter model name"
|
||||
value={currentValue}
|
||||
onChange={(e) => {
|
||||
setValue(`${service}_${field}`, e.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="manual-model-input"
|
||||
checked={isManualModelInput}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsManualModelInput(checked as boolean);
|
||||
if (!checked && modelOptions.length > 0) {
|
||||
// Reset to first option when switching back
|
||||
setValue(`${service}_${field}`, modelOptions[0], { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="manual-model-input"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Add Model Manually
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setValue(`${service}_${field}`, value, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="manual-model-input-dropdown"
|
||||
checked={isManualModelInput}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsManualModelInput(checked as boolean);
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="manual-model-input-dropdown"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Add Model Manually
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (actualSchema?.enum) {
|
||||
// Use friendly display names for language and voice fields
|
||||
const getDisplayName = (value: string) => {
|
||||
if (field === "language") {
|
||||
return LANGUAGE_DISPLAY_NAMES[value] || value;
|
||||
}
|
||||
if (field === "voice") {
|
||||
return VOICE_DISPLAY_NAMES[value] || value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
|
|
@ -308,7 +506,7 @@ export default function ServiceConfiguration() {
|
|||
<SelectContent>
|
||||
{actualSchema.enum.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
{getDisplayName(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
384
ui/src/components/VoiceSelector.tsx
Normal file
384
ui/src/components/VoiceSelector.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
"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 { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Providers that have MPS voice endpoints
|
||||
type TTSProviderWithVoices = "elevenlabs" | "deepgram" | "sarvam" | "cartesia" | "dograh";
|
||||
const MPS_VOICE_PROVIDERS: TTSProviderWithVoices[] = ["elevenlabs", "deepgram", "sarvam", "cartesia", "dograh"];
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
provider: string;
|
||||
value: string;
|
||||
onChange: (voiceId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
provider,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}) => {
|
||||
const { accessToken } = useUserConfig();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
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",
|
||||
};
|
||||
return providerMap[providerName.toLowerCase()] || null;
|
||||
}, []);
|
||||
|
||||
const fetchVoices = useCallback(async () => {
|
||||
const providerKey = getProviderKey(provider);
|
||||
if (!providerKey || !accessToken) {
|
||||
setVoices([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({
|
||||
path: { provider: providerKey },
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
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, getProviderKey, accessToken]);
|
||||
|
||||
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 (
|
||||
<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) {
|
||||
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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{voices.length} voices available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue