VibeVoice / frontend /src /App.jsx
RAM2106's picture
Initial VibeVoice Studio commit
cd5198d
Raw
History Blame Contribute Delete
93.9 kB
import React, { useState, useEffect, useRef } from 'react';
import LandingPage from './LandingPage';
import { supabase } from './supabaseClient';
import {
Mic,
Upload,
Play,
Square,
Volume2,
VolumeX,
Terminal,
Settings,
Radio,
Info,
AlertCircle,
CheckCircle,
Sparkles,
Power,
Loader2,
Trash2,
Podcast,
Headset,
Newspaper,
Volume1,
PlayCircle,
Plus,
Music,
Edit2
} from 'lucide-react';
const SAMPLING_RATE = 24000;
function App() {
const [currentView, setCurrentView] = useState('landing');
const [savedVoices, setSavedVoices] = useState([]);
const [podcastHistory, setPodcastHistory] = useState([]);
// Model & Server Status States
const [modelStatus, setModelStatus] = useState({
useRealModel: true,
modelId: 'microsoft/VibeVoice-Realtime-0.5B',
loaded: false,
loading: true,
error: null,
device: 'CPU'
});
const [wsConnected, setWsConnected] = useState(false);
const [togglingModel, setTogglingModel] = useState(false);
// Script & Synthesis States
const [script, setScript] = useState(
"Speaker 0: Welcome to Vibe Voice Studio. I am cloned from your custom voice sample.\nSpeaker 1: That is incredible! The transition between speakers sounds so smooth and natural.\nSpeaker 0: Yes, Microsoft's continuous acoustic tokenizers make multi-speaker podcasts sound beautiful."
);
const [streamMode, setStreamMode] = useState(true);
const [agentState, setAgentState] = useState({
status: 'idle', // idle, thinking, speaking, listening
title: 'AGENT READY',
sub: 'Configure scripts and trigger generation below'
});
// Audio Playback UI States
const [isAudioPlayingState, setIsAudioPlayingState] = useState(false);
const [volume, setVolume] = useState(80);
const [speed, setSpeed] = useState('1.0');
const [timeline, setTimeline] = useState({ elapsed: 0, total: 0 });
const [showToolbar, setShowToolbar] = useState(false);
// Speaker Slots States (Voice Cloning)
const [speakers, setSpeakers] = useState([
{ id: 0, name: 'Alex', isRecording: false, voicePath: null, previewUrl: null, role: 'Primary Speaker', color: 'cyan' },
{ id: 1, name: 'Sarah', isRecording: false, voicePath: null, previewUrl: null, role: 'Dialogue Partner', color: 'purple' }
]);
// Master Export Hub State
const [compiledAudioUrl, setCompiledAudioUrl] = useState(null);
const [compiledAudioMode, setCompiledAudioMode] = useState(null); // 'stream' or 'file'
const accumulatedSessionChunksRef = useRef([]);
// Preview Voice Reference Playback State
const [playingPreviewId, setPlayingPreviewId] = useState(null);
const previewSourceRef = useRef(null);
// Custom Naming Modal State
const [namingModal, setNamingModal] = useState({
isOpen: false,
fileBlob: null,
speakerId: null,
defaultName: ''
});
const [voiceNameInput, setVoiceNameInput] = useState('');
// Custom Action (Rename/Delete) Modal State
const [actionModal, setActionModal] = useState({
isOpen: false,
type: '', // 'rename_voice', 'delete_voice', 'rename_podcast', 'delete_podcast'
id: null,
oldValue: '',
inputValue: '',
audioUrl: '',
profileName: ''
});
// Console Logs State
const [logs, setLogs] = useState([
{ type: 'system', text: 'Welcome to VibeVoice React Studio Console. System ready.', time: new Date().toLocaleTimeString() },
{ type: 'info', text: 'VibeVoice neural weights loading directly on local CPU. Real-time zero-shot synthesis active.', time: new Date().toLocaleTimeString() }
]);
// Web Audio Context & Playback References
const audioCtxRef = useRef(null);
const analyserNodeRef = useRef(null);
const gainNodeRef = useRef(null);
const wsRef = useRef(null);
// Streaming Queue Refs (Gapless Playback)
const playbackQueueRef = useRef([]);
const nextPlayTimeRef = useRef(0);
const isStreamingActiveRef = useRef(false);
const scheduledSourcesRef = useRef([]);
const doneReceivedRef = useRef(false);
// Traditional REST Player Refs
const traditionalSourceRef = useRef(null);
const traditionalStartClockRef = useRef(0);
const traditionalDurationRef = useRef(0);
// Microphone Recording Refs
const mediaRecordersRef = useRef([null, null]);
const audioChunksRef = useRef([[], []]);
// UI Canvas Visualizer Refs
const canvasRef = useRef(null);
// ==========================================================================
// Console Log Helper
// ==========================================================================
const addLog = (type, text) => {
setLogs(prev => [
...prev,
{ type, text, time: new Date().toLocaleTimeString() }
]);
};
// Scroll Console to Bottom
useEffect(() => {
const consoleBody = document.getElementById('consoleBody');
if (consoleBody) {
consoleBody.scrollTop = consoleBody.scrollHeight;
}
}, [logs]);
// ==========================================================================
// Server Status & WebSocket Handshakes
// ==========================================================================
const checkServerStatus = async () => {
try {
const res = await fetch('/api/status');
const data = await res.json();
setModelStatus({
useRealModel: data.use_real_model,
modelId: data.model_id,
loaded: data.loaded,
loading: data.loading,
error: data.error,
device: data.device
});
} catch (e) {
addLog('error', 'Failed to connect to VibeVoice backend API server.');
}
};
const fetchSavedVoices = async () => {
try {
const { data, error } = await supabase.from('voice_profiles').select('*');
if (error) throw error;
setSavedVoices(data || []);
addLog('info', `Fetched ${data?.length || 0} saved voice profiles from Supabase.`);
// Auto-assign matching saved voices to speakers by name to get "old audio input" instantly!
if (data && data.length > 0) {
setSpeakers(prev => prev.map(s => {
const matched = data.find(v => v.name.trim().toLowerCase() === s.name.trim().toLowerCase());
if (matched && !s.voicePath) {
addLog('info', `Auto-loaded saved voice profile "${matched.name}" into Speaker ${s.id} slot.`);
return {
...s,
voicePath: matched.audio_url,
previewUrl: matched.audio_url
};
}
return s;
}));
}
} catch (err) {
addLog('error', 'Failed to fetch saved voice profiles from Supabase: ' + err.message);
}
};
const fetchPodcastHistory = async () => {
try {
const { data, error } = await supabase
.from('podcast_history')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
setPodcastHistory(data || []);
addLog('info', `Fetched ${data?.length || 0} archived podcasts from Supabase.`);
} catch (err) {
addLog('error', 'Failed to fetch podcast history: ' + err.message);
}
};
const savePodcastToHistory = async (wavBlob, textScript, mode) => {
try {
addLog('info', 'Archiving generated podcast to Supabase permanent storage...');
const fileName = `podcast_${Date.now()}.wav`;
// 1. Upload to storage
const { data: storageData, error: storageError } = await supabase
.storage
.from('voice_samples')
.upload(fileName, wavBlob);
if (storageError) throw storageError;
// 2. Get public url
const { data: publicUrlData } = supabase
.storage
.from('voice_samples')
.getPublicUrl(fileName);
const publicUrl = publicUrlData.publicUrl;
// 3. Insert metadata record into DB
const primaryVoice = speakers[0]?.voicePath || null;
const matchingProfile = primaryVoice ? savedVoices.find(v => v.audio_url === primaryVoice) : null;
const voiceProfileId = matchingProfile ? matchingProfile.id : null;
const { data: dbData, error: dbError } = await supabase
.from('podcast_history')
.insert([{
title: `Podcast - ${new Date().toLocaleString()} (${mode})`,
text_script: textScript,
audio_url: publicUrl,
speaker_id: 0,
voice_profile_id: voiceProfileId
}])
.select();
if (dbError) throw dbError;
addLog('success', 'Podcast archived successfully to Supabase Database!');
fetchPodcastHistory();
} catch (err) {
addLog('warning', `Failed to archive podcast to Supabase (${err.message}). Storing in local session memory...`);
// Fallback: Create local preview URL for the compiled podcast WAV
const localPreviewUrl = URL.createObjectURL(wavBlob);
setPodcastHistory(prev => [
{
id: `local_${Date.now()}`,
title: `Podcast - ${new Date().toLocaleString()} (${mode}) [Local Session]`,
text_script: textScript,
audio_url: localPreviewUrl,
speaker_id: 0,
voice_profile_id: null,
created_at: new Date().toISOString()
},
...prev
]);
addLog('success', 'Podcast successfully cached in local session memory!');
}
};
const saveTraditionalPodcastToHistory = async (audioUrl, textScript, mode) => {
try {
const response = await fetch(audioUrl);
const blob = await response.blob();
await savePodcastToHistory(blob, textScript, mode);
} catch (err) {
addLog('error', 'Failed to download compiled audio for archiving: ' + err.message);
}
};
// --- Custom Action Modal Trigger Hooks ---
const openRenameVoiceModal = (voiceId, oldName, audioUrl) => {
setActionModal({
isOpen: true,
type: 'rename_voice',
id: voiceId,
oldValue: oldName,
inputValue: oldName,
audioUrl,
profileName: oldName
});
};
const openDeleteVoiceModal = (voiceId, oldName, audioUrl) => {
setActionModal({
isOpen: true,
type: 'delete_voice',
id: voiceId,
oldValue: oldName,
inputValue: '',
audioUrl,
profileName: oldName
});
};
const openRenamePodcastModal = (podcastId, oldTitle) => {
setActionModal({
isOpen: true,
type: 'rename_podcast',
id: podcastId,
oldValue: oldTitle,
inputValue: oldTitle,
audioUrl: '',
profileName: oldTitle
});
};
const openDeletePodcastModal = (podcastId, title, audioUrl) => {
setActionModal({
isOpen: true,
type: 'delete_podcast',
id: podcastId,
oldValue: title,
inputValue: '',
audioUrl,
profileName: title
});
};
// --- Core execution functions invoked on Custom Modal Submit ---
const executeRenamePodcast = async (podcastId, newTitle) => {
if (!newTitle || newTitle.trim() === '') return;
try {
addLog('info', `Renaming podcast to "${newTitle.trim()}"...`);
// Local podcast session
if (typeof podcastId === 'string' && podcastId.startsWith('local_')) {
setPodcastHistory(prev => prev.map(p => p.id === podcastId ? { ...p, title: newTitle.trim() } : p));
addLog('success', `Podcast successfully renamed to "${newTitle.trim()}" locally.`);
return;
}
// Supabase DB podcast
const { error } = await supabase
.from('podcast_history')
.update({ title: newTitle.trim() })
.eq('id', podcastId);
if (error) throw error;
addLog('success', `Podcast title successfully updated in Supabase Database!`);
fetchPodcastHistory();
} catch (err) {
addLog('error', 'Failed to rename podcast: ' + err.message);
}
};
const executeRenameVoice = async (voiceId, newName, audioUrl) => {
if (!newName || newName.trim() === '') return;
try {
addLog('info', `Renaming voice profile to "${newName.trim()}"...`);
// Local voice profiles
if (typeof voiceId === 'string' && voiceId.startsWith('local_')) {
setSavedVoices(prev => prev.map(v => v.id === voiceId ? { ...v, name: newName.trim() } : v));
// Update speakers
setSpeakers(prev => prev.map(s => {
if (s.voicePath === audioUrl) {
return { ...s, name: newName.trim() };
}
return s;
}));
addLog('success', `Voice profile successfully renamed to "${newName.trim()}" locally.`);
return;
}
// Supabase
const { error } = await supabase
.from('voice_profiles')
.update({ name: newName.trim() })
.eq('id', voiceId);
if (error) throw error;
addLog('success', `Voice profile successfully renamed in database!`);
// Update state immediately
setSavedVoices(prev => prev.map(v => v.id === voiceId ? { ...v, name: newName.trim() } : v));
// Also update any speaker slot using it
setSpeakers(prev => prev.map(s => {
if (s.voicePath === audioUrl) {
return { ...s, name: newName.trim() };
}
return s;
}));
} catch (err) {
addLog('error', `Failed to rename voice profile: ${err.message}`);
}
};
const executeDeleteVoice = async (voiceId, audioUrl, profileName) => {
try {
addLog('info', `Deleting voice profile "${profileName}"...`);
// Clear voice path of speakers who use this profile
setSpeakers(prev => prev.map(s => s.voicePath === audioUrl ? { ...s, voicePath: null, previewUrl: null } : s));
// Local voice profile
if (typeof voiceId === 'string' && (voiceId.startsWith('local_') || voiceId.endsWith('.wav'))) {
setSavedVoices(prev => prev.filter(v => v.id !== voiceId));
if (audioUrl && audioUrl.startsWith('/static/cloned_voices/')) {
const filename = audioUrl.split('/').pop();
try {
await fetch(`/api/delete-voice/${filename}`, { method: 'DELETE' });
} catch(e) {}
}
addLog('success', `Voice profile "${profileName}" removed from local session.`);
return;
}
// Database profile
const { error: dbError } = await supabase
.from('voice_profiles')
.delete()
.eq('id', voiceId);
if (dbError) throw dbError;
// Extract filename from URL and delete from storage
if (audioUrl) {
try {
const filePart = audioUrl.split('/').pop();
if (filePart) {
await supabase.storage.from('voice_samples').remove([filePart]);
}
} catch (storageErr) {
console.warn('Failed to delete voice profile from Storage:', storageErr);
}
}
addLog('success', `Voice profile "${profileName}" successfully deleted from Supabase.`);
fetchSavedVoices();
} catch (err) {
addLog('error', `Failed to delete voice profile: ${err.message}`);
}
};
const executeDeletePodcast = async (id, audioUrl, title) => {
try {
addLog('info', `Deleting podcast "${title}" from archive...`);
if (typeof id === 'string' && id.startsWith('local_')) {
setPodcastHistory(prev => prev.filter(p => p.id !== id));
addLog('success', 'Podcast deleted successfully from local session memory.');
return;
}
// 1. Delete from database
const { error: dbError } = await supabase
.from('podcast_history')
.delete()
.eq('id', id);
if (dbError) throw dbError;
// 2. Try to extract filename from URL and delete from Storage
try {
const filePart = audioUrl.split('/').pop();
if (filePart) {
await supabase.storage.from('voice_samples').remove([filePart]);
}
} catch (err) {
console.warn('Failed to delete audio file from Storage:', err);
}
addLog('success', 'Podcast deleted successfully.');
fetchPodcastHistory();
} catch (err) {
addLog('error', 'Failed to delete podcast: ' + err.message);
}
};
const playHistoryPodcast = async (podcast) => {
initAudio();
handleStopPlayback(); // Stop any other playing sessions
try {
addLog('info', `Streaming archived podcast: "${podcast.title}"...`);
setIsAudioPlayingState(true);
setCompiledAudioUrl(podcast.audio_url);
setCompiledAudioMode('file');
loadTraditionalWav(podcast.audio_url);
} catch (e) {
addLog('error', 'Failed to play archived podcast.');
handleStopPlayback();
}
};
useEffect(() => {
addLog('system', 'Initializing VibeVoice Studio environment...');
checkServerStatus();
fetchSavedVoices();
fetchPodcastHistory();
// Poll server status periodically if loading
const interval = setInterval(() => {
checkServerStatus();
}, 4000);
return () => clearInterval(interval);
}, []);
// WebSocket Connection Manager
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'ws://127.0.0.1:8000/api/stream'
: `${protocol}//${host}/api/stream`;
addLog('system', `Opening real-time WebSocket connection to ${wsUrl}`);
const websocket = new WebSocket(wsUrl);
wsRef.current = websocket;
websocket.onopen = () => {
setWsConnected(true);
addLog('success', 'WebSocket real-time streaming link ESTABLISHED.');
};
websocket.onclose = () => {
setWsConnected(false);
addLog('error', 'WebSocket streaming link disconnected. Retrying connection...');
};
websocket.onerror = () => {
addLog('error', 'WebSocket encountered connection error.');
};
websocket.onmessage = async (event) => {
if (typeof event.data === 'string') {
const data = JSON.parse(event.data);
if (data.type === 'word') {
addLog('word', `Agent spoke word: "${data.word}"`);
} else if (data.type === 'done') {
addLog('success', 'WebSocket voice generation chunk transfer complete.');
doneReceivedRef.current = true;
// Generate local Object URL of the accumulated chunks for mastering
if (accumulatedSessionChunksRef.current.length > 0) {
try {
const wavBlob = exportWav(accumulatedSessionChunksRef.current, SAMPLING_RATE);
const objectUrl = URL.createObjectURL(wavBlob);
setCompiledAudioUrl(objectUrl);
setCompiledAudioMode('stream');
addLog('success', 'Master podcast output audio created and mastered for local export.');
// Automatically archive to Supabase history
savePodcastToHistory(wavBlob, script, 'WebSocket Stream');
} catch (err) {
addLog('error', `Failed to master session output: ${err.message}`);
}
}
// If no sources are playing and queue is completely empty, finalize playback
if (scheduledSourcesRef.current.length === 0 && playbackQueueRef.current.length === 0) {
handleStopPlayback();
}
} else if (data.type === 'error') {
addLog('error', `Server stream error: ${data.message}`);
handleStopPlayback();
}
} else {
// Raw Audio Binary Chunks
const arrayBuffer = await event.data.arrayBuffer();
const int16Array = new Int16Array(arrayBuffer);
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) {
float32Array[i] = int16Array[i] / 32767.0;
}
if (isStreamingActiveRef.current) {
playbackQueueRef.current.push(float32Array);
// Accumulate for session download
accumulatedSessionChunksRef.current.push(float32Array);
scheduleNextStreamingChunk();
}
}
};
return () => {
if (websocket) websocket.close();
};
}, []);
// ==========================================================================
// Web Audio Context & Node Setup (Pillar 2 & 3)
// ==========================================================================
const initAudio = () => {
if (audioCtxRef.current) return;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
const context = new AudioContextClass({ sampleRate: SAMPLING_RATE });
audioCtxRef.current = context;
const analyser = context.createAnalyser();
analyser.fftSize = 256;
analyserNodeRef.current = analyser;
const gain = context.createGain();
gain.gain.setValueAtTime(volume / 100, context.currentTime);
gainNodeRef.current = gain;
// Connect pipeline
gain.connect(analyser);
analyser.connect(context.destination);
addLog('system', `Browser AudioContext engine running at ${SAMPLING_RATE}Hz.`);
};
useEffect(() => {
if (gainNodeRef.current && audioCtxRef.current) {
gainNodeRef.current.gain.setValueAtTime(volume / 100, audioCtxRef.current.currentTime);
}
}, [volume]);
// Streaming Gapless Playback Logic
const startStreamingPlayback = () => {
initAudio();
if (audioCtxRef.current.state === 'suspended') {
audioCtxRef.current.resume();
}
playbackQueueRef.current = [];
accumulatedSessionChunksRef.current = []; // Clear previous export session chunks
scheduledSourcesRef.current = [];
doneReceivedRef.current = false;
nextPlayTimeRef.current = audioCtxRef.current.currentTime + 0.15;
isStreamingActiveRef.current = true;
setIsAudioPlayingState(true);
setAgentState({
status: 'speaking',
title: 'STREAMING SPEECH',
sub: 'Synthesizing voice chunks in real-time over WebSocket...'
});
addLog('info', 'WebSocket player active. Buffering and playing...');
};
const scheduleNextStreamingChunk = () => {
if (!isStreamingActiveRef.current || playbackQueueRef.current.length === 0) return;
const chunk = playbackQueueRef.current.shift();
if (!chunk || chunk.length === 0) return;
const context = audioCtxRef.current;
const buffer = context.createBuffer(1, chunk.length, SAMPLING_RATE);
buffer.copyToChannel(chunk, 0);
const source = context.createBufferSource();
source.buffer = buffer;
source.playbackRate.value = parseFloat(speed);
source.connect(gainNodeRef.current);
const startTime = Math.max(context.currentTime, nextPlayTimeRef.current);
source.start(startTime);
scheduledSourcesRef.current.push(source);
const duration = buffer.duration / source.playbackRate.value;
nextPlayTimeRef.current = startTime + duration;
source.onended = () => {
// Remove completed source
scheduledSourcesRef.current = scheduledSourcesRef.current.filter(src => src !== source);
// Check if queue is completely drained and done signal is received
if (scheduledSourcesRef.current.length === 0 && playbackQueueRef.current.length === 0 && isStreamingActiveRef.current && doneReceivedRef.current) {
handleStopPlayback();
addLog('info', 'Agent voice stream play completed.');
}
};
};
const handleStopPlayback = () => {
isStreamingActiveRef.current = false;
playbackQueueRef.current = [];
// Stop and clear all active buffers
scheduledSourcesRef.current.forEach(src => {
try { src.stop(); } catch(e) {}
});
scheduledSourcesRef.current = [];
if (traditionalSourceRef.current) {
try { traditionalSourceRef.current.stop(); } catch(e) {}
traditionalSourceRef.current = null;
}
// Stop reference preview playback if active
if (previewSourceRef.current) {
try { previewSourceRef.current.stop(); } catch(e) {}
previewSourceRef.current = null;
}
setPlayingPreviewId(null);
setIsAudioPlayingState(false);
setShowToolbar(false);
setAgentState({
status: 'idle',
title: 'AGENT READY',
sub: 'Configure scripts and trigger generation below'
});
addLog('system', 'Agent audio playback stopped.');
};
// ==========================================================================
// Canvas Waves Drawing Loop (AnalyserNode hook)
// ==========================================================================
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
let animationFrameId;
const renderWave = () => {
animationFrameId = requestAnimationFrame(renderWave);
ctx.fillStyle = 'rgba(7, 9, 14, 0.25)'; // Back-sweep fade
ctx.fillRect(0, 0, width, height);
let dataArray = null;
let bufferLength = 0;
if (analyserNodeRef.current && isAudioPlayingState) {
bufferLength = analyserNodeRef.current.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
analyserNodeRef.current.getByteTimeDomainData(dataArray);
}
ctx.lineWidth = 2.5;
ctx.shadowBlur = isAudioPlayingState ? 10 : 0;
ctx.shadowColor = 'rgba(0, 242, 254, 0.4)';
// Linear Neon color gradient across visualizer canvas
const gradient = ctx.createLinearGradient(0, 0, width, 0);
gradient.addColorStop(0, '#a259ff'); // Purple
gradient.addColorStop(0.5, '#00f2fe'); // Cyan
gradient.addColorStop(1, '#ff5c8d'); // Pink
ctx.strokeStyle = gradient;
ctx.beginPath();
if (dataArray) {
// Draw real sound wave oscillations
const sliceWidth = width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * height) / 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
} else {
// Draw calm animated breathing flatline when quiet
const points = 40;
const sliceWidth = width / points;
let x = 0;
const time = Date.now() * 0.003;
for (let i = 0; i < points; i++) {
const breathing = Math.sin(time) * 0.2 + 0.8;
const y = height / 2 + Math.sin(i * 0.15 + time * 2) * (2.5 * breathing);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
}
ctx.lineTo(width, height / 2);
ctx.stroke();
};
renderWave();
return () => cancelAnimationFrame(animationFrameId);
}, [isAudioPlayingState]);
// ==========================================================================
// Synthesis Triggers (REST vs WebSocket Stream)
// ==========================================================================
const handleSynthesize = async () => {
const text = script.trim();
if (!text) {
addLog('error', 'Cannot synthesize. Text script field is empty!');
return;
}
initAudio();
handleStopPlayback(); // Halt ongoing audio runs
// Map dynamic speaker voice paths
const speakerVoices = {};
speakers.forEach(s => {
if (s.voicePath) {
speakerVoices[s.id] = s.voicePath;
}
});
// Reset master download url
setCompiledAudioUrl(null);
setCompiledAudioMode(null);
if (streamMode) {
// WS Streaming Mode
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
addLog('error', 'WebSocket is offline. Streaming mode disabled.');
return;
}
startStreamingPlayback();
wsRef.current.send(JSON.stringify({
text,
voice_sample_path: speakers[0]?.voicePath || null,
speaker_id: 0,
speaker_voices: speakerVoices
}));
} else {
// Traditional File-based Playback Mode
try {
addLog('info', 'Compiling voice script... Sending POST to VibeVoice generator...');
setAgentState({
status: 'thinking',
title: 'COMPILING AUDIO',
sub: 'Running neural speech generation weights...'
});
setIsAudioPlayingState(true);
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
voice_sample_path: speakers[0]?.voicePath || null,
speaker_id: 0,
speaker_voices: speakerVoices
})
});
const data = await res.json();
if (data.status === 'success') {
addLog('success', `Voice file compiled. Method: [${data.mode}]. Playing track...`);
loadTraditionalWav(data.audio_url);
setCompiledAudioUrl(data.audio_url);
setCompiledAudioMode('file');
// Automatically fetch compiled file and archive to Supabase history
saveTraditionalPodcastToHistory(data.audio_url, text, data.mode);
} else {
throw new Error('Server returned error response.');
}
} catch (e) {
handleStopPlayback();
addLog('error', `Synthesis failed: ${e.message}`);
}
}
};
const loadTraditionalWav = async (audioUrl) => {
try {
const res = await fetch(audioUrl);
const arrayBuffer = await res.arrayBuffer();
audioCtxRef.current.decodeAudioData(arrayBuffer, (decodedBuffer) => {
const source = audioCtxRef.current.createBufferSource();
source.buffer = decodedBuffer;
source.playbackRate.value = parseFloat(speed);
source.connect(gainNodeRef.current);
source.start(0);
traditionalSourceRef.current = source;
traditionalDurationRef.current = decodedBuffer.duration;
traditionalStartClockRef.current = audioCtxRef.current.currentTime;
setAgentState({
status: 'speaking',
title: 'PLAYING FILE',
sub: 'Streaming synthesized high-fidelity compiled audio track...'
});
setShowToolbar(true);
setTimeline({ elapsed: 0, total: decodedBuffer.duration });
const tickTimeline = () => {
if (traditionalSourceRef.current === source) {
const elapsed = (audioCtxRef.current.currentTime - traditionalStartClockRef.current) * parseFloat(speed);
setTimeline(prev => ({ ...prev, elapsed: Math.min(elapsed, decodedBuffer.duration) }));
if (elapsed < decodedBuffer.duration) {
requestAnimationFrame(tickTimeline);
}
}
};
tickTimeline();
source.onended = () => {
if (traditionalSourceRef.current === source) {
handleStopPlayback();
}
};
}, (err) => {
addLog('error', 'Failed to decode WAV file buffer.');
handleStopPlayback();
});
} catch(e) {
addLog('error', 'Error playing generated track.');
handleStopPlayback();
}
};
const formatTime = (seconds) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
const encodeWAV = (samples, sampleRate) => {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
const writeString = (view, offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
const floatTo16BitPCM = (output, offset, input) => {
for (let i = 0; i < input.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
};
/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* file length */
view.setUint32(4, 36 + samples.length * 2, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length */
view.setUint32(16, 16, true);
/* sample format (raw) */
view.setUint16(20, 1, true);
/* channel count */
view.setUint16(22, 1, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * 2, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, 2, true);
/* bits per sample */
view.setUint16(34, 16, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * 2, true);
floatTo16BitPCM(view, 44, samples);
return new Blob([view], { type: 'audio/wav' });
};
// ==========================================================================
// Voice Cloning Microphone Hooks (Pillar 4)
// ==========================================================================
const toggleRecording = async (speakerId) => {
const speaker = speakers.find(s => s.id === speakerId);
if (!speaker) return;
if (speaker.isRecording) {
// Stop recording
const recorder = mediaRecordersRef.current[speakerId];
if (recorder) {
recorder.stop();
setSpeakers(prev => prev.map(s => s.id === speakerId ? { ...s, isRecording: false } : s));
setAgentState({
status: 'idle',
title: 'AGENT READY',
sub: 'Voice profile recorded. Syncing to neural engine...'
});
}
} else {
// Start recording
try {
addLog('info', `Requesting mic permission for Speaker ${speakerId} (${speaker.name})...`);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
setAgentState({
status: 'listening',
title: 'LISTENING...',
sub: 'Speak normally for 10-15s to capture vocal signature...'
});
// Use independent default sample-rate AudioContext specifically for mic capture to avoid browser resampling bugs
const RecordingContextClass = window.AudioContext || window.webkitAudioContext;
const recContext = new RecordingContextClass();
const source = recContext.createMediaStreamSource(stream);
const processor = recContext.createScriptProcessor(4096, 1, 1);
const recordingSamples = [];
processor.onaudioprocess = (e) => {
const channelData = e.inputBuffer.getChannelData(0);
recordingSamples.push(new Float32Array(channelData));
};
source.connect(processor);
processor.connect(recContext.destination);
mediaRecordersRef.current[speakerId] = {
stop: () => {
source.disconnect(processor);
processor.disconnect(recContext.destination);
stream.getTracks().forEach(track => track.stop());
recContext.close();
addLog('info', 'Encoding microphone speech data to 16-bit PCM WAV...');
let totalLength = 0;
for (let i = 0; i < recordingSamples.length; i++) {
totalLength += recordingSamples[i].length;
}
const flattened = new Float32Array(totalLength);
let offset = 0;
for (let i = 0; i < recordingSamples.length; i++) {
flattened.set(recordingSamples[i], offset);
offset += recordingSamples[i].length;
}
const wavBlob = encodeWAV(flattened, recContext.sampleRate);
setNamingModal({
isOpen: true,
fileBlob: wavBlob,
speakerId: speakerId,
defaultName: speaker.name
});
setVoiceNameInput(speaker.name);
}
};
setSpeakers(prev => prev.map(s => s.id === speakerId ? { ...s, isRecording: true } : s));
addLog('success', 'Microphone recording started. Speak clearly now!');
} catch (e) {
addLog('error', `Microphone recording failed: ${e.message}`);
setAgentState({
status: 'idle',
title: 'AGENT READY',
sub: 'Record trigger failed or permission denied'
});
}
}
};
const handleFileUpload = (e, speakerId) => {
const file = e.target.files[0];
if (!file) return;
const speaker = speakers.find(s => s.id === speakerId);
addLog('info', `Selected file for upload: ${file.name}`);
const cleanFileName = file.name.replace(/\.[^/.]+$/, "");
setNamingModal({
isOpen: true,
fileBlob: file,
speakerId: speakerId,
defaultName: cleanFileName
});
setVoiceNameInput(cleanFileName);
};
// Play/Stop Reference Voice Preview using main AudioContext pipeline
const playPreview = async (speakerId, url) => {
initAudio();
handleStopPlayback(); // Stop any other playing session
if (playingPreviewId === speakerId) {
stopPreviewPlayback();
return;
}
try {
addLog('info', `Playing voice preview for Speaker ${speakerId}...`);
setIsAudioPlayingState(true);
setPlayingPreviewId(speakerId);
const res = await fetch(url);
const arrayBuffer = await res.arrayBuffer();
audioCtxRef.current.decodeAudioData(arrayBuffer, (decodedBuffer) => {
const source = audioCtxRef.current.createBufferSource();
source.buffer = decodedBuffer;
source.connect(gainNodeRef.current);
source.start(0);
previewSourceRef.current = source;
setAgentState({
status: 'speaking',
title: 'PLAYING REFERENCE',
sub: `Listening to cloned voice reference profile...`
});
source.onended = () => {
stopPreviewPlayback();
};
}, (err) => {
addLog('error', 'Failed to decode reference audio file buffer.');
stopPreviewPlayback();
});
} catch (e) {
addLog('error', 'Error playing reference voice.');
stopPreviewPlayback();
}
};
const stopPreviewPlayback = () => {
if (previewSourceRef.current) {
try { previewSourceRef.current.stop(); } catch(e) {}
previewSourceRef.current = null;
}
setPlayingPreviewId(null);
setIsAudioPlayingState(false);
setAgentState({
status: 'idle',
title: 'AGENT READY',
sub: 'Configure scripts and trigger generation below'
});
};
// Add Dynamic Speaker Slot
const addSpeaker = () => {
const nextId = speakers.length > 0 ? Math.max(...speakers.map(s => s.id)) + 1 : 0;
const speakerColors = ['cyan', 'purple', 'emerald', 'amber', 'rose', 'blue'];
const color = speakerColors[nextId % speakerColors.length];
const roles = ['Podcast Guest', 'Narrator', 'Secondary Host', 'Panelist', 'Expert Analyst'];
const role = roles[(nextId - 2) % roles.length] || 'Guest Slot';
const newSpk = {
id: nextId,
name: `Speaker ${nextId}`,
isRecording: false,
voicePath: null,
previewUrl: null,
role: role,
color: color
};
setSpeakers(prev => [...prev, newSpk]);
addLog('system', `Added dynamic speaker slot: [Speaker ${nextId}]`);
};
// Remove Dynamic Speaker Slot
const removeSpeaker = (speakerId) => {
if (speakerId === 0) {
addLog('error', 'Cannot delete Speaker 0 (Primary Speaker is required).');
return;
}
handleStopPlayback();
setSpeakers(prev => prev.filter(s => s.id !== speakerId));
addLog('system', `Removed speaker slot: [Speaker ${speakerId}]`);
};
const uploadVoiceProfile = async (fileBlob, speakerId, speakerName) => {
try {
addLog('info', `Uploading permanent voice profile "${speakerName}" to Supabase...`);
const fileName = `voice_${Date.now()}_${speakerId}.wav`;
// 1. Upload to Supabase Storage
const { data: storageData, error: storageError } = await supabase
.storage
.from('voice_samples')
.upload(fileName, fileBlob);
if (storageError) throw storageError;
// Get public URL
const { data: publicUrlData } = supabase
.storage
.from('voice_samples')
.getPublicUrl(fileName);
const publicUrl = publicUrlData.publicUrl;
// 2. Insert into Supabase Database
const { data: dbData, error: dbError } = await supabase
.from('voice_profiles')
.insert([{ name: speakerName, audio_url: publicUrl }])
.select();
if (dbError) throw dbError;
// 3. Update UI state
setSpeakers(prev => prev.map(s => s.id === speakerId ? {
...s,
voicePath: publicUrl,
previewUrl: publicUrl
} : s));
addLog('success', `Voice signature for "${speakerName}" saved to Supabase successfully!`);
// Refresh the saved voices list
fetchSavedVoices();
} catch(e) {
addLog('warning', `Supabase upload failed (${e.message}). Falling back to local backend storage...`);
try {
const formData = new FormData();
const fileToUpload = fileBlob instanceof File
? fileBlob
: new File([fileBlob], `voice_${Date.now()}_${speakerId}.wav`, { type: 'audio/wav' });
formData.append('file', fileToUpload);
formData.append('speaker_name', speakerName);
const res = await fetch('/api/upload-voice', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(`Backend returned status ${res.status}`);
const data = await res.json();
if (data.status === 'success') {
const localUrl = '/' + data.voice_path;
setSpeakers(prev => prev.map(s => s.id === speakerId ? {
...s,
voicePath: localUrl,
previewUrl: localUrl
} : s));
addLog('success', `Voice signature for "${speakerName}" saved locally on server!`);
// Instantly add this local voice to the savedVoices dropdown selection
setSavedVoices(prev => {
const exists = prev.some(v => v.audio_url === localUrl);
if (exists) return prev;
return [...prev, {
id: data.filename,
name: speakerName,
audio_url: localUrl
}];
});
} else {
throw new Error(data.message || 'Local upload failed');
}
} catch (localErr) {
addLog('error', `Voice signature local upload fallback failed: ${localErr.message}`);
}
}
};
// Toggle AI Model Mode on Backend
const handleToggleModelMode = async () => {
const enable = !modelStatus.useRealModel;
setTogglingModel(true);
addLog('info', `Requesting engine toggle: ${enable ? 'Enabling AI Model' : 'Enabling Demo Synthesizer'}`);
try {
const res = await fetch(`/api/toggle-model?enable=${enable}`, { method: 'POST' });
const data = await res.json();
setModelStatus(prev => ({
...prev,
useRealModel: data.use_real_model,
loading: enable, // starts load
loaded: false
}));
setTimeout(checkServerStatus, 1500);
} catch (e) {
addLog('error', 'Toggle engine request failed.');
} finally {
setTogglingModel(false);
}
};
// Presets loaders
const loadPreset = (type) => {
if (type === 'podcast') {
setScript(`Speaker 0: Welcome back to the Vibe Voice podcast segment. Today we are cloning human expressions.
Speaker 1: That is right! The 0.5B model manages this dialogue transition on a local CPU extremely fast.
Speaker 0: Incredible! It sounds like two real people sitting in this glassmorphic studio together.`);
addLog('info', 'Loaded template: [Podcast Dialogue Interview]');
} else if (type === 'assistant') {
setScript("Speaker 0: Hello, I am your personalized voice assistant. How can I help you customize your Dine Direct orders today?");
addLog('info', 'Loaded template: [DineDirect AI Support Agent]');
} else if (type === 'news') {
setScript("Speaker 1: Breaking news from Microsoft Research. The Vibe Voice speech synthesis models are now live on local devices.");
addLog('info', 'Loaded template: [News Bulletins Broadcast]');
}
};
if (currentView === 'landing') {
return <LandingPage onTryDemo={() => setCurrentView('studio')} />;
}
return (
<div className="relative min-h-screen bg-[#07090e] text-[#f0f3fa] font-sans selection:bg-[#00f2fe]/20 select-none pb-12 overflow-x-hidden">
{/* Background Floating Ambient Blobs */}
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
<div className="absolute w-[600px] h-[600px] rounded-full top-[-200px] left-[-150px] bg-gradient-to-br from-[#00f2fe]/15 to-transparent blur-[120px] animate-float-glow-1"></div>
<div className="absolute w-[700px] h-[700px] rounded-full bottom-[-250px] right-[-150px] bg-gradient-to-br from-[#a259ff]/15 to-transparent blur-[120px] animate-float-glow-2"></div>
</div>
<div className="relative z-10 max-w-[1300px] mx-auto px-4 pt-6 flex flex-col min-h-screen">
{/* Header Widget */}
<header className="flex flex-col md:flex-row justify-between items-center bg-glass p-4 rounded-2xl border border-white/8 backdrop-blur-xl mb-6 shadow-2xl gap-4">
<div className="flex items-center gap-3 cursor-pointer select-none" onClick={() => setCurrentView('landing')}>
<Sparkles className="w-8 h-8 text-neon-cyan drop-shadow-[0_0_10px_rgba(0,242,254,0.4)]" />
<div>
<h1 className="text-xl font-bold tracking-tight">
VibeVoice<span className="bg-gradient-to-r from-[#00f2fe] to-[#a259ff] bg-clip-text text-transparent">Studio</span>
</h1>
<p className="text-[10px] text-[#8e9bb5] uppercase tracking-wider">Microsoft Frontier Speech Synthesis</p>
</div>
</div>
<div className="flex items-center gap-4">
{/* Engine Status Panel */}
<div className="flex items-center gap-3 bg-[#00f2fe]/5 px-4 py-2 rounded-xl border border-[#00f2fe]/20 shadow-[0_0_12px_rgba(0,242,254,0.06)]">
<span className={`w-2 h-2 rounded-full ${
modelStatus.loading
? 'bg-[#ffb703] shadow-[0_0_8px_#ffb703] animate-pulse'
: modelStatus.loaded
? 'bg-[#00e673] shadow-[0_0_8px_#00e673]'
: 'bg-[#ff5c8d] shadow-[0_0_8px_#ff5c8d]'
}`}></span>
<span className="text-xs text-[#8e9bb5]">
VibeVoice Engine: <strong className="text-[#f0f3fa]">
{modelStatus.loading
? 'Loading AI...'
: 'VibeVoice-1.5 AI'}
</strong>
</span>
</div>
{/* Connection badge */}
<div className={`flex items-center gap-2 text-xs font-semibold px-4 py-2 rounded-xl border ${
wsConnected
? 'text-[#00f2fe] bg-[#00f2fe]/6 border-[#00f2fe]/20 shadow-[0_0_8px_rgba(0,242,254,0.1)]'
: 'text-[#8e9bb5] bg-white/4 border-white/5'
}`}>
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse' : ''}`} />
<span>{wsConnected ? 'Connected' : 'Offline'}</span>
</div>
<button
onClick={() => setCurrentView('landing')}
className="flex items-center gap-1.5 px-4 py-2 text-xs font-bold bg-white/4 border border-white/5 hover:border-white/12 rounded-xl hover:bg-white/8 transition duration-200 cursor-pointer active:scale-95 text-white"
>
Back to Home
</button>
</div>
</header>
{/* Dashboard Grid Grid */}
<main className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6 flex-1">
{/* Left Column: Voice Cloning Controls */}
<section className="bg-glass p-6 rounded-3xl flex flex-col gap-5">
<div>
<div className="flex items-center gap-2 mb-1">
<Settings className="w-5 h-5 text-neon-cyan" />
<h2 className="text-lg font-semibold">Voice Cloning Center</h2>
</div>
<p className="text-xs text-[#8e9bb5] leading-relaxed">Clone any voice in seconds. Record or upload a 10-30s audio sample to create a speaker profile prompt.</p>
</div>
{/* Speaker Slots */}
{speakers.map((spk, idx) => {
const colorClasses = spk.id === 0
? 'bg-[#00f2fe]/6 border-[#00f2fe]/20 text-[#00f2fe]'
: spk.id === 1
? 'bg-[#a259ff]/6 border-[#a259ff]/20 text-[#a259ff]'
: spk.color === 'emerald' ? 'bg-[#00e673]/6 border-[#00e673]/20 text-[#00e673]'
: spk.color === 'amber' ? 'bg-[#ffb703]/6 border-[#ffb703]/20 text-[#ffb703]'
: spk.color === 'rose' ? 'bg-[#ff5c8d]/6 border-[#ff5c8d]/20 text-[#ff5c8d]'
: 'bg-[#00f2fe]/6 border-[#00f2fe]/20 text-[#00f2fe]';
return (
<div key={spk.id} className="bg-glass-card p-4 rounded-2xl flex flex-col gap-3 transition duration-300 relative group">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-[#8e9bb5]">Speaker {spk.id}</span>
{spk.id > 0 && (
<button
onClick={() => removeSpeaker(spk.id)}
className="opacity-0 group-hover:opacity-100 text-[#ff5c8d]/60 hover:text-[#ff5c8d] p-0.5 transition duration-200 cursor-pointer"
title="Delete speaker slot"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<span className={`text-[10px] px-2 py-0.5 rounded-full uppercase font-bold border ${colorClasses}`}>{spk.role}</span>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-[#8e9bb5] uppercase tracking-wide">Speaker Name</label>
<input
type="text"
value={spk.name}
onChange={(e) => {
const val = e.target.value;
setSpeakers(prev => prev.map(s => s.id === spk.id ? { ...s, name: val } : s));
}}
className="bg-black/20 border border-white/6 rounded-lg px-3 py-1.5 text-xs text-[#f0f3fa] focus:border-[#00f2fe] focus:shadow-[0_0_8px_rgba(0,242,254,0.15)] outline-none"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-[#8e9bb5] uppercase tracking-wide">Or Select Saved Voice</label>
<div className="flex gap-2">
<select
value={spk.voicePath ? savedVoices.find(v => v.audio_url === spk.voicePath)?.id || '' : ''}
onChange={(e) => {
const val = e.target.value;
const selectedVoice = savedVoices.find(v => String(v.id) === String(val));
if (selectedVoice) {
setSpeakers(prev => prev.map(s => s.id === spk.id ? {
...s,
name: selectedVoice.name,
voicePath: selectedVoice.audio_url,
previewUrl: selectedVoice.audio_url
} : s));
addLog('info', `Selected saved voice "${selectedVoice.name}" from Supabase.`);
} else {
// Clear selection
setSpeakers(prev => prev.map(s => s.id === spk.id ? {
...s,
voicePath: null,
previewUrl: null
} : s));
}
}}
className="flex-1 bg-black/20 border border-white/6 rounded-lg px-3 py-1.5 text-xs text-[#f0f3fa] focus:border-[#00f2fe] focus:shadow-[0_0_8px_rgba(0,242,254,0.15)] outline-none"
>
<option value="">-- Choose a Voice --</option>
{savedVoices.map(v => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
{spk.voicePath && (() => {
const matchedVoice = savedVoices.find(v => v.audio_url === spk.voicePath);
if (matchedVoice) {
return (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => openRenameVoiceModal(matchedVoice.id, matchedVoice.name, matchedVoice.audio_url)}
className="p-1.5 rounded-lg border border-white/6 hover:border-[#00f2fe]/40 text-[#8e9bb5] hover:text-[#00f2fe] bg-white/2 hover:bg-white/5 active:scale-95 transition cursor-pointer"
title="Rename selected voice signature"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => openDeleteVoiceModal(matchedVoice.id, matchedVoice.name, matchedVoice.audio_url)}
className="p-1.5 rounded-lg border border-white/6 hover:border-[#ff5c8d]/40 text-[#8e9bb5] hover:text-[#ff5c8d] bg-white/2 hover:bg-[#ff5c8d]/10 active:scale-95 transition cursor-pointer"
title="Delete selected voice signature"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
}
return null;
})()}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => toggleRecording(spk.id)}
className={`flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-lg border text-xs cursor-pointer active:scale-95 transition duration-200 ${
spk.isRecording
? 'bg-[#ff5c8d]/20 border-[#ff5c8d] text-[#ff5c8d] animate-pulse'
: 'bg-transparent border-white/10 hover:border-[#00f2fe] hover:text-[#00f2fe] text-white'
}`}
>
{spk.isRecording ? <Square className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
{spk.isRecording ? 'Stop Mic' : 'Record Mic'}
</button>
<label className="flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-lg border border-white/10 hover:border-[#00f2fe] hover:text-[#00f2fe] text-white text-xs cursor-pointer active:scale-95 transition duration-200">
<Upload className="w-3.5 h-3.5" />
Upload WAV
<input
type="file"
accept="audio/wav,audio/mp3"
onChange={(e) => handleFileUpload(e, spk.id)}
className="hidden"
/>
</label>
</div>
{spk.previewUrl && (
<div className="flex flex-col gap-1.5 pt-2 border-t border-dashed border-white/5">
<span className="text-[10px] text-[#00e673] font-semibold flex items-center gap-1">
<CheckCircle className="w-3 h-3" /> Voice Cloned Successfully
</span>
{/* Premium Custom Reference Audio Player */}
<div className="flex items-center gap-3 bg-white/4 p-2 rounded-xl border border-white/5 mt-1">
<button
onClick={() => playPreview(spk.id, spk.previewUrl)}
className="w-8 h-8 rounded-full bg-[#00f2fe]/20 border border-[#00f2fe]/30 flex items-center justify-center text-[#00f2fe] hover:bg-[#00f2fe] hover:text-black transition duration-200 active:scale-90 cursor-pointer"
>
{playingPreviewId === spk.id ? (
<Square className="w-3.5 h-3.5 fill-current" />
) : (
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
)}
</button>
<div className="flex-1">
<span className="text-[10px] text-white font-semibold flex items-center gap-1 select-text">
<Music className="w-3 h-3 text-[#00f2fe]" /> reference_{spk.name}.wav
</span>
<span className="text-[9px] text-[#8e9bb5]">Ready for zero-shot synthesis</span>
</div>
</div>
</div>
)}
</div>
);
})}
{/* Add Speaker Slot Trigger */}
<button
onClick={addSpeaker}
className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-white/4 hover:bg-[#00f2fe]/10 border border-white/5 hover:border-[#00f2fe]/30 text-[#8e9bb5] hover:text-[#00f2fe] text-xs font-bold rounded-xl transition duration-200 cursor-pointer active:scale-95"
>
<Plus className="w-4 h-4" />
Add Speaker Slot
</button>
{/* Presets */}
<div className="mt-2 pt-4 border-t border-white/5 flex flex-col gap-2.5">
<h3 className="text-xs font-semibold text-[#8e9bb5]">Dialogue Templates</h3>
<div className="flex flex-col gap-2">
<button
onClick={() => loadPreset('podcast')}
className="flex items-center gap-2.5 w-full py-2.5 px-3 bg-white/2 hover:bg-white/5 border border-white/5 hover:border-white/10 text-[#8e9bb5] hover:text-[#f0f3fa] text-xs font-medium rounded-xl transition duration-200 justify-start cursor-pointer"
>
<Podcast className="w-4 h-4 text-[#a259ff]" />
Podcast Interview Preset
</button>
<button
onClick={() => loadPreset('assistant')}
className="flex items-center gap-2.5 w-full py-2.5 px-3 bg-white/2 hover:bg-white/5 border border-white/5 hover:border-white/10 text-[#8e9bb5] hover:text-[#f0f3fa] text-xs font-medium rounded-xl transition duration-200 justify-start cursor-pointer"
>
<Headset className="w-4 h-4 text-[#00f2fe]" />
DineDirect Voice Agent
</button>
<button
onClick={() => loadPreset('news')}
className="flex items-center gap-2.5 w-full py-2.5 px-3 bg-white/2 hover:bg-white/5 border border-white/5 hover:border-white/10 text-[#8e9bb5] hover:text-[#f0f3fa] text-xs font-medium rounded-xl transition duration-200 justify-start cursor-pointer"
>
<Newspaper className="w-4 h-4 text-[#ff5c8d]" />
News Broadcast Preset
</button>
</div>
</div>
{/* 🎙️ Voice Vault (Saved Neural Profiles) */}
<div className="mt-4 pt-4 border-t border-white/5 flex flex-col gap-2.5">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-[#8e9bb5] uppercase tracking-wider">Voice Vault ({savedVoices.length})</h3>
<span className="text-[10px] text-[#00f2fe] bg-[#00f2fe]/10 px-2 py-0.5 rounded-full font-semibold border border-[#00f2fe]/20">Storage</span>
</div>
<div className="flex flex-col gap-2 max-h-[220px] overflow-y-auto pr-1">
{savedVoices.length === 0 ? (
<p className="text-[11px] text-[#8e9bb5] italic py-2 text-center">No voice profiles saved yet.</p>
) : (
savedVoices.map(v => (
<div key={v.id} className="flex items-center justify-between bg-white/2 hover:bg-white/4 border border-white/5 hover:border-white/10 p-2 rounded-xl transition duration-150 group/vault">
<div className="flex items-center gap-2 min-w-0">
<button
onClick={() => playPreview(v.id, v.audio_url)}
className="w-6 h-6 rounded-full bg-white/5 hover:bg-[#00f2fe]/20 hover:text-[#00f2fe] flex items-center justify-center text-[#8e9bb5] transition active:scale-90 cursor-pointer shrink-0"
title="Preview Voice"
>
{playingPreviewId === v.id ? (
<Square className="w-2.5 h-2.5 fill-current animate-pulse text-[#00f2fe]" />
) : (
<Play className="w-2.5 h-2.5 fill-current ml-0.5" />
)}
</button>
<span className="text-xs font-semibold text-white truncate max-w-[120px]" title={v.name}>
{v.name}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => openRenameVoiceModal(v.id, v.name, v.audio_url)}
className="p-1 rounded hover:bg-white/5 text-[#8e9bb5] hover:text-[#00f2fe] transition active:scale-95 cursor-pointer"
title="Rename voice profile"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => openDeleteVoiceModal(v.id, v.name, v.audio_url)}
className="p-1 rounded hover:bg-[#ff5c8d]/5 text-[#8e9bb5] hover:text-[#ff5c8d] transition active:scale-95 cursor-pointer"
title="Delete voice profile"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
</div>
</section>
{/* Right Column: Execution Panels */}
<section className="flex flex-col gap-6">
{/* Visualizer Container */}
<div className="bg-glass p-6 rounded-3xl flex flex-col justify-center items-center gap-4 min-h-[250px] bg-gradient-to-b from-[#101830]/30 to-transparent">
<div className="flex flex-col md:flex-row items-center gap-8 w-full max-w-[500px] justify-center">
{/* Visual pulsating holographic Orb */}
<div className="relative w-[90px] h-[90px] flex items-center justify-center">
<div className={`w-[60px] h-[60px] rounded-full z-10 transition-all duration-500 ${
agentState.status === 'idle' && 'orb-state-idle'
} ${
agentState.status === 'thinking' && 'orb-state-thinking'
} ${
agentState.status === 'speaking' && 'orb-state-speaking'
} ${
agentState.status === 'listening' && 'orb-state-listening'
}`}></div>
{/* Glowing backing shadows */}
<div className={`absolute -inset-[15px] rounded-full blur-[25px] opacity-50 z-0 transition-all duration-500 ${
agentState.status === 'idle' && 'bg-[#00f2fe]'
} ${
agentState.status === 'thinking' && 'bg-[#a259ff] scale-110 animate-pulse'
} ${
agentState.status === 'speaking' && 'bg-[#ff5c8d]'
} ${
agentState.status === 'listening' && 'bg-[#00e673]'
}`}></div>
{/* Expansion speech rings */}
{agentState.status === 'speaking' && (
<>
<div className="absolute inset-0 rounded-full border-2 border-[#ff5c8d] shadow-[0_0_10px_rgba(255,92,141,0.35)] animate-[waveExpand_1.2s_infinite_cubic-bezier(0.1,0.8,0.3,1)] opacity-100 z-10"></div>
<div className="absolute inset-0 rounded-full border-2 border-[#ff5c8d] shadow-[0_0_10px_rgba(255,92,141,0.35)] animate-[waveExpand_1.2s_infinite_cubic-bezier(0.1,0.8,0.3,1)] opacity-100 z-10 [animation-delay:0.6s]"></div>
</>
)}
</div>
<div className="flex flex-col text-center md:text-left gap-1">
<span className="font-orbitron font-extrabold text-lg tracking-wider text-shadow-md text-[#f0f3fa] uppercase">
{agentState.title}
</span>
<p className="text-xs text-[#8e9bb5]">{agentState.sub}</p>
</div>
</div>
{/* Waveform Canvas */}
<div className="w-full flex justify-center border-t border-white/5 pt-4 mt-2">
<canvas ref={canvasRef} width="600" height="90" className="w-full max-w-[600px] h-[75px] rounded-xl"></canvas>
</div>
</div>
{/* Script Studio Box */}
<div className="bg-glass p-6 rounded-3xl flex flex-col gap-4">
<div className="flex justify-between items-center border-b border-white/5 pb-4">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[#a259ff]" />
<h2 className="text-md font-semibold">Podcast Script Studio</h2>
</div>
{/* Real-time Streaming Check */}
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={streamMode}
onChange={(e) => setStreamMode(e.target.checked)}
className="hidden"
/>
<div className={`w-[42px] h-[22px] rounded-full relative border transition duration-300 ${
streamMode ? 'bg-[#00f2fe]/20 border-[#00f2fe]' : 'bg-white/8 border-white/10'
}`}>
<div className={`w-4 h-4 rounded-full absolute top-[2px] left-[2px] transition-transform duration-300 ${
streamMode ? 'transform translate-x-[20px] bg-[#00f2fe] shadow-[0_0_6px_#00f2fe]' : 'bg-[#f0f3fa]'
}`}></div>
</div>
<span className={`text-xs font-semibold ${streamMode ? 'text-[#00f2fe]' : 'text-[#8e9bb5]'}`}>
Real-time Streaming
</span>
</label>
</div>
{/* Textarea script fields */}
<div className="bg-black/25 border border-white/8 rounded-xl overflow-hidden">
<textarea
value={script}
onChange={(e) => setScript(e.target.value)}
placeholder="Format: Speaker ID: Speech dialog content..."
className="w-full h-[180px] bg-transparent resize-none p-4 text-[#f0f3fa] font-mono text-sm leading-relaxed outline-none border-none"
/>
</div>
{/* Action Buttons */}
<div className="flex gap-4">
<button
onClick={handleSynthesize}
disabled={isAudioPlayingState}
className="flex-1 flex items-center justify-center gap-2 py-3 px-6 bg-gradient-to-r from-[#00f2fe] to-[#a259ff] hover:shadow-[0_0_20px_rgba(0,242,254,0.4)] text-black font-bold rounded-xl transition duration-200 active:scale-98 cursor-pointer disabled:opacity-40"
>
<PlayCircle className="w-5 h-5" />
Synthesize Dialogue
</button>
<button
onClick={handleStopPlayback}
disabled={!isAudioPlayingState}
className="flex-1 flex items-center justify-center gap-2 py-3 px-6 bg-white/5 border border-white/8 hover:bg-white/10 text-white font-medium rounded-xl transition duration-200 active:scale-98 cursor-pointer disabled:opacity-40"
>
<Square className="w-4 h-4" />
Stop Playback
</button>
</div>
</div>
{/* Master Export Hub */}
{compiledAudioUrl && (
<div className="bg-glass p-6 rounded-3xl border border-[#00f2fe]/20 bg-gradient-to-r from-[#00f2fe]/4 to-[#a259ff]/4 shadow-[0_0_20px_rgba(0,242,254,0.08)] flex flex-col gap-4 transition duration-300">
<div className="flex justify-between items-center border-b border-white/5 pb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-neon-cyan animate-pulse" />
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider">Podcast Master Output</h3>
<p className="text-[9px] text-[#8e9bb5] uppercase tracking-wide">Ready for download & broadcast</p>
</div>
</div>
<span className="text-[10px] px-2.5 py-0.5 rounded-full bg-[#00e673]/10 border border-[#00e673]/20 text-[#00e673] font-bold">
{compiledAudioMode === 'stream' ? 'Stream Mastered' : 'Engine Compiled'}
</span>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4 justify-between bg-black/20 p-4 rounded-2xl border border-white/5 w-full">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-[#00f2fe] to-[#a259ff] flex items-center justify-center text-black font-bold">
<Volume2 className="w-5 h-5" />
</div>
<div className="text-left">
<h4 className="text-xs font-semibold text-white">VibeVoice_Podcast_Session.wav</h4>
<p className="text-[10px] text-[#8e9bb5]">
{compiledAudioMode === 'stream' ? 'Real-time WebSocket Stream' : 'REST Compiled Dialogue Track'}
</p>
</div>
</div>
<a
href={compiledAudioUrl}
download="VibeVoice_Podcast_Session.wav"
className="w-full sm:w-auto flex items-center justify-center gap-2 py-2.5 px-5 bg-gradient-to-r from-[#00f2fe] to-[#a259ff] hover:shadow-[0_0_15px_rgba(0,242,254,0.3)] text-black font-bold rounded-xl text-xs transition duration-200 cursor-pointer active:scale-95"
>
<Upload className="w-4 h-4 rotate-180" />
Export WAV File
</a>
</div>
</div>
)}
{/* Custom timeline bar player */}
{showToolbar && (
<div className="bg-glass p-4 rounded-3xl border border-[#00f2fe]/20 shadow-[0_0_20px_rgba(0,242,254,0.05)]">
<div className="flex flex-col md:flex-row items-center gap-4 w-full">
<button onClick={handleStopPlayback} className="w-9 h-9 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white hover:bg-[#00f2fe] hover:text-black hover:shadow-[0_0_10px_rgba(0,242,254,0.35)] transition cursor-pointer">
<Square className="w-4 h-4" />
</button>
<div className="flex items-center gap-3 flex-1 w-full">
<span className="font-mono text-[10px] text-[#8e9bb5]">{formatTime(timeline.elapsed)}</span>
<div className="relative flex-1 h-1.5 bg-white/8 rounded-full overflow-hidden">
<div className="absolute left-0 top-0 bottom-0 bg-gradient-to-r from-[#00f2fe] to-[#a259ff] rounded-full" style={{ width: `${(timeline.elapsed / timeline.total) * 100}%` }}></div>
</div>
<span className="font-mono text-[10px] text-[#8e9bb5]">{formatTime(timeline.total)}</span>
</div>
<div className="flex items-center gap-3 w-[150px]">
{volume === 0 ? <VolumeX className="w-4 h-4 text-[#8e9bb5]" /> : volume < 50 ? <Volume1 className="w-4 h-4 text-[#8e9bb5]" /> : <Volume2 className="w-4 h-4 text-[#8e9bb5]" />}
<input
type="range"
min="0"
max="100"
value={volume}
onChange={(e) => setVolume(parseInt(e.target.value))}
className="w-full accent-[#00f2fe] h-1 bg-white/10 rounded-lg outline-none cursor-pointer"
/>
</div>
<select
value={speed}
onChange={(e) => setSpeed(e.target.value)}
className="bg-white/5 border border-white/8 rounded-lg px-2 py-1 text-xs text-white outline-none cursor-pointer"
>
<option value="0.75" className="bg-[#07090e]">0.75x</option>
<option value="1.0" className="bg-[#07090e]">1.0x</option>
<option value="1.25" className="bg-[#07090e]">1.25x</option>
<option value="1.5" className="bg-[#07090e]">1.5x</option>
</select>
</div>
</div>
)}
{/* Podcast History & Archive section */}
<div className="bg-glass p-6 rounded-3xl border border-white/4 bg-black/40 flex flex-col gap-4">
<div className="flex justify-between items-center border-b border-white/5 pb-2 text-xs font-semibold text-[#8e9bb5]">
<div className="flex items-center gap-2">
<Podcast className="w-4 h-4 text-[#a259ff] animate-pulse" />
<span className="text-white font-bold uppercase tracking-wider text-[10px]">Supabase Broadcast Archive</span>
</div>
<span className="text-[10px] bg-[#a259ff]/10 px-2 py-0.5 rounded-full text-neon-purple font-mono border border-[#a259ff]/20">
{podcastHistory.length} Saved
</span>
</div>
<div className="max-h-[220px] overflow-y-auto pr-1 flex flex-col gap-2.5 custom-scrollbar">
{podcastHistory.length === 0 ? (
<div className="py-8 text-center flex flex-col items-center justify-center gap-2 border border-dashed border-white/5 rounded-2xl bg-white/2">
<Music className="w-8 h-8 text-[#8e9bb5]/40" />
<div>
<p className="text-xs text-white font-medium">No podcasts archived yet</p>
<p className="text-[10px] text-[#8e9bb5] mt-0.5 max-w-[200px]">Generate speech scripts to automatically archive them permanently!</p>
</div>
</div>
) : (
podcastHistory.map((podcast) => (
<div
key={podcast.id}
className="p-3 bg-white/3 border border-white/5 rounded-xl hover:border-white/10 hover:bg-white/5 transition duration-200 flex items-center justify-between gap-3 group/item relative overflow-hidden"
>
{isAudioPlayingState && compiledAudioUrl === podcast.audio_url && (
<div className="absolute inset-0 bg-gradient-to-r from-[#a259ff]/10 to-transparent pointer-events-none" />
)}
<div className="flex items-center gap-3 min-w-0">
<button
onClick={() => playHistoryPodcast(podcast)}
className={`w-8 h-8 rounded-full flex items-center justify-center transition active:scale-95 cursor-pointer shrink-0 ${
isAudioPlayingState && compiledAudioUrl === podcast.audio_url
? 'bg-[#ff5c8d] text-white animate-pulse'
: 'bg-white/5 hover:bg-white/10 text-white'
}`}
>
{isAudioPlayingState && compiledAudioUrl === podcast.audio_url ? (
<Square className="w-3.5 h-3.5 fill-current" />
) : (
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
)}
</button>
<div className="min-w-0 flex flex-col gap-0.5">
<span className="text-xs font-bold text-white truncate max-w-[240px] block group-hover/item:text-[#00f2fe] transition duration-150">
{podcast.title}
</span>
<span className="text-[10px] text-[#8e9bb5] truncate block max-w-[240px] leading-relaxed cursor-help" title={podcast.text_script}>
Script: "{podcast.text_script}"
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<a
href={podcast.audio_url}
download
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-lg border border-white/6 hover:border-[#00f2fe]/40 text-[#8e9bb5] hover:text-white hover:bg-white/4 active:scale-95 transition"
title="Open audio in new tab / download"
>
<Upload className="w-3.5 h-3.5 rotate-180" />
</a>
<button
onClick={() => openRenamePodcastModal(podcast.id, podcast.title)}
className="p-1.5 rounded-lg border border-white/6 hover:border-[#00f2fe]/40 text-[#8e9bb5] hover:text-white hover:bg-white/4 active:scale-95 transition cursor-pointer"
title="Rename podcast title"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => openDeletePodcastModal(podcast.id, podcast.title, podcast.audio_url)}
className="p-1.5 rounded-lg border border-white/6 hover:border-[#ff5c8d]/40 text-[#8e9bb5] hover:text-[#ff5c8d] hover:bg-[#ff5c8d]/5 active:scale-95 transition cursor-pointer"
title="Delete podcast from Supabase archive"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
</div>
{/* Diagnostic Terminal card */}
<div className="bg-glass p-6 rounded-3xl border border-white/4 bg-black/40">
<div className="flex justify-between items-center border-b border-white/5 pb-2 mb-3 text-xs font-semibold text-[#8e9bb5]">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-[#00f2fe]" />
<span>Agent HUD Console Logs</span>
</div>
<button
onClick={() => setLogs([{ type: 'system', text: 'Console buffer cleared.', time: new Date().toLocaleTimeString() }])}
className="px-2 py-0.5 rounded border border-white/10 hover:border-white/20 active:scale-95 text-[10px] text-white hover:bg-white/2 cursor-pointer transition duration-150"
>
Clear Buffer
</button>
</div>
<div id="consoleBody" className="h-[120px] overflow-y-auto font-mono text-[10.5px] leading-relaxed flex flex-col gap-1.5 pr-2 select-text">
{logs.map((logItem, idx) => (
<div key={idx} className={`px-2 py-0.5 rounded ${
logItem.type === 'system' && 'text-[#8e9bb5] bg-white/2'
} ${
logItem.type === 'info' && 'text-[#00f2fe] bg-[#00f2fe]/2'
} ${
logItem.type === 'success' && 'text-[#00e673] bg-[#00e673]/2'
} ${
logItem.type === 'error' && 'text-[#ff5c8d] bg-[#ff5c8d]/2'
} ${
logItem.type === 'word' && 'text-white font-medium drop-shadow-[0_0_4px_rgba(255,255,255,0.25)]'
}`}>
[{logItem.time}] {logItem.text}
</div>
))}
</div>
</div>
</section>
</main>
</div>
{/* 🎙️ Premium Glassmorphic Custom Voice Profile Naming Modal */}
{namingModal.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md animate-fade-in">
<div className="w-[90%] max-w-[420px] bg-gradient-to-b from-white/10 to-white/3 border border-white/12 backdrop-blur-2xl p-6 rounded-3xl shadow-2xl flex flex-col gap-4 animate-scale-up">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#00f2fe]/10 flex items-center justify-center border border-[#00f2fe]/20">
<Sparkles className="w-5 h-5 text-neon-cyan animate-pulse" />
</div>
<div>
<h3 className="text-base font-bold text-white tracking-tight">Save Custom Voice Signature</h3>
<p className="text-[10px] text-[#8e9bb5] uppercase tracking-wider">Neural Studio Archivist</p>
</div>
</div>
<p className="text-xs text-[#8e9bb5] leading-relaxed">
Give your new voice profile signature a descriptive name (e.g. <em>John's Deep Voice</em>, <em>Excited Host</em>) to save it permanently in your custom voice selection.
</p>
<div className="flex flex-col gap-1.5 mt-2">
<label className="text-[10px] text-[#8e9bb5] uppercase tracking-wide font-semibold">Voice Signature Name</label>
<input
type="text"
autoFocus
placeholder="Enter voice signature name..."
value={voiceNameInput}
onChange={(e) => setVoiceNameInput(e.target.value)}
className="bg-black/40 border border-white/10 focus:border-[#00f2fe] rounded-xl px-4 py-2.5 text-xs text-white outline-none focus:shadow-[0_0_12px_rgba(0,242,254,0.2)] transition duration-200"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const name = voiceNameInput.trim() || namingModal.defaultName;
uploadVoiceProfile(namingModal.fileBlob, namingModal.speakerId, name);
setNamingModal({ isOpen: false, fileBlob: null, speakerId: null, defaultName: '' });
}
}}
/>
</div>
<div className="flex justify-end gap-3 mt-3">
<button
onClick={() => setNamingModal({ isOpen: false, fileBlob: null, speakerId: null, defaultName: '' })}
className="px-4 py-2 rounded-xl border border-white/6 hover:bg-white/4 active:scale-95 text-xs font-semibold text-[#8e9bb5] hover:text-white cursor-pointer transition duration-150"
>
Cancel
</button>
<button
onClick={() => {
const name = voiceNameInput.trim() || namingModal.defaultName;
uploadVoiceProfile(namingModal.fileBlob, namingModal.speakerId, name);
setNamingModal({ isOpen: false, fileBlob: null, speakerId: null, defaultName: '' });
}}
className="px-5 py-2 rounded-xl bg-gradient-to-r from-[#00f2fe] to-[#a259ff] text-xs font-bold text-black hover:shadow-[0_0_15px_rgba(0,242,254,0.3)] active:scale-95 cursor-pointer transition duration-200"
>
Save to Studio
</button>
</div>
</div>
</div>
)}
{/* 🔮 Premium custom action (Rename/Delete) modal */}
{actionModal.isOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-md animate-fade-in">
<div className="w-[90%] max-w-[420px] bg-gradient-to-b from-white/10 to-white/3 border border-white/12 backdrop-blur-2xl p-6 rounded-3xl shadow-2xl flex flex-col gap-4 animate-scale-up">
<div className="flex justify-between items-center border-b border-white/6 pb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-neon-cyan" />
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
{actionModal.type.startsWith('rename') ? 'Rename Profile' : 'Confirm Action'}
</h3>
</div>
<button
onClick={() => setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' })}
className="text-[#8e9bb5] hover:text-white transition cursor-pointer text-sm"
>
</button>
</div>
{actionModal.type.startsWith('rename') ? (
<div className="flex flex-col gap-3">
<p className="text-xs text-[#8e9bb5] leading-relaxed">
Enter a new name for your {actionModal.type === 'rename_voice' ? 'voice profile signature' : 'podcast archive title'}:
</p>
<input
type="text"
autoFocus
value={actionModal.inputValue}
onChange={(e) => setActionModal(prev => ({ ...prev, inputValue: e.target.value }))}
className="bg-black/40 border border-white/10 focus:border-[#00f2fe] rounded-xl px-4 py-2.5 text-xs text-white outline-none focus:shadow-[0_0_12px_rgba(0,242,254,0.2)] transition duration-200"
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (actionModal.type === 'rename_voice') {
executeRenameVoice(actionModal.id, actionModal.inputValue, actionModal.audioUrl);
} else {
executeRenamePodcast(actionModal.id, actionModal.inputValue);
}
setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' });
}
}}
/>
<div className="flex items-center gap-2.5 mt-2">
<button
onClick={() => setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' })}
className="flex-1 py-2.5 text-xs font-bold bg-white/4 border border-white/5 hover:border-white/12 rounded-xl text-white transition active:scale-95 cursor-pointer"
>
Cancel
</button>
<button
onClick={() => {
if (actionModal.type === 'rename_voice') {
executeRenameVoice(actionModal.id, actionModal.inputValue, actionModal.audioUrl);
} else {
executeRenamePodcast(actionModal.id, actionModal.inputValue);
}
setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' });
}}
className="flex-1 py-2.5 text-xs font-bold bg-gradient-to-r from-[#00f2fe] to-[#a259ff] hover:shadow-[0_0_12px_rgba(0,242,254,0.3)] text-black rounded-xl transition active:scale-95 cursor-pointer"
>
Save Changes
</button>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<p className="text-xs text-[#8e9bb5] leading-relaxed">
Are you sure you want to permanently delete the {actionModal.type === 'delete_voice' ? 'voice profile signature' : 'archived podcast'} <strong className="text-white">"{actionModal.profileName}"</strong>? This action cannot be undone.
</p>
<div className="flex items-center gap-2.5 mt-2">
<button
onClick={() => setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' })}
className="flex-1 py-2.5 text-xs font-bold bg-white/4 border border-white/5 hover:border-white/12 rounded-xl text-white transition active:scale-95 cursor-pointer"
>
Cancel
</button>
<button
onClick={() => {
if (actionModal.type === 'delete_voice') {
executeDeleteVoice(actionModal.id, actionModal.audioUrl, actionModal.profileName);
} else {
executeDeletePodcast(actionModal.id, actionModal.audioUrl, actionModal.profileName);
}
setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' });
}}
className="flex-1 py-2.5 text-xs font-bold bg-[#ff5c8d] hover:bg-[#ff5c8d]/90 hover:shadow-[0_0_12px_rgba(255,92,141,0.3)] text-white rounded-xl transition active:scale-95 cursor-pointer"
>
Delete Permanently
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
// ==========================================================================
// WAV Exporter Helpers
// ==========================================================================
function exportWav(chunks, sampleRate) {
let totalLength = 0;
for (const chunk of chunks) {
totalLength += chunk.length;
}
const result = new Float32Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
const buffer = new ArrayBuffer(44 + totalLength * 2);
const view = new DataView(buffer);
// RIFF identifier
writeString(view, 0, 'RIFF');
// File length
view.setUint32(4, 36 + totalLength * 2, true);
// RIFF type
writeString(view, 8, 'WAVE');
// Format chunk identifier
writeString(view, 12, 'fmt ');
// Format chunk length
view.setUint32(16, 16, true);
// Sample format (raw PCM)
view.setUint16(20, 1, true);
// Channel count (mono)
view.setUint16(22, 1, true);
// Sample rate
view.setUint32(24, sampleRate, true);
// Byte rate (sample rate * block align)
view.setUint32(28, sampleRate * 2, true);
// Block align (channel count * bytes per sample)
view.setUint16(32, 2, true);
// Bits per sample
view.setUint16(34, 16, true);
// Data chunk identifier
writeString(view, 36, 'data');
// Data chunk length
view.setUint32(40, totalLength * 2, true);
// Write PCM audio samples
let index = 44;
for (let i = 0; i < totalLength; i++) {
const s = Math.max(-1, Math.min(1, result[i]));
view.setInt16(index, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
index += 2;
}
return new Blob([view], { type: 'audio/wav' });
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
export default App;