Spaces:
Running
Running
| 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> | |
| ); | |
| } | |