'use client'; import { useState, useCallback } from 'react'; import { Volume2, Play, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import { getTTSVoices, isQwenCloneVoice, resolveTTSModelForVoice } from '@/lib/audio/constants'; import { useTTSPreview } from '@/lib/audio/use-tts-preview'; import { getVoxCPMProviderOptions, getVoxCPMVoiceOptions, useAllVoiceProfiles, } from '@/lib/audio/voxcpm-voices'; import { VOXCPM_AUTO_VOICE_ID, normalizeVoxCPMBackend, voxCPMBackendSupportsReferenceAudio, } from '@/lib/audio/voxcpm'; /** Extract the English name from voice name format "ChineseName (English)" */ function getVoiceDisplayName( id: string, name: string, lang: string, t: (key: string) => string, ): string { if (id === VOXCPM_AUTO_VOICE_ID) { return t('settings.voxcpmAutoVoice'); } if (lang === 'en-US') { const match = name.match(/\(([^)]+)\)/); return match ? match[1] : name; } return name; } export function TtsConfigPopover() { const { t, locale } = useI18n(); const [open, setOpen] = useState(false); const { previewing, startPreview, stopPreview } = useTTSPreview(); const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled); const ttsProviderId = useSettingsStore((s) => s.ttsProviderId); const ttsVoice = useSettingsStore((s) => s.ttsVoice); const ttsSpeed = useSettingsStore((s) => s.ttsSpeed); const setTTSSpeed = useSettingsStore((s) => s.setTTSSpeed); const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig); const setTTSVoice = useSettingsStore((s) => s.setTTSVoice); const { profiles: voiceProfiles } = useAllVoiceProfiles(); const voxcpmProfiles = voiceProfiles.filter((profile) => profile.providerId === 'voxcpm-tts'); const qwenProfiles = voiceProfiles.filter((profile) => profile.providerId === 'qwen-tts'); const voxcpmBackend = normalizeVoxCPMBackend( ttsProvidersConfig['voxcpm-tts']?.providerOptions?.backend, ); const voices = ttsProviderId === 'voxcpm-tts' ? getVoxCPMVoiceOptions(voxcpmProfiles, { supportsClone: voxCPMBackendSupportsReferenceAudio(voxcpmBackend), }) : ttsProviderId === 'qwen-tts' ? [ ...getTTSVoices(ttsProviderId), ...qwenProfiles.map((profile) => ({ id: profile.id, name: profile.name, language: 'auto', gender: 'neutral' as const, })), ] : getTTSVoices(ttsProviderId); const localizedVoices = voices.map((voice) => ({ ...voice, displayName: getVoiceDisplayName(voice.id, voice.name, locale, t), })); const pillCls = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border'; const canPreview = ttsProviderId !== 'voxcpm-tts' || ttsVoice !== VOXCPM_AUTO_VOICE_ID; const usesQwenClone = ttsProviderId === 'qwen-tts' && isQwenCloneVoice(ttsVoice); const handleVoiceChange = (voiceId: string) => { setTTSVoice(voiceId); }; const handlePreview = useCallback(async () => { if (previewing) { stopPreview(); return; } if (!canPreview) { toast.info(t('settings.voxcpmAutoVoiceNoPreview')); return; } try { const providerConfig = ttsProvidersConfig[ttsProviderId]; const providerOptions = ttsProviderId === 'voxcpm-tts' ? { ...(providerConfig?.providerOptions || {}), ...(await getVoxCPMProviderOptions(ttsVoice, { role: 'teacher', locale })), } : undefined; await startPreview({ text: t('settings.ttsTestTextDefault'), providerId: ttsProviderId, modelId: resolveTTSModelForVoice(ttsProviderId, ttsVoice, providerConfig?.modelId), voice: ttsVoice, speed: ttsSpeed, apiKey: providerConfig?.apiKey, // Managed providers resolve their base URL server-side; only send the // client's own base URL (custom providers). baseUrl: providerConfig?.baseUrl || providerConfig?.customDefaultBaseUrl, providerOptions, }); } catch (error) { const message = error instanceof Error && error.message ? error.message : t('settings.ttsTestFailed'); toast.error(message); } }, [ previewing, canPreview, startPreview, stopPreview, t, locale, ttsProviderId, ttsProvidersConfig, ttsSpeed, ttsVoice, ]); const handleOpenChange = useCallback( (nextOpen: boolean) => { if (!nextOpen) { stopPreview(); } setOpen(nextOpen); }, [stopPreview], ); return ( {t('toolbar.ttsHint')} {/* Header with toggle */}
{t('toolbar.ttsTitle')}
{/* Config body */} {ttsEnabled && (
{/* Voice + Preview row */}
{t('settings.ttsSpeed')} {usesQwenClone ? '1×' : `${ttsSpeed.toFixed(2)}×`}
setTTSSpeed(Number(event.target.value))} className="w-full disabled:cursor-not-allowed disabled:opacity-50" />
{usesQwenClone && (

{t('settings.qwenCloneSpeedHint')}

)}
)}
); }