'use client';
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import {
TTS_PROVIDERS,
DEFAULT_TTS_VOICES,
isQwenCloneVoice,
QWEN_TTS_VOICE_CLONE_MODEL,
resolveTTSModelForVoice,
getManuallySelectableTTSModels,
} from '@/lib/audio/constants';
import type { TTSProviderId } from '@/lib/audio/types';
import {
Volume2,
Loader2,
CheckCircle2,
XCircle,
Eye,
EyeOff,
Plus,
Route,
Server,
Trash2,
Upload,
Wand2,
FileAudio,
Mic,
Square,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { createLogger } from '@/lib/logger';
import { useTTSPreview } from '@/lib/audio/use-tts-preview';
import { isTTSProviderConfigured, isTTSProviderEnabled } from '@/lib/audio/provider-enablement';
import { isCustomTTSProvider } from '@/lib/audio/types';
import {
getVoxCPMProviderOptions,
normalizeQwenReferenceAudio,
preserveRecordedVoiceName,
normalizeVoxCPMReferenceAudio,
validateVoxCPMReferenceAudio,
VOXCPM_REFERENCE_AUDIO_MAX_SECONDS,
useVoxCPMVoiceProfiles,
useQwenVoiceProfiles,
} from '@/lib/audio/voxcpm-voices';
import {
VOXCPM_BACKENDS,
VOXCPM_TTS_PROVIDER_ID,
buildVoxCPMBackendUrl,
getVoxCPMBackendEndpoint,
getVoxCPMProfileVoiceId,
normalizeVoxCPMBackend,
VOXCPM_VLLM_MODEL_ID,
voxCPMBackendSupportsReferenceAudio,
} from '@/lib/audio/voxcpm';
const log = createLogger('TTSSettings');
interface TTSSettingsProps {
selectedProviderId: TTSProviderId;
}
export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
const { t, locale } = useI18n();
const ttsVoice = useSettingsStore((state) => state.ttsVoice);
const ttsSpeed = useSettingsStore((state) => state.ttsSpeed);
const setTTSSpeed = useSettingsStore((state) => state.setTTSSpeed);
const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig);
const setTTSProviderConfig = useSettingsStore((state) => state.setTTSProviderConfig);
const activeProviderId = useSettingsStore((state) => state.ttsProviderId);
const setTTSVoice = useSettingsStore((state) => state.setTTSVoice);
const removeCustomTTSProvider = useSettingsStore((state) => state.removeCustomTTSProvider);
const ttsProvider = TTS_PROVIDERS[selectedProviderId as keyof typeof TTS_PROVIDERS];
const isCustom = isCustomTTSProvider(selectedProviderId);
// Voice-clone models are resolved from the picked voice, never chosen by
// hand, so they are hidden from the manual model list.
const manuallySelectableModels = getManuallySelectableTTSModels(selectedProviderId);
const providerConfig = ttsProvidersConfig[selectedProviderId];
const isServerConfigured = !!providerConfig?.isServerConfigured;
// Per-provider enablement (#665): the toggle is meaningful only for an
// AVAILABLE provider (configured / server-managed). An unconfigured provider
// can't be "enabled" into the picker, so its toggle is disabled. Server
// force-disable also locks it. `checked` reflects the true effective state.
const providerServerDisabled = !!providerConfig?.serverDisabled;
const providerConfigured = isTTSProviderConfigured(selectedProviderId, providerConfig);
const providerEnableLocked = providerServerDisabled || !providerConfigured;
const providerEnabled = isTTSProviderEnabled(selectedProviderId, providerConfig);
const isVoxCPM = selectedProviderId === 'voxcpm-tts';
const voxcpmBackend = normalizeVoxCPMBackend(providerConfig?.providerOptions?.backend);
const requiresApiKey = isCustom
? !!providerConfig?.requiresApiKey
: !!ttsProvider?.requiresApiKey;
const isKeylessLocalProvider = !isCustom && !requiresApiKey && !!ttsProvider?.defaultBaseUrl;
// When testing a non-active provider, use that provider's default voice
// instead of the active provider's voice (which may be incompatible).
const effectiveVoice =
selectedProviderId === activeProviderId
? ttsVoice
: isCustomTTSProvider(selectedProviderId)
? ((providerConfig?.customVoices as Array<{ id: string }> | undefined) || [])[0]?.id ||
'default'
: DEFAULT_TTS_VOICES[selectedProviderId as keyof typeof DEFAULT_TTS_VOICES] || 'default';
const cloneSpeedDisabled = selectedProviderId === 'qwen-tts' && isQwenCloneVoice(effectiveVoice);
const [showApiKey, setShowApiKey] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [testText, setTestText] = useState(t('settings.ttsTestTextDefault'));
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const { previewing: testingTTS, startPreview, stopPreview } = useTTSPreview();
// Doubao TTS uses compound "appId:accessKey" — split for separate UI fields
const isDoubao = selectedProviderId === 'doubao-tts';
const rawApiKey = ttsProvidersConfig[selectedProviderId]?.apiKey || '';
const doubaoColonIdx = rawApiKey.indexOf(':');
const doubaoAppId = isDoubao && doubaoColonIdx > 0 ? rawApiKey.slice(0, doubaoColonIdx) : '';
const doubaoAccessKey =
isDoubao && doubaoColonIdx > 0
? rawApiKey.slice(doubaoColonIdx + 1)
: isDoubao
? rawApiKey
: '';
const setDoubaoCompoundKey = (appId: string, accessKey: string) => {
const combined = appId && accessKey ? `${appId}:${accessKey}` : appId || accessKey;
setTTSProviderConfig(selectedProviderId, { apiKey: combined });
};
// Keep the sample text in sync with locale changes.
useEffect(() => {
setTestText(t('settings.ttsTestTextDefault'));
}, [t]);
// Reset transient UI state when switching providers.
useEffect(() => {
stopPreview();
setShowApiKey(false);
setTestStatus('idle');
setTestMessage('');
}, [selectedProviderId, stopPreview]);
const handleTestTTS = async () => {
if (!testText.trim()) return;
setTestStatus('testing');
setTestMessage('');
try {
const providerOptions =
selectedProviderId === 'voxcpm-tts'
? {
...(ttsProvidersConfig[selectedProviderId]?.providerOptions || {}),
...(await getVoxCPMProviderOptions(effectiveVoice, { role: 'teacher', locale })),
}
: undefined;
await startPreview({
text: testText,
providerId: selectedProviderId,
modelId: resolveTTSModelForVoice(
selectedProviderId,
effectiveVoice,
ttsProvidersConfig[selectedProviderId]?.modelId || ttsProvider?.defaultModelId || '',
),
voice: effectiveVoice,
speed: ttsSpeed,
apiKey: ttsProvidersConfig[selectedProviderId]?.apiKey,
// Managed providers resolve their base URL server-side; only send the
// client's own base URL (custom providers).
baseUrl:
ttsProvidersConfig[selectedProviderId]?.baseUrl ||
providerConfig?.customDefaultBaseUrl ||
'',
providerOptions,
});
setTestStatus('success');
setTestMessage(t('settings.ttsTestSuccess'));
} catch (error) {
log.error('TTS test failed:', error);
setTestStatus('error');
setTestMessage(
error instanceof Error && error.message
? `${t('settings.ttsTestFailed')}: ${error.message}`
: t('settings.ttsTestFailed'),
);
}
};
const effectiveBaseUrl =
ttsProvidersConfig[selectedProviderId]?.baseUrl ||
(isCustom ? providerConfig?.customDefaultBaseUrl : ttsProvider?.defaultBaseUrl) ||
'';
const endpointPath = (() => {
if (isCustom) return '/audio/speech';
switch (selectedProviderId) {
case 'openai-tts':
case 'glm-tts':
case 'lemonade-tts':
return '/audio/speech';
case 'azure-tts':
return '/cognitiveservices/v1';
case 'qwen-tts':
return '/services/aigc/multimodal-generation/generation';
case 'voxcpm-tts':
return getVoxCPMBackendEndpoint(voxcpmBackend);
case 'elevenlabs-tts':
return '/text-to-speech';
case 'doubao-tts':
return '/unidirectional';
default:
return '';
}
})();
const requestUrl =
effectiveBaseUrl && endpointPath
? selectedProviderId === 'voxcpm-tts'
? buildVoxCPMBackendUrl(effectiveBaseUrl, voxcpmBackend)
: effectiveBaseUrl + endpointPath
: '';
const isVoxCPMVLLMOmni = voxcpmBackend === 'vllm-omni';
return (
{/* Browser-native TTS can't produce managed audio files, so the Pro-mode
timeline's per-line audio (preview / regenerate / bulk voiceover) is
unavailable on it — surface that when this provider is selected. */}
{selectedProviderId === 'browser-native-tts' && (
{t('settings.ttsBrowserNativeTimelineNotice')}
)}
{/* Enable / disable this provider for the voice picker and auto-assignment (#665). */}
)}
{/* API Key & Base URL — hidden for managed providers, which are admin-owned
and not overridable from the client. */}
{!isServerConfigured &&
(requiresApiKey || isCustom || isVoxCPM || isKeylessLocalProvider) &&
(isVoxCPM ? (