OmniVoice-Studio / frontend /src /hooks /useProfiles.js
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
8.61 kB
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,
};
}