OmniVoice-Studio / frontend /src /components /CompareModal.jsx
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
6.38 kB
import React, { useEffect, useRef } from 'react';
import { Scale, Fingerprint, Play, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { PRESETS } from '../utils/constants';
import { generateSpeech } from '../api/generate';
import { Button, Panel, Field, Textarea, Select } from '../ui';
import './CompareModal.css';
export default function CompareModal({
open, onClose,
profiles,
compareText, setCompareText,
compareVoiceA, setCompareVoiceA,
compareVoiceB, setCompareVoiceB,
compareResultA, setCompareResultA,
compareResultB, setCompareResultB,
compareProgress, setCompareProgress,
isComparing, setIsComparing,
steps, cfg, speed, denoise, postprocess,
fileToMediaUrl, loadHistory,
}) {
const drawerRef = useRef(null);
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
const onPointer = (e) => {
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.();
};
window.addEventListener('keydown', onKey);
// Defer click-outside so the opening click doesn't immediately close.
const t = setTimeout(() => window.addEventListener('mousedown', onPointer), 0);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onPointer);
clearTimeout(t);
};
}, [open, onClose]);
const runCompare = async () => {
setIsComparing(true);
setCompareResultA(null);
setCompareResultB(null);
const generateVoice = async (voiceId) => {
setCompareProgress('Preparing voice...');
const formData = new FormData();
formData.append('text', compareText);
let fin_prof = voiceId;
let fin_inst = '';
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');
fin_inst = parts.join(', ');
}
fin_prof = '';
} else if (profiles.find(p => p.id === fin_prof)?.instruct) {
fin_inst = profiles.find(p => p.id === fin_prof).instruct;
}
if (fin_prof) formData.append('profile_id', fin_prof);
if (fin_inst) formData.append('instruct', fin_inst);
formData.append('num_step', steps);
formData.append('guidance_scale', cfg);
formData.append('speed', speed);
formData.append('denoise', denoise);
formData.append('postprocess_output', postprocess);
const res = await generateSpeech(formData);
const blob = await res.blob();
const urls = await fileToMediaUrl(blob, null);
return urls.audioUrl;
};
try {
setCompareProgress('Generating Voice A...');
const audioA = await generateVoice(compareVoiceA);
setCompareResultA(audioA);
setCompareProgress('Generating Voice B...');
const audioB = await generateVoice(compareVoiceB);
setCompareResultB(audioB);
setCompareProgress('');
toast.success('Comparison complete!');
loadHistory();
} catch (err) {
toast.error('Play failed: ' + err.message);
setCompareProgress('');
} finally {
setIsComparing(false);
}
};
const canCompare = !isComparing && compareVoiceA && compareVoiceB && compareText.trim();
if (!open) return null;
return (
<div className="compare-drawer" role="dialog" aria-modal="false" aria-label="A/B Voice Comparison">
<div className="compare-drawer__sheet" ref={drawerRef}>
<header className="compare-drawer__head">
<span className="compare-drawer__handle" aria-hidden="true" />
<span className="compare-drawer__title">
<Scale size={14} /> A/B Voice Comparison
</span>
<button
type="button"
className="compare-drawer__close"
onClick={onClose}
aria-label="Close comparison"
>
<X size={12} />
</button>
</header>
<div className="compare-drawer__body">
<p className="ui-compare__desc">
Compare two voices side by side to make casting decisions. App stays interactive behind.
</p>
<Field label="Test phrase">
<Textarea
value={compareText}
onChange={e => setCompareText(e.target.value)}
rows={2}
className="compare-textarea--noresize"
/>
</Field>
<div className="ui-compare__grid">
<CompareSide
accent="var(--color-brand)"
label="Voice A"
profiles={profiles}
value={compareVoiceA}
onChange={setCompareVoiceA}
audio={compareResultA}
/>
<CompareSide
accent="var(--color-success)"
label="Voice B"
profiles={profiles}
value={compareVoiceB}
onChange={setCompareVoiceB}
audio={compareResultB}
/>
</div>
</div>
<footer className="compare-drawer__foot">
<Button variant="ghost" onClick={onClose}>Close</Button>
<Button
variant="primary"
loading={isComparing}
disabled={!canCompare}
onClick={runCompare}
leading={!isComparing && <Play size={12} />}
>
{isComparing ? (compareProgress || 'Comparing…') : 'Compare'}
</Button>
</footer>
</div>
</div>
);
}
function CompareSide({ accent, label, profiles, value, onChange, audio }) {
return (
<Panel variant="flat" padding="sm">
<h3 className="ui-compare__head" style={{ color: accent }}>
<Fingerprint size={14} /> {label}
</h3>
<Field>
<Select value={value} onChange={e => onChange(e.target.value)}>
<option value="">— Select voice —</option>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
{PRESETS.map(p => <option key={p.id} value={`preset:${p.id}`}>{p.name} (Preset)</option>)}
</Select>
</Field>
{audio ? (
<audio src={audio} controls className="ui-compare__audio" />
) : (
<div className="ui-compare__audio-empty">No audio yet</div>
)}
</Panel>
);
}