- Extract actual error message from blob response in previewVoice so users see the real API error instead of generic "Failed to generate preview" - VoicePreviewButton now reads err.message from thrown Error objects - Add available: bool field to ProviderVoicesResponse; returns false when ELEVENLABS_API_KEY is not configured so the frontend can react proactively instead of hitting a 400 on preview - VoiceSelector shows a descriptive config warning when available=false Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
181 lines
4.9 KiB
TypeScript
181 lines
4.9 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { api } from '../lib/api';
|
|
import type { TTSStylePreset, TTSProvider } from '../types/api';
|
|
|
|
interface VoicePreviewButtonProps {
|
|
voiceName: string;
|
|
language: string;
|
|
disabled?: boolean;
|
|
provider?: TTSProvider;
|
|
model?: string;
|
|
speed?: number;
|
|
stylePreset?: TTSStylePreset;
|
|
customStylePrompt?: string;
|
|
stability?: number;
|
|
similarityBoost?: number;
|
|
}
|
|
|
|
export function VoicePreviewButton({
|
|
voiceName,
|
|
language,
|
|
disabled,
|
|
provider,
|
|
model,
|
|
speed,
|
|
stylePreset,
|
|
customStylePrompt,
|
|
stability,
|
|
similarityBoost,
|
|
}: VoicePreviewButtonProps) {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
const audioUrlRef = useRef<string | null>(null);
|
|
|
|
// Clear cached audio when any TTS setting changes
|
|
useEffect(() => {
|
|
// Stop any playing audio
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current = null;
|
|
}
|
|
// Revoke old URL
|
|
if (audioUrlRef.current) {
|
|
URL.revokeObjectURL(audioUrlRef.current);
|
|
audioUrlRef.current = null;
|
|
}
|
|
setIsPlaying(false);
|
|
setError(null);
|
|
}, [voiceName, language, provider, model, speed, stylePreset, customStylePrompt, stability, similarityBoost]);
|
|
|
|
const handlePreview = async () => {
|
|
setError(null);
|
|
|
|
// If already playing, stop
|
|
if (isPlaying && audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.currentTime = 0;
|
|
setIsPlaying(false);
|
|
return;
|
|
}
|
|
|
|
// If we have cached audio, play it
|
|
if (audioUrlRef.current && audioRef.current) {
|
|
audioRef.current.play();
|
|
setIsPlaying(true);
|
|
return;
|
|
}
|
|
|
|
// Fetch new audio
|
|
setIsLoading(true);
|
|
try {
|
|
const blob = await api.previewVoice(
|
|
voiceName,
|
|
language,
|
|
model,
|
|
speed,
|
|
stylePreset,
|
|
customStylePrompt,
|
|
provider,
|
|
stability,
|
|
similarityBoost,
|
|
);
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// Clean up old URL if exists
|
|
if (audioUrlRef.current) {
|
|
URL.revokeObjectURL(audioUrlRef.current);
|
|
}
|
|
|
|
audioUrlRef.current = url;
|
|
|
|
// Create and play audio
|
|
const audio = new Audio(url);
|
|
audioRef.current = audio;
|
|
|
|
audio.onended = () => {
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
audio.onerror = () => {
|
|
setError('Failed to play audio');
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
await audio.play();
|
|
setIsPlaying(true);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Failed to generate preview';
|
|
setError(errorMessage);
|
|
console.error('Voice preview error:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="inline-flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handlePreview}
|
|
disabled={disabled || isLoading}
|
|
className={`
|
|
inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md
|
|
transition-colors duration-150
|
|
${disabled || isLoading
|
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
: isPlaying
|
|
? 'bg-red-100 text-red-700 hover:bg-red-200'
|
|
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
|
}
|
|
`}
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
fill="none"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
/>
|
|
</svg>
|
|
Loading...
|
|
</>
|
|
) : isPlaying ? (
|
|
<>
|
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1zm0 4a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
Stop
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
Preview
|
|
</>
|
|
)}
|
|
</button>
|
|
{error && <span className="text-xs text-red-600">{error}</span>}
|
|
</div>
|
|
);
|
|
}
|