| 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([]); |
|
|
| |
| 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); |
|
|
| |
| 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', |
| title: 'AGENT READY', |
| sub: 'Configure scripts and trigger generation below' |
| }); |
|
|
| |
| 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); |
|
|
| |
| 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' } |
| ]); |
|
|
| |
| const [compiledAudioUrl, setCompiledAudioUrl] = useState(null); |
| const [compiledAudioMode, setCompiledAudioMode] = useState(null); |
| const accumulatedSessionChunksRef = useRef([]); |
|
|
| |
| const [playingPreviewId, setPlayingPreviewId] = useState(null); |
| const previewSourceRef = useRef(null); |
|
|
| |
| const [namingModal, setNamingModal] = useState({ |
| isOpen: false, |
| fileBlob: null, |
| speakerId: null, |
| defaultName: '' |
| }); |
| const [voiceNameInput, setVoiceNameInput] = useState(''); |
|
|
| |
| const [actionModal, setActionModal] = useState({ |
| isOpen: false, |
| type: '', |
| id: null, |
| oldValue: '', |
| inputValue: '', |
| audioUrl: '', |
| profileName: '' |
| }); |
|
|
| |
| 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() } |
| ]); |
|
|
| |
| const audioCtxRef = useRef(null); |
| const analyserNodeRef = useRef(null); |
| const gainNodeRef = useRef(null); |
| const wsRef = useRef(null); |
| |
| |
| const playbackQueueRef = useRef([]); |
| const nextPlayTimeRef = useRef(0); |
| const isStreamingActiveRef = useRef(false); |
| const scheduledSourcesRef = useRef([]); |
| const doneReceivedRef = useRef(false); |
| |
| |
| const traditionalSourceRef = useRef(null); |
| const traditionalStartClockRef = useRef(0); |
| const traditionalDurationRef = useRef(0); |
|
|
| |
| const mediaRecordersRef = useRef([null, null]); |
| const audioChunksRef = useRef([[], []]); |
|
|
| |
| const canvasRef = useRef(null); |
|
|
| |
| |
| |
| const addLog = (type, text) => { |
| setLogs(prev => [ |
| ...prev, |
| { type, text, time: new Date().toLocaleTimeString() } |
| ]); |
| }; |
|
|
| |
| useEffect(() => { |
| const consoleBody = document.getElementById('consoleBody'); |
| if (consoleBody) { |
| consoleBody.scrollTop = consoleBody.scrollHeight; |
| } |
| }, [logs]); |
|
|
| |
| |
| |
| 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.`); |
| |
| |
| 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`; |
|
|
| |
| const { data: storageData, error: storageError } = await supabase |
| .storage |
| .from('voice_samples') |
| .upload(fileName, wavBlob); |
|
|
| if (storageError) throw storageError; |
|
|
| |
| const { data: publicUrlData } = supabase |
| .storage |
| .from('voice_samples') |
| .getPublicUrl(fileName); |
|
|
| const publicUrl = publicUrlData.publicUrl; |
|
|
| |
| 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...`); |
| |
| |
| 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); |
| } |
| }; |
|
|
| |
| 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 |
| }); |
| }; |
|
|
| |
|
|
| const executeRenamePodcast = async (podcastId, newTitle) => { |
| if (!newTitle || newTitle.trim() === '') return; |
| try { |
| addLog('info', `Renaming podcast to "${newTitle.trim()}"...`); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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()}"...`); |
| |
| |
| if (typeof voiceId === 'string' && voiceId.startsWith('local_')) { |
| setSavedVoices(prev => prev.map(v => v.id === voiceId ? { ...v, name: newName.trim() } : v)); |
| |
| |
| 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; |
| } |
|
|
| |
| 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!`); |
| |
| |
| setSavedVoices(prev => prev.map(v => v.id === voiceId ? { ...v, name: newName.trim() } : v)); |
| |
| |
| 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}"...`); |
| |
| |
| setSpeakers(prev => prev.map(s => s.voicePath === audioUrl ? { ...s, voicePath: null, previewUrl: null } : s)); |
|
|
| |
| 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; |
| } |
|
|
| |
| const { error: dbError } = await supabase |
| .from('voice_profiles') |
| .delete() |
| .eq('id', voiceId); |
|
|
| if (dbError) throw dbError; |
|
|
| |
| 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; |
| } |
| |
| |
| const { error: dbError } = await supabase |
| .from('podcast_history') |
| .delete() |
| .eq('id', id); |
| if (dbError) throw dbError; |
|
|
| |
| 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(); |
| |
| 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(); |
| |
| |
| |
| const interval = setInterval(() => { |
| checkServerStatus(); |
| }, 4000); |
|
|
| return () => clearInterval(interval); |
| }, []); |
|
|
| |
| 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; |
| |
| 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.'); |
| |
| |
| savePodcastToHistory(wavBlob, script, 'WebSocket Stream'); |
| } catch (err) { |
| addLog('error', `Failed to master session output: ${err.message}`); |
| } |
| } |
| |
| if (scheduledSourcesRef.current.length === 0 && playbackQueueRef.current.length === 0) { |
| handleStopPlayback(); |
| } |
| } else if (data.type === 'error') { |
| addLog('error', `Server stream error: ${data.message}`); |
| handleStopPlayback(); |
| } |
| } else { |
| |
| 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); |
| |
| accumulatedSessionChunksRef.current.push(float32Array); |
| scheduleNextStreamingChunk(); |
| } |
| } |
| }; |
|
|
| return () => { |
| if (websocket) websocket.close(); |
| }; |
| }, []); |
|
|
| |
| |
| |
| 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; |
|
|
| |
| 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]); |
|
|
| |
| const startStreamingPlayback = () => { |
| initAudio(); |
| if (audioCtxRef.current.state === 'suspended') { |
| audioCtxRef.current.resume(); |
| } |
|
|
| playbackQueueRef.current = []; |
| accumulatedSessionChunksRef.current = []; |
| 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 = () => { |
| |
| scheduledSourcesRef.current = scheduledSourcesRef.current.filter(src => src !== source); |
| |
| |
| 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 = []; |
|
|
| |
| scheduledSourcesRef.current.forEach(src => { |
| try { src.stop(); } catch(e) {} |
| }); |
| scheduledSourcesRef.current = []; |
|
|
| if (traditionalSourceRef.current) { |
| try { traditionalSourceRef.current.stop(); } catch(e) {} |
| traditionalSourceRef.current = null; |
| } |
|
|
| |
| 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.'); |
| }; |
|
|
| |
| |
| |
| 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)'; |
| 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)'; |
|
|
| |
| const gradient = ctx.createLinearGradient(0, 0, width, 0); |
| gradient.addColorStop(0, '#a259ff'); |
| gradient.addColorStop(0.5, '#00f2fe'); |
| gradient.addColorStop(1, '#ff5c8d'); |
| ctx.strokeStyle = gradient; |
|
|
| ctx.beginPath(); |
|
|
| if (dataArray) { |
| |
| 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 { |
| |
| 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]); |
|
|
| |
| |
| |
| const handleSynthesize = async () => { |
| const text = script.trim(); |
| if (!text) { |
| addLog('error', 'Cannot synthesize. Text script field is empty!'); |
| return; |
| } |
|
|
| initAudio(); |
| handleStopPlayback(); |
|
|
| |
| const speakerVoices = {}; |
| speakers.forEach(s => { |
| if (s.voicePath) { |
| speakerVoices[s.id] = s.voicePath; |
| } |
| }); |
|
|
| |
| setCompiledAudioUrl(null); |
| setCompiledAudioMode(null); |
|
|
| if (streamMode) { |
| |
| 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 { |
| |
| 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'); |
| |
| |
| 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); |
| } |
| }; |
|
|
| |
| writeString(view, 0, 'RIFF'); |
| |
| view.setUint32(4, 36 + samples.length * 2, true); |
| |
| writeString(view, 8, 'WAVE'); |
| |
| writeString(view, 12, 'fmt '); |
| |
| view.setUint32(16, 16, true); |
| |
| view.setUint16(20, 1, true); |
| |
| view.setUint16(22, 1, true); |
| |
| view.setUint32(24, sampleRate, true); |
| |
| view.setUint32(28, sampleRate * 2, true); |
| |
| view.setUint16(32, 2, true); |
| |
| view.setUint16(34, 16, true); |
| |
| writeString(view, 36, 'data'); |
| |
| view.setUint32(40, samples.length * 2, true); |
|
|
| floatTo16BitPCM(view, 44, samples); |
|
|
| return new Blob([view], { type: 'audio/wav' }); |
| }; |
|
|
| |
| |
| |
| const toggleRecording = async (speakerId) => { |
| const speaker = speakers.find(s => s.id === speakerId); |
| if (!speaker) return; |
|
|
| if (speaker.isRecording) { |
| |
| 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 { |
| |
| 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...' |
| }); |
|
|
| |
| 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); |
| }; |
|
|
| |
| const playPreview = async (speakerId, url) => { |
| initAudio(); |
| handleStopPlayback(); |
|
|
| 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' |
| }); |
| }; |
|
|
| |
| 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}]`); |
| }; |
|
|
| |
| 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`; |
| |
| |
| const { data: storageData, error: storageError } = await supabase |
| .storage |
| .from('voice_samples') |
| .upload(fileName, fileBlob); |
|
|
| if (storageError) throw storageError; |
|
|
| |
| const { data: publicUrlData } = supabase |
| .storage |
| .from('voice_samples') |
| .getPublicUrl(fileName); |
|
|
| const publicUrl = publicUrlData.publicUrl; |
|
|
| |
| const { data: dbData, error: dbError } = await supabase |
| .from('voice_profiles') |
| .insert([{ name: speakerName, audio_url: publicUrl }]) |
| .select(); |
|
|
| if (dbError) throw dbError; |
|
|
| |
| setSpeakers(prev => prev.map(s => s.id === speakerId ? { |
| ...s, |
| voicePath: publicUrl, |
| previewUrl: publicUrl |
| } : s)); |
| |
| addLog('success', `Voice signature for "${speakerName}" saved to Supabase successfully!`); |
| |
| |
| 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!`); |
| |
| |
| 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}`); |
| } |
| } |
| }; |
|
|
| |
| 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, |
| loaded: false |
| })); |
|
|
| setTimeout(checkServerStatus, 1500); |
| } catch (e) { |
| addLog('error', 'Toggle engine request failed.'); |
| } finally { |
| setTogglingModel(false); |
| } |
| }; |
|
|
| |
| 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> |
|
|
| {} |
| {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> |
| )} |
|
|
| {} |
| {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> |
| ); |
| } |
|
|
| |
| |
| |
| 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); |
| |
| |
| writeString(view, 0, 'RIFF'); |
| |
| view.setUint32(4, 36 + totalLength * 2, true); |
| |
| writeString(view, 8, 'WAVE'); |
| |
| writeString(view, 12, 'fmt '); |
| |
| view.setUint32(16, 16, true); |
| |
| view.setUint16(20, 1, true); |
| |
| view.setUint16(22, 1, true); |
| |
| view.setUint32(24, sampleRate, true); |
| |
| view.setUint32(28, sampleRate * 2, true); |
| |
| view.setUint16(32, 2, true); |
| |
| view.setUint16(34, 16, true); |
| |
| writeString(view, 36, 'data'); |
| |
| view.setUint32(40, totalLength * 2, true); |
| |
| |
| 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; |
|
|