169 lines
6.5 KiB
TypeScript
169 lines
6.5 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useSettings } from 'state/useSettings';
|
|
|
|
type VoiceGroup = { label: string; voices: string[] };
|
|
|
|
type TtsConfig = {
|
|
provider: string;
|
|
url: string;
|
|
apiKey?: string;
|
|
model: string;
|
|
voice: string;
|
|
};
|
|
|
|
const SERVER_DEFAULT = '__server_default__';
|
|
|
|
export const VoicePreference = () => {
|
|
const client = useClient();
|
|
const { settings, saveSettings } = useSettings();
|
|
const [voices, setVoices] = useState<string[]>([]);
|
|
const [groups, setGroups] = useState<VoiceGroup[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [serverConfig, setServerConfig] = useState<TtsConfig | null>(null);
|
|
const [listening, setListening] = useState<'idle' | 'loading' | 'playing'>('idle');
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const fetchConfig = async () => {
|
|
try {
|
|
const config = await client.get<TtsConfig | null>('/server-settings/tts');
|
|
setServerConfig(config);
|
|
return config;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const fetchVoices = async (config: TtsConfig) => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await client.post<{ voices?: string[]; groups?: VoiceGroup[] }>('/server-settings/tts/voices', {
|
|
provider: config.provider,
|
|
url: config.url,
|
|
apiKey: config.apiKey || undefined,
|
|
model: config.model || undefined,
|
|
});
|
|
setVoices(res.voices ?? []);
|
|
setGroups(res.groups ?? []);
|
|
} catch {
|
|
setVoices([]);
|
|
setGroups([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchConfig().then((config) => {
|
|
if (config) fetchVoices(config);
|
|
});
|
|
}, []);
|
|
|
|
const handleChange = (value: string) => {
|
|
const voice = value === SERVER_DEFAULT ? null : value;
|
|
saveSettings({ ...settings, tts: { voice } });
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
if (serverConfig) fetchVoices(serverConfig);
|
|
};
|
|
|
|
const handleListen = async () => {
|
|
if (listening === 'loading') return;
|
|
if (listening === 'playing') {
|
|
audioRef.current?.pause();
|
|
audioRef.current = null;
|
|
setListening('idle');
|
|
return;
|
|
}
|
|
if (!serverConfig) return;
|
|
setListening('loading');
|
|
try {
|
|
const effectiveVoice = settings.tts.voice ?? serverConfig.voice;
|
|
const res = await fetch(`${client.baseUrl}/server-settings/tts/test`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${client.token}` },
|
|
body: JSON.stringify({ ...serverConfig, voice: effectiveVoice }),
|
|
});
|
|
if (!res.ok || res.headers.get('content-type')?.includes('json')) {
|
|
setListening('idle');
|
|
return;
|
|
}
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const audio = new Audio(url);
|
|
audioRef.current = audio;
|
|
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
|
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
|
await audio.play();
|
|
setListening('playing');
|
|
} catch {
|
|
setListening('idle');
|
|
}
|
|
};
|
|
|
|
const selectedValue = settings.tts.voice ?? SERVER_DEFAULT;
|
|
const prettify = (v: string) => v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase());
|
|
|
|
return (
|
|
<div className="grid gap-4">
|
|
<Label className="grid gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
|
title="Refresh voices"
|
|
>
|
|
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={selectedValue} onValueChange={handleChange}>
|
|
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
|
<SelectValue placeholder="Server default" />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[600] max-h-[300px]">
|
|
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
|
|
{groups.length > 0
|
|
? groups.map((g) => (
|
|
<SelectGroup key={g.label}>
|
|
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
|
{g.voices.map((v) => (
|
|
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
))
|
|
: voices.map((v) => (
|
|
<SelectItem key={v} value={v}>{v}</SelectItem>
|
|
))
|
|
}
|
|
</SelectContent>
|
|
</Select>
|
|
<button
|
|
type="button"
|
|
onClick={handleListen}
|
|
disabled={listening === 'loading' || !serverConfig}
|
|
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
|
|
title={listening === 'playing' ? 'Stop' : 'Listen'}
|
|
>
|
|
{listening === 'loading' ? (
|
|
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
|
|
) : listening === 'playing' ? (
|
|
<Square className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
|
|
) : (
|
|
<Play className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
|
|
</Label>
|
|
</div>
|
|
);
|
|
};
|