import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Loader2, Play, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, getErrorMessage } from "@/lib/queryClient"; import { DEFAULT_VOICE, TTS_STYLE_OPTIONS, voicesFor, type TtsEngine, type TtsStyle, type VoiceOption, } from "@shared/voices"; import type { TimedWord } from "@shared/captions"; export interface VoiceSettings { engine: TtsEngine; voice: string; style: TtsStyle; } /** Voix générée par « Tester la voix », réutilisée par l'aperçu vidéo. */ export interface VoicePreviewResult { audioUrl: string; duration: number; words: TimedWord[]; /** Texte et réglages ayant servi : l'aperçu n'est valable que s'ils n'ont pas changé. */ text: string; settings: VoiceSettings; } export function isVoicePreviewCurrent( preview: VoicePreviewResult | null, text: string, settings: VoiceSettings, ): preview is VoicePreviewResult { return ( !!preview && preview.text === text.trim() && preview.settings.engine === settings.engine && preview.settings.voice === settings.voice && preview.settings.style === settings.style ); } interface VoicePickerProps { value: VoiceSettings; onChange: (value: VoiceSettings) => void; /** Texte lu par le bouton « Tester la voix ». */ sampleText?: string; compact?: boolean; /** Appelé avec la voix générée (audio et minutage des mots). */ onPreview?: (result: VoicePreviewResult) => void; } const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !"; /** * Choix du moteur, de la voix et du ton, avec écoute de l'aperçu. * Partagé par les pages Reel (vidéo et images, bureau et mobile). */ export function VoicePicker({ value, onChange, sampleText, compact = false, onPreview }: VoicePickerProps) { const { toast } = useToast(); const [loading, setLoading] = useState(false); const [playing, setPlaying] = useState(false); const audioRef = useRef(null); useEffect(() => () => audioRef.current?.pause(), []); // Voix Qwen réellement installées (voix clonées comprises) ; moteur grisé si le service local ne répond pas const { data: qwenCatalog } = useQuery<{ qwenAvailable: boolean; qwen: VoiceOption[] }>({ queryKey: ["/api/reels/voices"], staleTime: 5 * 60_000, }); const qwenAvailable = qwenCatalog?.qwenAvailable ?? false; const voices = value.engine === "qwen" && qwenCatalog?.qwen.length ? qwenCatalog.qwen : voicesFor(value.engine); const groups = [ { label: "Voix féminines", items: voices.filter((v) => v.gender === "female") }, { label: "Voix masculines", items: voices.filter((v) => v.gender === "male") }, ]; const setEngine = (engine: TtsEngine) => { if (engine !== value.engine) onChange({ ...value, engine, voice: DEFAULT_VOICE[engine] }); }; const stop = () => { audioRef.current?.pause(); setPlaying(false); }; const preview = async () => { if (playing) return stop(); setLoading(true); try { const text = sampleText?.trim() || FALLBACK_SAMPLE; const response = await apiRequest("POST", "/api/reels/tts-preview", { text, ttsEngine: value.engine, ttsVoice: value.voice, ttsStyle: value.style, }); const data = await response.json(); for (const warning of data.warnings ?? []) { toast({ title: "Voix de secours utilisée", description: warning }); } const audioUrl = `data:audio/mpeg;base64,${data.audioBase64}`; if (sampleText?.trim()) { onPreview?.({ audioUrl, duration: data.duration, words: data.words ?? [], text, settings: value }); } const audio = new Audio(audioUrl); audioRef.current?.pause(); audioRef.current = audio; audio.onended = () => setPlaying(false); await audio.play(); setPlaying(true); } catch (error) { toast({ title: "Impossible de tester la voix", description: getErrorMessage(error, "La voix n'a pas pu être générée."), variant: "destructive", }); } finally { setLoading(false); } }; const labelClass = compact ? "text-xs font-medium" : "text-sm font-medium"; return (
); }