Spaces:
Sleeping
Sleeping
| import { useState, useCallback } from 'react'; | |
| import { useAppStore } from '../store'; | |
| import { createProfile, deleteProfile as apiDeleteProfile, lockProfile, unlockProfile } from '../api/profiles'; | |
| import { generateSpeech, audioUrlWithCacheBust } from '../api/generate'; | |
| import { playBlobAudio } from '../utils/media'; | |
| import { PRESETS } from '../utils/constants'; | |
| import { askConfirm } from '../utils/dialog'; | |
| import { toast } from 'react-hot-toast'; | |
| /** | |
| * Encapsulates voice-profile CRUD, lock/unlock, preview, and save-from-history. | |
| */ | |
| export default function useProfiles({ loadHistory, loadProfiles }) { | |
| const [selectedProfile, setSelectedProfile] = useState(null); | |
| const [showSaveProfile, setShowSaveProfile] = useState(false); | |
| const [profileName, setProfileName] = useState(''); | |
| const [previewLoading, setPreviewLoading] = useState(null); | |
| const [segmentPreviewLoading, setSegmentPreviewLoading] = useState(null); | |
| // Voice Preview floating card | |
| const [isVoicePreviewOpen, setIsVoicePreviewOpen] = useState(false); | |
| const [voicePreviewProfileId, setVoicePreviewProfileId] = useState(''); | |
| const setRefText = useAppStore(s => s.setRefText); | |
| const setInstruct = useAppStore(s => s.setInstruct); | |
| const setLanguage = useAppStore(s => s.setLanguage); | |
| const language = useAppStore(s => s.language); | |
| const mode = useAppStore(s => s.mode); | |
| const steps = useAppStore(s => s.steps); | |
| const cfg = useAppStore(s => s.cfg); | |
| const dubLang = useAppStore(s => s.dubLang); | |
| const dubSegments = useAppStore(s => s.dubSegments); | |
| const text = useAppStore(s => s.text); | |
| // loadProfiles is provided by useAppData (single source of truth) | |
| const handleSaveProfile = useCallback(async (refAudio, refText, instruct, language) => { | |
| if (!profileName.trim() || !refAudio) return toast.error("Need a name and reference audio"); | |
| const formData = new FormData(); | |
| formData.append("name", profileName); | |
| const arrBuf = await refAudio.arrayBuffer(); | |
| const safeBlob = new Blob([arrBuf], { type: refAudio.type }); | |
| formData.append("ref_audio", safeBlob, refAudio.name || "profile.wav"); | |
| formData.append("ref_text", refText); | |
| formData.append("instruct", instruct); | |
| formData.append("language", language); | |
| try { | |
| await createProfile(formData); | |
| setShowSaveProfile(false); | |
| setProfileName(''); | |
| await loadProfiles(); | |
| } catch (e) { toast.error(e.message); } | |
| }, [profileName, loadProfiles]); | |
| const handleDeleteProfile = useCallback(async (id) => { | |
| if (!(await askConfirm('Delete this voice profile?'))) return; | |
| await apiDeleteProfile(id); | |
| if (selectedProfile === id) setSelectedProfile(null); | |
| await loadProfiles(); | |
| }, [selectedProfile, loadProfiles]); | |
| const handleSelectProfile = useCallback((profile) => { | |
| setSelectedProfile(profile.id); | |
| setRefText(profile.ref_text || ''); | |
| setInstruct(profile.instruct || ''); | |
| if (profile.language && profile.language !== 'Auto') setLanguage(profile.language); | |
| }, [setRefText, setInstruct, setLanguage]); | |
| const handlePreviewVoice = useCallback(async (proj, e) => { | |
| e.stopPropagation(); | |
| if (previewLoading) return; | |
| let previewText = "This is a voice preview."; | |
| let reqLang = language; | |
| if (mode === 'dub' && dubSegments.length > 0) { | |
| let seg = dubSegments.find(s => s.profile_id === proj.id && s.text.trim().length > 0); | |
| if (!seg) seg = dubSegments.find(s => s.text.trim().length > 0); | |
| if (seg) previewText = seg.text; | |
| reqLang = dubLang; | |
| } else if (text.trim() !== '') { | |
| previewText = text; | |
| } | |
| setPreviewLoading(proj.id); | |
| const toastId = toast.loading(`Synthesizing preview for ${proj.name}...`); | |
| try { | |
| const formData = new FormData(); | |
| formData.append("text", previewText); | |
| formData.append("profile_id", proj.id); | |
| if (reqLang && reqLang !== 'Auto') formData.append("language", reqLang); | |
| formData.append("num_step", steps || 16); | |
| const res = await generateSpeech(formData); | |
| const blob = await res.blob(); | |
| toast.success('Preview ready!', { id: toastId }); | |
| playBlobAudio(blob).catch(() => toast.error('Playback failed', { id: toastId })); | |
| await loadHistory(); | |
| } catch (err) { | |
| toast.error('Preview failed: ' + err.message, { id: toastId }); | |
| } finally { | |
| setPreviewLoading(null); | |
| } | |
| }, [previewLoading, language, mode, dubSegments, dubLang, text, steps, loadHistory]); | |
| const handleSegmentPreview = useCallback(async (seg, e) => { | |
| e.preventDefault(); | |
| if (segmentPreviewLoading) return; | |
| setSegmentPreviewLoading(seg.id); | |
| const toastId = toast.loading(`Synthesizing segment...`); | |
| try { | |
| const formData = new FormData(); | |
| formData.append("text", seg.text); | |
| let fin_prof = seg.profile_id || ''; | |
| let fin_inst = seg.instruct || ''; | |
| if (fin_prof.startsWith('preset:')) { | |
| const pr = PRESETS.find(p => p.id === fin_prof.replace('preset:', '')); | |
| if (pr) { | |
| const parts = Object.values(pr.attrs).filter(v => v !== 'Auto'); | |
| if (fin_inst.trim()) parts.push(fin_inst.trim()); | |
| fin_inst = parts.join(', '); | |
| } | |
| fin_prof = ''; | |
| } | |
| if (fin_prof) formData.append("profile_id", fin_prof); | |
| if (fin_inst) formData.append("instruct", fin_inst); | |
| const fin_lang = seg.target_lang || dubLang; | |
| if (fin_lang !== 'Auto') formData.append("language", fin_lang); | |
| formData.append("num_step", 8); | |
| formData.append("guidance_scale", cfg || 2.0); | |
| if (seg.speed && seg.speed !== 1.0) formData.append("speed", seg.speed); | |
| const res = await generateSpeech(formData); | |
| const blob = await res.blob(); | |
| toast.success('Preview ready!', { id: toastId }); | |
| playBlobAudio(blob).catch(() => toast.error('Playback failed', { id: toastId })); | |
| } catch (err) { | |
| toast.error('Preview failed: ' + err.message, { id: toastId }); | |
| } finally { | |
| setSegmentPreviewLoading(null); | |
| } | |
| }, [segmentPreviewLoading, dubLang, cfg]); | |
| const handleSaveHistoryAsProfile = useCallback(async (item) => { | |
| try { | |
| const pName = `Voice ${new Date().toLocaleTimeString('en', {hour:'2-digit', minute:'2-digit'})} — ${(item.mode||'design').toUpperCase()}`; | |
| const response = await fetch(audioUrlWithCacheBust(item.audio_path)); | |
| if (!response.ok) throw new Error("Audio not found"); | |
| const blob = await response.blob(); | |
| const file = new File([blob], item.audio_path, { type: "audio/wav" }); | |
| const formData = new FormData(); | |
| formData.append("name", pName); | |
| formData.append("ref_audio", file); | |
| const extractedText = item.text ? (item.text.length > 50 ? item.text.substring(0, 50) : item.text) : ""; | |
| formData.append("ref_text", extractedText); | |
| formData.append("instruct", item.instruct || ""); | |
| formData.append("language", item.language || "Auto"); | |
| if (item.seed !== undefined && item.seed !== null) { | |
| formData.append("seed", item.seed); | |
| } | |
| await createProfile(formData); | |
| toast.success("Voice saved to profiles!"); | |
| await loadProfiles(); | |
| } catch (e) { | |
| toast.error(e.message || "Failed to save voice profile"); | |
| } | |
| }, [loadProfiles]); | |
| const handleLockProfile = useCallback(async (profileId, historyId, seed) => { | |
| try { | |
| const formData = new FormData(); | |
| formData.append("history_id", historyId); | |
| if (seed !== null && seed !== undefined) formData.append("seed", seed); | |
| await lockProfile(profileId, formData); | |
| toast.success("🔒 Voice locked! Identity is now consistent across all generations."); | |
| await loadProfiles(); | |
| } catch (e) { | |
| toast.error(e.message || "Failed to lock profile"); | |
| } | |
| }, [loadProfiles]); | |
| const handleUnlockProfile = useCallback(async (profileId) => { | |
| try { | |
| await unlockProfile(profileId); | |
| toast.success("🎨 Voice unlocked. Generations will vary again."); | |
| await loadProfiles(); | |
| } catch (e) { | |
| toast.error(e.message || "Failed to unlock profile"); | |
| } | |
| }, [loadProfiles]); | |
| return { | |
| selectedProfile, setSelectedProfile, | |
| showSaveProfile, setShowSaveProfile, | |
| profileName, setProfileName, | |
| previewLoading, segmentPreviewLoading, | |
| isVoicePreviewOpen, setIsVoicePreviewOpen, | |
| voicePreviewProfileId, setVoicePreviewProfileId, | |
| handleSaveProfile, | |
| handleDeleteProfile, | |
| handleSelectProfile, | |
| handlePreviewVoice, | |
| handleSegmentPreview, | |
| handleSaveHistoryAsProfile, | |
| handleLockProfile, | |
| handleUnlockProfile, | |
| }; | |
| } | |