Spaces:
Build error
Build error
| <html lang="vi" class="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Voice Studio | VieNeu AI</title> | |
| <!-- Tailwind CSS CDN --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| theme: { | |
| extend: { | |
| colors: { | |
| primary: '#8b5cf6', | |
| secondary: '#ec4899', | |
| dark: '#0f172a', | |
| card: 'rgba(30, 41, 59, 0.7)' | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <link rel="stylesheet" href="/static/styles.css"> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| </head> | |
| <body class="text-slate-200 min-h-screen custom-bg selection:bg-primary selection:text-white font-inter"> | |
| <!-- Header --> | |
| <header class="border-b border-slate-700/50 bg-slate-900/50 backdrop-blur-md sticky top-0 z-50"> | |
| <div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-primary/20"> | |
| <i data-feather="mic" class="text-white w-5 h-5"></i> | |
| </div> | |
| <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-400"> | |
| Voice Studio <span class="font-normal text-sm text-primary ml-1">v2-Turbo</span> | |
| </h1> | |
| </div> | |
| <nav class="flex gap-4 text-sm font-medium"> | |
| <a href="/" class="text-slate-400 hover:text-white transition-colors">Video AI</a> | |
| <a href="/tts" class="text-white border-b-2 border-primary pb-1">Text to Speech</a> | |
| <a href="/studio" class="text-slate-400 hover:text-white transition-colors">Animation Studio</a> | |
| </nav> | |
| </div> | |
| </header> | |
| <main class="max-w-6xl mx-auto px-6 py-10 grid grid-cols-1 lg:grid-cols-12 gap-8"> | |
| <!-- Left Column: Controls (8 cols) --> | |
| <div class="lg:col-span-8 space-y-6"> | |
| <!-- Text Input --> | |
| <div class="glass-panel rounded-2xl p-6 shadow-xl"> | |
| <h2 class="text-lg font-semibold mb-4 text-white flex items-center gap-2"> | |
| <i data-feather="file-text" class="w-5 h-5 text-primary"></i> Nội dung kịch bản | |
| </h2> | |
| <textarea id="scriptText" rows="6" placeholder="Nhập kịch bản... Mẹo: [v:static/voice/ngoc-oanh.mp3] đổi giọng, [s:1.2] chỉnh tốc độ, [p:500] nghỉ." | |
| class="w-full bg-slate-900/50 border border-slate-700 text-slate-200 rounded-xl p-4 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-600 resize-none font-medium text-lg leading-relaxed"></textarea> | |
| </div> | |
| <!-- Voice Settings --> | |
| <div class="glass-panel rounded-2xl p-6 shadow-xl"> | |
| <!-- Custom Tabs --> | |
| <div class="flex border-b border-slate-700 mb-6"> | |
| <button id="tabPreset" class="px-6 py-3 text-sm font-semibold text-primary border-b-2 border-primary flex gap-2 items-center transition-colors"> | |
| <i data-feather="users" class="w-4 h-4"></i> Giọng có sẵn | |
| </button> | |
| <button id="tabClone" class="px-6 py-3 text-sm font-semibold text-slate-400 hover:text-slate-200 flex gap-2 items-center transition-colors"> | |
| <i data-feather="copy" class="w-4 h-4"></i> Clone Giọng | |
| </button> | |
| </div> | |
| <div id="presetContainer" class="space-y-6"> | |
| <!-- Dropdown Select --> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-300 mb-2">Chọn nhân vật</label> | |
| <div class="relative"> | |
| <select id="voicePreset" class="w-full appearance-none bg-slate-900/50 border border-slate-700 text-slate-200 rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary outline-none cursor-pointer font-medium"> | |
| <option value="bich_ngoc">Bích Ngọc (Nữ - Miền Bắc)</option> | |
| <option value="pham_tuyen">Phạm Tuyên (Nam - Miền Bắc)</option> | |
| <option value="thuc_doan">Thục Đoan (Nữ - Miền Nam)</option> | |
| <option value="xuan_vinh">Xuân Vĩnh (Nam - Miền Nam)</option> | |
| <option value="static/voice/giong-google.mp3">Giọng Google</option> | |
| <option value="static/voice/thien-tam.mp3">Thiện Tâm (Nam - Giọng trầm)</option> | |
| <option value="static/voice/nguyet-nga.mp3">Nguyệt Nga (Review phim)</option> | |
| <option value="static/voice/ngoc-oanh.mp3">Ngọc Oanh (Kể chuyện)</option> | |
| <option value="static/voice/giong-nu-ke-chuyen.mp3">Giọng Nữ Kể chuyện (Mới)</option> | |
| <option value="static/voice/giong-nu-review.mp3">Giọng Nữ Review SP (Mới)</option> | |
| <option value="static/voice/giong-nu-tiktok-hay.mp3">Giọng nữ TikTok hay (Mới)</option> | |
| <option value="static/voice/male1.mp3">Giọng Phóng viên</option> | |
| </select> | |
| <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-slate-400"> | |
| <i data-feather="chevron-down" class="w-4 h-4"></i> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="cloneContainer" class="hidden space-y-6"> | |
| <!-- Character Name Input --> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-300 mb-2">Tên nhân vật (Gợi nhớ)</label> | |
| <input type="text" id="characterName" placeholder="Ví dụ: Giọng Sếp, Giọng Elon Musk..." | |
| class="w-full bg-slate-900/50 border border-slate-700 text-slate-200 rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary outline-none transition-all placeholder:text-slate-600"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-slate-300 mb-2">Upload file âm thanh giọng mẫu</label> | |
| <div class="relative group"> | |
| <input type="file" id="cloneAudio" accept="audio/*" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"> | |
| <div class="border-2 border-dashed border-slate-600 rounded-xl p-6 flex flex-col items-center justify-center gap-3 bg-slate-800/50 group-hover:bg-slate-800 transition-colors group-hover:border-primary/50"> | |
| <i data-feather="upload-cloud" class="w-8 h-8 text-slate-400 group-hover:text-primary transition-colors"></i> | |
| <span id="cloneFileName" class="text-sm font-medium text-slate-400">Trích xuất giọng từ file (.mp3, .wav)</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Voice Library Section --> | |
| <div id="librarySection" class="hidden mt-6 pt-6 border-t border-slate-700/50"> | |
| <h3 class="text-sm font-semibold text-slate-400 mb-3 flex items-center gap-2"> | |
| <i data-feather="bookmark" class="w-4 h-4"></i> Thư viện giọng của riêng bạn (Lịch sử) | |
| </h3> | |
| <div id="voiceList" class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-48 overflow-y-auto pr-2 custom-scrollbar"> | |
| <!-- Items populated by JS --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Advanced Settings: Temperature Slider --> | |
| <div class="mt-8 pt-6 border-t border-slate-700/50"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <label class="text-sm font-medium text-slate-300 flex items-center gap-2"> | |
| <i data-feather="thermometer" class="w-4 h-4 text-secondary"></i> Nhiệt độ (Temperature) | |
| </label> | |
| <span id="tempValue" class="text-xs font-mono bg-slate-800 px-2 py-1 rounded text-primary">0.5</span> | |
| </div> | |
| <input type="range" id="temperature" min="0.1" max="1.5" step="0.1" value="0.5" | |
| class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-primary"> | |
| <p class="text-xs text-slate-500 mt-2">Thấp = Khô khan chuẩn xác | Cao = Cảm xúc sáng tạo hơn.</p> | |
| </div> | |
| <!-- BGM Settings --> | |
| <div class="mt-6 pt-6 border-t border-slate-700/50"> | |
| <label class="text-sm font-medium text-slate-300 flex items-center gap-2 mb-4"> | |
| <i data-feather="music" class="w-4 h-4 text-emerald-400"></i> Nhạc nền (BGM) | |
| </label> | |
| <div class="relative mb-4"> | |
| <select id="bgmPreset" class="w-full appearance-none bg-slate-900/50 border border-slate-700 text-slate-200 rounded-xl px-4 py-3 focus:ring-2 focus:ring-emerald-500 outline-none cursor-pointer font-medium"> | |
| <option value="">-- Chọn nhạc mặc định --</option> | |
| <option value="static/music/Cheel - Blue Dream.mp3">Cheel - Blue Dream</option> | |
| <option value="static/music/Soft Feeling - Cheel.mp3">Soft Feeling - Cheel</option> | |
| <option value="static/music/Sunset Dream - Cheel.mp3">Sunset Dream - Cheel</option> | |
| <option value="static/music/Morning Mandolin - Chris Haugen.mp3">Morning Mandolin</option> | |
| <option value="static/music/Kiss the Sky - Aakash Gandhi.mp3">Kiss the Sky</option> | |
| </select> | |
| <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-slate-400"> | |
| <i data-feather="chevron-down" class="w-4 h-4"></i> | |
| </div> | |
| </div> | |
| <div class="text-xs text-slate-500 mb-2">Hoặc tải file lên:</div> | |
| <input type="file" id="bgmAudio" accept="audio/*" class="text-xs text-slate-400 mb-4 block w-full | |
| file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 | |
| file:text-sm file:font-semibold file:bg-slate-800 file:text-emerald-400 | |
| hover:file:bg-slate-700 cursor-pointer"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <span class="text-xs text-slate-400">Âm lượng nhạc nền</span> | |
| <span id="bgmVolValue" class="text-xs font-mono text-emerald-400 bg-slate-800 px-2 py-1 rounded">0.1</span> | |
| </div> | |
| <input type="range" id="bgmVolume" min="0" max="0.5" step="0.01" value="0.1" | |
| class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Submit Button --> | |
| <button id="submitBtn" class="w-full relative overflow-hidden group bg-gradient-to-r from-primary to-blue-600 text-white font-bold py-4 px-4 rounded-xl shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:shadow-[0_0_30px_rgba(139,92,246,0.5)] transition-all flex items-center justify-center gap-2"> | |
| <span class="z-10 flex items-center gap-2 text-lg"> | |
| <i data-feather="zap" class="w-5 h-5"></i> Bắt đầu tổng hợp Audio | |
| </span> | |
| </button> | |
| </div> | |
| <!-- Right Column: Result (4 cols) --> | |
| <div class="lg:col-span-4"> | |
| <div class="glass-panel flex flex-col rounded-2xl h-full shadow-xl sticky top-24"> | |
| <div class="p-6 border-b border-slate-700 flex items-center gap-2"> | |
| <i data-feather="headphones" class="w-5 h-5 text-secondary"></i> | |
| <h3 class="font-semibold text-white">Audio Kết Quả</h3> | |
| </div> | |
| <div class="flex-1 p-6 flex flex-col items-center justify-center min-h-[300px] relative"> | |
| <!-- Empty State --> | |
| <div id="emptyResult" class="text-center"> | |
| <i data-feather="music" class="w-12 h-12 text-slate-700 mx-auto mb-4"></i> | |
| <p class="text-slate-500 text-sm font-medium">Bấm "Bắt đầu" để tạo âm thanh</p> | |
| </div> | |
| <!-- Loading State --> | |
| <div id="loadingResult" class="hidden flex-col items-center"> | |
| <div class="spinner w-8 h-8 border-[3px] border-t-primary mb-4"></div> | |
| <p class="text-primary font-medium animate-pulse">Đang tổng hợp...</p> | |
| <p class="text-xs text-slate-500 mt-1">Sẽ mất vài giây để xử lý</p> | |
| </div> | |
| <!-- Audio Player Area --> | |
| <div id="audioResult" class="hidden w-full flex flex-col items-center space-y-6"> | |
| <div class="w-20 h-20 bg-gradient-to-tr from-primary to-secondary rounded-full flex items-center justify-center shadow-[0_0_40px_rgba(236,72,153,0.3)] beep-anim"> | |
| <i data-feather="play" class="w-8 h-8 text-white ml-1"></i> | |
| </div> | |
| <audio id="player" controls class="w-full"></audio> | |
| <a id="downloadBtn" href="#" download="vieneu_output.wav" class="text-sm font-medium text-slate-300 hover:text-white flex items-center gap-1 bg-slate-800 py-2 px-4 rounded-lg border border-slate-700 hover:border-slate-500 transition-colors"> | |
| <i data-feather="download" class="w-4 h-4"></i> Tải file .wav | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <style> | |
| .beep-anim { | |
| animation: pulse-glow 2s infinite; | |
| } | |
| @keyframes pulse-glow { | |
| 0% { box-shadow: 0 0 0 0 rgba(236,72,153, 0.4); } | |
| 70% { box-shadow: 0 0 0 20px rgba(236,72,153, 0); } | |
| 100% { box-shadow: 0 0 0 0 rgba(236,72,153, 0); } | |
| } | |
| .custom-scrollbar::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { | |
| background: #475569; | |
| border-radius: 10px; | |
| } | |
| .voice-card-active { | |
| border-color: #8b5cf6 ; | |
| background: rgba(139,92,246, 0.1) ; | |
| } | |
| </style> | |
| <script> | |
| feather.replace(); | |
| // Local Voice Library Management | |
| let myVoices = JSON.parse(localStorage.getItem('myClonedVoices') || '[]'); | |
| let selectedLibraryPath = null; | |
| function updateLibraryUI() { | |
| const list = document.getElementById('voiceList'); | |
| const section = document.getElementById('librarySection'); | |
| if (myVoices.length === 0) { | |
| section.classList.add('hidden'); | |
| return; | |
| } | |
| section.classList.remove('hidden'); | |
| list.innerHTML = ''; | |
| myVoices.forEach((v, index) => { | |
| const card = document.createElement('div'); | |
| card.className = `p-3 rounded-lg border border-slate-700 bg-slate-800/40 cursor-pointer hover:border-slate-500 transition-all group flex items-center justify-between ${selectedLibraryPath === v.path ? 'voice-card-active' : ''}`; | |
| card.onclick = () => selectVoiceFromLibrary(v.path); | |
| card.innerHTML = ` | |
| <div class="flex items-center gap-2 overflow-hidden"> | |
| <i data-feather="user" class="w-3 h-3 text-primary"></i> | |
| <span class="text-xs font-semibold truncate text-slate-300 group-hover:text-white transition-colors">${v.name}</span> | |
| </div> | |
| <button onclick="event.stopPropagation(); deleteVoice(${index})" class="opacity-0 group-hover:opacity-100 p-1 hover:text-red-400 transition-all"> | |
| <i data-feather="trash-2" class="w-3 h-3"></i> | |
| </button> | |
| `; | |
| list.appendChild(card); | |
| }); | |
| feather.replace(); | |
| } | |
| function selectVoiceFromLibrary(path) { | |
| if (selectedLibraryPath === path) { | |
| selectedLibraryPath = null; | |
| document.getElementById('cloneFileName').textContent = 'Trích xuất giọng từ file (.mp3, .wav)'; | |
| } else { | |
| selectedLibraryPath = path; | |
| // Clear file input UI | |
| document.getElementById('cloneAudio').value = ''; | |
| document.getElementById('cloneFileName').textContent = 'Sử dụng giọng từ thư viện...'; | |
| } | |
| updateLibraryUI(); | |
| } | |
| function deleteVoice(index) { | |
| if (myVoices[index].path === selectedLibraryPath) selectedLibraryPath = null; | |
| myVoices.splice(index, 1); | |
| localStorage.setItem('myClonedVoices', JSON.stringify(myVoices)); | |
| updateLibraryUI(); | |
| } | |
| updateLibraryUI(); | |
| // Range Slider Value update | |
| const tempSlider = document.getElementById('temperature'); | |
| const tempValue = document.getElementById('tempValue'); | |
| tempSlider.addEventListener('input', (e) => { | |
| tempValue.textContent = parseFloat(e.target.value).toFixed(1); | |
| }); | |
| // Tabs Logic | |
| const tabPreset = document.getElementById('tabPreset'); | |
| const tabClone = document.getElementById('tabClone'); | |
| const presetContainer = document.getElementById('presetContainer'); | |
| const cloneContainer = document.getElementById('cloneContainer'); | |
| let isCloning = false; | |
| const bgmVolSlider = document.getElementById('bgmVolume'); | |
| const bgmVolValue = document.getElementById('bgmVolValue'); | |
| bgmVolSlider.addEventListener('input', (e) => { | |
| bgmVolValue.textContent = e.target.value; | |
| }); | |
| tabPreset.addEventListener('click', () => { | |
| isCloning = false; | |
| tabPreset.className = "px-6 py-3 text-sm font-semibold text-primary border-b-2 border-primary flex gap-2 items-center transition-colors"; | |
| tabClone.className = "px-6 py-3 text-sm font-semibold text-slate-400 hover:text-slate-200 flex gap-2 items-center transition-colors border-b-2 border-transparent"; | |
| presetContainer.classList.remove('hidden'); | |
| cloneContainer.classList.add('hidden'); | |
| selectedLibraryPath = null; | |
| updateLibraryUI(); | |
| }); | |
| tabClone.addEventListener('click', () => { | |
| isCloning = true; | |
| tabClone.className = "px-6 py-3 text-sm font-semibold text-primary border-b-2 border-primary flex gap-2 items-center transition-colors"; | |
| tabPreset.className = "px-6 py-3 text-sm font-semibold text-slate-400 hover:text-slate-200 flex gap-2 items-center transition-colors border-b-2 border-transparent"; | |
| presetContainer.classList.add('hidden'); | |
| cloneContainer.classList.remove('hidden'); | |
| updateLibraryUI(); | |
| }); | |
| document.getElementById('cloneAudio').addEventListener('change', function(e) { | |
| const fileName = e.target.files[0] ? e.target.files[0].name : 'Trích xuất giọng từ file (.mp3, .wav)'; | |
| document.getElementById('cloneFileName').textContent = fileName; | |
| if (e.target.files[0]) { | |
| selectedLibraryPath = null; | |
| updateLibraryUI(); | |
| } | |
| }); | |
| // Submit logic | |
| const submitBtn = document.getElementById('submitBtn'); | |
| let pollInterval; | |
| submitBtn.addEventListener('click', async () => { | |
| const text = document.getElementById('scriptText').value; | |
| if(!text.trim()) { | |
| alert("Vui lòng nhập kịch bản cần đọc!"); | |
| return; | |
| } | |
| const formData = new FormData(); | |
| formData.append('script', text); | |
| formData.append('temperature', parseFloat(tempSlider.value)); | |
| // BGM logic | |
| const bgmFile = document.getElementById('bgmAudio').files[0]; | |
| if (bgmFile) { | |
| formData.append('bgm_audio', bgmFile); | |
| } | |
| formData.append('bgm_volume', parseFloat(bgmVolSlider.value)); | |
| formData.append('bgm_preset', document.getElementById('bgmPreset').value); | |
| let tempCharacterName = ""; | |
| if (isCloning) { | |
| if (selectedLibraryPath) { | |
| formData.append('existing_ref_path', selectedLibraryPath); | |
| } else { | |
| const cloneAudio = document.getElementById('cloneAudio').files[0]; | |
| if(!cloneAudio) { | |
| alert("Vui lòng chọn file âm thanh hoặc chọn từ thư viện!"); | |
| return; | |
| } | |
| formData.append('ref_audio', cloneAudio); | |
| tempCharacterName = document.getElementById('characterName').value.trim() || "Nhân vật mới"; | |
| } | |
| } else { | |
| formData.append('voice_preset', document.getElementById('voicePreset').value); | |
| } | |
| // Update UI | |
| submitBtn.disabled = true; | |
| submitBtn.classList.add('opacity-70'); | |
| document.getElementById('emptyResult').classList.add('hidden'); | |
| document.getElementById('audioResult').classList.add('hidden'); | |
| document.getElementById('loadingResult').classList.remove('hidden'); | |
| document.getElementById('loadingResult').classList.add('flex'); | |
| try { | |
| const response = await fetch('/api/v1/tts/generate', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) throw new Error("Yêu cầu thất bại"); | |
| const data = await response.json(); | |
| const jobId = data.job_id; | |
| pollInterval = setInterval(() => checkTtsJobStatus(jobId, tempCharacterName), 1500); | |
| } catch (err) { | |
| alert("Lỗi: " + err); | |
| resetSubmitUI(); | |
| } | |
| }); | |
| async function checkTtsJobStatus(jobId, charName) { | |
| try { | |
| const res = await fetch(`/api/v1/jobs/${jobId}`); | |
| if (!res.ok) return; | |
| const jobData = await res.json(); | |
| if (jobData.status === 'completed') { | |
| clearInterval(pollInterval); | |
| // SAVE TO LIBRARY IF NEW | |
| if (charName) { | |
| const actualPath = jobData.raw_video_path; // We reused this field for ref_audio_path in TTS jobs | |
| if (actualPath && actualPath !== "audio_only" && !myVoices.find(v => v.path === actualPath)) { | |
| myVoices.push({ name: charName, path: actualPath }); | |
| localStorage.setItem('myClonedVoices', JSON.stringify(myVoices)); | |
| updateLibraryUI(); | |
| } | |
| } | |
| showResult(jobData.result_url); | |
| resetSubmitUI(); | |
| } else if (jobData.status === 'failed' || jobData.status === 'error') { | |
| clearInterval(pollInterval); | |
| alert("Lỗi quá trình Render TTS!"); | |
| resetSubmitUI(); | |
| } | |
| } catch(e) {} | |
| } | |
| function showResult(audioUrl) { | |
| document.getElementById('loadingResult').classList.add('hidden'); | |
| document.getElementById('loadingResult').classList.remove('flex'); | |
| document.getElementById('audioResult').classList.remove('hidden'); | |
| const player = document.getElementById('player'); | |
| player.src = audioUrl; | |
| player.play(); | |
| const btn = document.getElementById('downloadBtn'); | |
| btn.href = audioUrl; | |
| } | |
| function resetSubmitUI() { | |
| submitBtn.disabled = false; | |
| submitBtn.classList.remove('opacity-70'); | |
| if (document.getElementById('loadingResult').classList.contains('flex')) { | |
| document.getElementById('loadingResult').classList.add('hidden'); | |
| document.getElementById('loadingResult').classList.remove('flex'); | |
| document.getElementById('emptyResult').classList.remove('hidden'); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |