Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>VoiceWarp — Real-Time Voice Translation</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); | |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;} | |
| :root{ | |
| --bg:#0d1117; --s1:#161b22; --s2:#1c2128; | |
| --border:#30363d; --accent:#2f81f7; --green:#3fb950; | |
| --red:#f85149; --yellow:#d29922; | |
| --text:#e6edf3; --muted:#7d8590; | |
| } | |
| html,body{height:100%;background:var(--bg);color:var(--text);font-family:Inter,sans-serif;font-size:14px;} | |
| .app{max-width:860px;margin:0 auto;padding:0 16px 40px;} | |
| .header{padding:20px 0 16px;border-bottom:1px solid var(--border);margin-bottom:24px; | |
| display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;} | |
| .logo{font-size:1.3rem;font-weight:700;letter-spacing:-.02em;} | |
| .logo span{color:var(--accent);} | |
| .logo-sub{font-size:11px;color:var(--muted);margin-top:2px;} | |
| .badges{display:flex;gap:6px;flex-wrap:wrap;} | |
| .badge{font-size:10px;padding:3px 9px;border-radius:20px; | |
| background:rgba(47,129,247,.1);border:1px solid rgba(47,129,247,.22);color:#79c0ff; | |
| font-family:'JetBrains Mono',monospace;} | |
| .card{background:var(--s1);border:1px solid var(--border);border-radius:10px; | |
| padding:20px 22px;margin-bottom:14px;} | |
| .card-title{font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase; | |
| color:var(--muted);font-family:'JetBrains Mono',monospace;margin-bottom:14px; | |
| display:flex;align-items:center;gap:8px;} | |
| .card-title .step-num{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--accent); | |
| color:var(--accent);font-size:11px;font-weight:700; | |
| display:flex;align-items:center;justify-content:center;flex-shrink:0;} | |
| .tip{font-size:12px;color:var(--muted);background:var(--s2);border:1px solid var(--border); | |
| border-radius:6px;padding:10px 14px;line-height:1.75;margin-bottom:14px;} | |
| .tip b{color:var(--text);} | |
| .form-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:0;} | |
| @media(max-width:600px){.form-row{grid-template-columns:1fr;}} | |
| .field label{display:block;font-size:11px;font-weight:600;color:var(--muted); | |
| text-transform:uppercase;letter-spacing:.07em; | |
| font-family:'JetBrains Mono',monospace;margin-bottom:5px;} | |
| select{width:100%;background:var(--s2);border:1px solid var(--border);border-radius:6px; | |
| color:var(--text);font-size:13px;padding:8px 10px;cursor:pointer;appearance:none; | |
| background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 5 5-5' stroke='%237d8590' fill='none' stroke-width='1.5'/%3E%3C/svg%3E"); | |
| background-repeat:no-repeat;background-position:right 10px center;padding-right:28px;} | |
| select:focus{outline:none;border-color:var(--accent);} | |
| .recorder{background:var(--s2);border:1px solid var(--border);border-radius:8px;padding:16px;} | |
| .rec-state{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted); | |
| margin-bottom:12px;min-height:22px;display:flex;align-items:center;gap:8px;} | |
| .rec-indicator{width:10px;height:10px;border-radius:50%;background:var(--muted);flex-shrink:0;} | |
| .rec-indicator.recording{background:var(--red);animation:blink .9s infinite;} | |
| .rec-indicator.ready{background:var(--green);} | |
| @keyframes blink{0%,100%{opacity:1}50%{opacity:.2}} | |
| .rec-timer{font-family:'JetBrains Mono',monospace;font-weight:600;font-size:13px;color:var(--text);min-width:44px;} | |
| .rec-btns{display:flex;gap:8px;flex-wrap:wrap;} | |
| .rec-wave{width:100%;height:48px;border-radius:6px;margin-top:10px;background:var(--bg);} | |
| audio.preview{width:100%;margin-top:10px;border-radius:6px;display:none;} | |
| .btn{display:inline-flex;align-items:center;gap:7px;font-family:Inter,sans-serif; | |
| font-weight:600;font-size:13px;border-radius:6px;border:none;cursor:pointer; | |
| padding:9px 18px;transition:all .15s;} | |
| .btn:disabled{opacity:.35;cursor:not-allowed;} | |
| .btn-record{background:var(--red);color:#fff;} | |
| .btn-record:not(:disabled):hover{background:#ff6b63;} | |
| .btn-stop{background:var(--s1);color:var(--text);border:1px solid var(--border);} | |
| .btn-stop:not(:disabled):hover{border-color:var(--accent);} | |
| .btn-clear{background:transparent;color:var(--muted);border:1px solid var(--border);} | |
| .btn-clear:not(:disabled):hover{border-color:var(--accent);color:var(--text);} | |
| .btn-primary{background:var(--accent);color:#fff;} | |
| .btn-primary:not(:disabled):hover{background:#388bfd;box-shadow:0 0 0 3px rgba(47,129,247,.25);} | |
| .btn-primary:disabled{background:#1a3a6b;} | |
| .btn-lg{padding:11px 28px;font-size:14px;} | |
| .upload-area{border:1.5px dashed var(--border);border-radius:8px;padding:18px; | |
| text-align:center;cursor:pointer;transition:border-color .15s;background:var(--s2);} | |
| .upload-area:hover{border-color:var(--accent);} | |
| .upload-area.has-file{border-color:var(--green);} | |
| .upload-icon{font-size:1.6rem;margin-bottom:6px;} | |
| .upload-text{font-size:12px;color:var(--muted);line-height:1.6;} | |
| .upload-text b{color:var(--text);} | |
| .upload-text .formats{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--muted);margin-top:4px;} | |
| .progress-wrap{background:var(--s2);border:1px solid var(--border); | |
| border-radius:8px;padding:14px 16px;margin-top:4px;} | |
| .progress-steps{display:flex;gap:0;margin-bottom:10px;} | |
| .p-step{flex:1;text-align:center;font-size:10px;font-family:'JetBrains Mono',monospace; | |
| color:var(--muted);padding:6px 4px;border-radius:4px;transition:all .3s; | |
| position:relative;} | |
| .p-step.active{background:rgba(47,129,247,.12);color:var(--accent);} | |
| .p-step.done{background:rgba(63,185,80,.1);color:var(--green);} | |
| .p-step.error{background:rgba(248,81,73,.1);color:var(--red);} | |
| .progress-msg{font-size:12px;color:var(--muted);font-family:'JetBrains Mono',monospace; | |
| text-align:center;} | |
| .output-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;} | |
| @media(max-width:600px){.output-grid{grid-template-columns:1fr;}} | |
| .out-panel{background:var(--s2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;} | |
| .out-label{font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase; | |
| color:var(--muted);font-family:'JetBrains Mono',monospace;margin-bottom:8px; | |
| display:flex;align-items:center;gap:6px;} | |
| .out-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;} | |
| .out-text{font-size:13px;color:var(--text);line-height:1.6; | |
| font-family:'JetBrains Mono',monospace;min-height:60px; | |
| white-space:pre-wrap;} | |
| .out-text.placeholder{color:var(--muted);font-size:12px;} | |
| .audio-out{background:var(--s2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;} | |
| .audio-out audio{width:100%;border-radius:6px;} | |
| hr{border:none;border-top:1px solid var(--border);margin:16px 0;} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <div class="header"> | |
| <div> | |
| <div class="logo">Voice<span>Warp</span></div> | |
| <div class="logo-sub">Speak in any language · Hear yourself translated</div> | |
| </div> | |
| <div class="badges"> | |
| <span class="badge">Whisper ASR</span> | |
| <span class="badge">NLLB-200</span> | |
| <span class="badge">XTTS v2</span> | |
| <span class="badge">20 Languages</span> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><div class="step-num">1</div>Choose Languages & Model</div> | |
| <div class="form-row"> | |
| <div class="field"> | |
| <label>I am speaking</label> | |
| <select id="src-lang"> | |
| <option>Arabic</option><option>Chinese</option><option>Czech</option> | |
| <option>Dutch</option><option selected>English</option><option>French</option> | |
| <option>German</option><option>Hindi</option><option>Hungarian</option> | |
| <option>Italian</option><option>Japanese</option><option>Korean</option> | |
| <option>Polish</option><option>Portuguese</option><option>Romanian</option> | |
| <option>Russian</option><option>Spanish</option><option>Swedish</option> | |
| <option>Turkish</option><option>Ukrainian</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label>Translate to</label> | |
| <select id="tgt-lang"> | |
| <option>Arabic</option><option>Chinese</option><option>Czech</option> | |
| <option>Dutch</option><option>English</option><option>French</option> | |
| <option>German</option><option selected>Hindi</option><option>Hungarian</option> | |
| <option>Italian</option><option>Japanese</option><option>Korean</option> | |
| <option>Polish</option><option>Portuguese</option><option>Romanian</option> | |
| <option>Russian</option><option>Spanish</option><option>Swedish</option> | |
| <option>Turkish</option><option>Ukrainian</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label>Whisper Model</label> | |
| <select id="model-size"> | |
| <option value="tiny">Tiny — fastest</option> | |
| <option value="small" selected>Small — recommended</option> | |
| <option value="medium">Medium — most accurate</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><div class="step-num">2</div>Record Your Voice</div> | |
| <div class="tip">Click <b>Start Recording</b>, speak, then click <b>Stop</b>.</div> | |
| <div class="recorder"> | |
| <div class="rec-state"> | |
| <div class="rec-indicator" id="rec-dot"></div> | |
| <span id="rec-state-text">Ready to record</span> | |
| <span class="rec-timer" id="rec-timer"></span> | |
| </div> | |
| <div class="rec-btns"> | |
| <button class="btn btn-record" id="btn-record" onclick="startRec()">Start Recording</button> | |
| <button class="btn btn-stop" id="btn-stop" onclick="stopRec()" disabled>Stop Recording</button> | |
| <button class="btn btn-clear" id="btn-play" onclick="playBack()" disabled>Play Back</button> | |
| <button class="btn btn-clear" id="btn-clear" onclick="clearRec()" disabled>Clear</button> | |
| </div> | |
| <canvas class="rec-wave" id="rec-wave" width="800" height="48"></canvas> | |
| <audio class="preview" id="audio-preview" controls></audio> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><div class="step-num" style="border-color:var(--muted);color:var(--muted);">+</div>Optional: Voice Reference</div> | |
| <input type="file" id="ref-file-input" style="display:none" onchange="handleRefUpload(this)"> | |
| <div class="upload-area" id="upload-area" onclick="document.getElementById('ref-file-input').click()"> | |
| <div class="upload-icon">🔊</div> | |
| <div class="upload-text"><b>Click to upload voice reference</b></div> | |
| </div> | |
| <div id="ref-status" style="font-size:11px;color:var(--muted);margin-top:8px;font-family:'JetBrains Mono',monospace;"></div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title"><div class="step-num">3</div>Translate</div> | |
| <button class="btn btn-primary btn-lg" id="btn-translate" style="width:100%;justify-content:center;" onclick="doTranslate()">Translate Now</button> | |
| <div class="progress-wrap" id="progress-wrap" style="display:none;margin-top:12px;"> | |
| <div class="progress-steps"> | |
| <div class="p-step" id="ps-upload">Uploading</div> | |
| <div class="p-step" id="ps-asr">Transcribing</div> | |
| <div class="p-step" id="ps-translate">Translating</div> | |
| <div class="p-step" id="ps-tts">Cloning Voice</div> | |
| <div class="p-step" id="ps-done">Done</div> | |
| </div> | |
| <div class="progress-msg" id="progress-msg">Starting...</div> | |
| </div> | |
| </div> | |
| <div class="card" id="output-card" style="display:none;"> | |
| <div class="card-title">Output</div> | |
| <div class="output-grid"> | |
| <div class="out-panel"> | |
| <div class="out-label"><div class="out-dot" style="background:#2f81f7;"></div>What you said</div> | |
| <div class="out-text placeholder" id="out-transcript">Transcription appears here...</div> | |
| </div> | |
| <div class="out-panel"> | |
| <div class="out-label"><div class="out-dot" style="background:#3fb950;"></div>Translation</div> | |
| <div class="out-text placeholder" id="out-translation">Translation appears here...</div> | |
| </div> | |
| </div> | |
| <div class="audio-out"> | |
| <audio id="out-audio" controls style="width:100%;"></audio> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let mediaRecorder = null, audioChunks = [], audioBlob = null, audioCtx = null, analyser = null, stream = null; | |
| let animFrame = null, recSeconds = 0, recTimer = null, pollTimer = null, recPath = null, refPath = null; | |
| const canvas = document.getElementById('rec-wave'), ctx = canvas.getContext('2d'); | |
| // --- HF ROUTING FIX --- | |
| // Use relative paths so HF Spaces routes to your Python app | |
| const API_BASE = window.location.origin; | |
| async function startRec() { | |
| try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) { setState('error', 'Mic denied'); return; } | |
| audioChunks = []; audioBlob = null; recPath = null; recSeconds = 0; | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| analyser = audioCtx.createAnalyser(); analyser.fftSize = 512; | |
| audioCtx.createMediaStreamSource(stream).connect(analyser); | |
| drawWave(); | |
| mediaRecorder = new MediaRecorder(stream); | |
| mediaRecorder.ondataavailable = e => audioChunks.push(e.data); | |
| mediaRecorder.onstop = () => { | |
| audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); | |
| document.getElementById('audio-preview').src = URL.createObjectURL(audioBlob); | |
| document.getElementById('audio-preview').style.display = 'block'; | |
| setState('ready', 'Recording ready'); | |
| setBtn('btn-play', true); setBtn('btn-clear', true); setBtn('btn-record', true); setBtn('btn-stop', false); | |
| stopWave(); | |
| }; | |
| mediaRecorder.start(); | |
| recTimer = setInterval(() => { recSeconds++; document.getElementById('rec-timer').textContent = fmt(recSeconds); }, 1000); | |
| setState('recording', 'Recording...'); setBtn('btn-record', false); setBtn('btn-stop', true); | |
| } | |
| function stopRec() { | |
| if (mediaRecorder?.state !== 'inactive') mediaRecorder.stop(); | |
| stream?.getTracks().forEach(t => t.stop()); | |
| clearInterval(recTimer); | |
| } | |
| async function handleRefUpload(input) { | |
| const file = input.files[0]; if (!file) return; | |
| const el = document.getElementById('ref-status'); | |
| el.textContent = 'Uploading...'; | |
| try { | |
| const fd = new FormData(); fd.append('file', file); fd.append('role', 'ref'); | |
| const r = await fetch(`${API_BASE}/upload`, { method: 'POST', body: fd }); | |
| const j = await safeJson(r); | |
| if (j.ok) { refPath = j.path; el.textContent = 'Voice ready'; el.style.color = 'var(--green)'; } | |
| } catch (e) { el.textContent = 'Upload error'; } | |
| } | |
| async function doTranslate() { | |
| if (!audioBlob) return alert('Record first'); | |
| setTranslateBtn(false, 'Working...'); | |
| showProgress(true); | |
| setStep('ps-upload', 'active'); | |
| try { | |
| if (!recPath) { | |
| const fd = new FormData(); fd.append('file', new File([audioBlob], 'rec.webm')); fd.append('role', 'recording'); | |
| const r = await fetch(`${API_BASE}/upload`, { method: 'POST', body: fd }); | |
| const j = await safeJson(r); | |
| if (!j.ok) throw new Error(); | |
| recPath = j.path; | |
| } | |
| setStep('ps-upload', 'done'); | |
| setStep('ps-asr', 'active'); | |
| const fd2 = new FormData(); | |
| fd2.append('recording_path', recPath); fd2.append('ref_path', refPath || ''); | |
| fd2.append('src_lang', document.getElementById('src-lang').value); | |
| fd2.append('tgt_lang', document.getElementById('tgt-lang').value); | |
| fd2.append('model_size', document.getElementById('model-size').value); | |
| const r2 = await fetch(`${API_BASE}/translate`, { method: 'POST', body: fd2 }); | |
| const j2 = await safeJson(r2); | |
| pollJob(j2.job_id); | |
| } catch (e) { setStep('ps-upload', 'error'); setTranslateBtn(true, 'Retry'); } | |
| } | |
| function pollJob(jobId) { | |
| pollTimer = setInterval(async () => { | |
| try { | |
| const r = await fetch(`${API_BASE}/job/${jobId}`); | |
| const job = await safeJson(r); | |
| if (job.status === 'done') { clearInterval(pollTimer); onJobDone(job.result); } | |
| else if (job.status === 'error') { clearInterval(pollTimer); setAllSteps('error'); } | |
| } catch (e) {} | |
| }, 2000); | |
| } | |
| function onJobDone(result) { | |
| setAllSteps('done'); | |
| document.getElementById('output-card').style.display = 'block'; | |
| document.getElementById('out-transcript').textContent = result.transcript; | |
| document.getElementById('out-translation').textContent = result.translation; | |
| if (result.audio_b64) { | |
| const aud = document.getElementById('out-audio'); | |
| aud.src = `data:audio/wav;base64,${result.audio_b64}`; | |
| aud.play().catch(() => {}); | |
| } | |
| setTranslateBtn(true, 'Translate Again'); | |
| } | |
| // UI & Wave Helpers (Kept from original) | |
| function drawWave() { | |
| const W = canvas.offsetWidth, H = canvas.offsetHeight; | |
| canvas.width = W; canvas.height = H; | |
| const buf = new Uint8Array(analyser.frequencyBinCount); | |
| function draw() { | |
| animFrame = requestAnimationFrame(draw); analyser.getByteTimeDomainData(buf); | |
| ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, W, H); | |
| ctx.lineWidth = 2; ctx.strokeStyle = '#f85149'; ctx.beginPath(); | |
| let x = 0, sw = W / buf.length; | |
| buf.forEach((v, i) => { let y = (v / 128) * H / 2; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); x += sw; }); | |
| ctx.stroke(); | |
| } | |
| draw(); | |
| } | |
| function stopWave() { cancelAnimationFrame(animFrame); } | |
| function setState(s, m) { document.getElementById('rec-dot').className = 'rec-indicator ' + s; document.getElementById('rec-state-text').textContent = m; } | |
| function setBtn(id, e) { document.getElementById(id).disabled = !e; } | |
| function setTranslateBtn(e, l) { const b = document.getElementById('btn-translate'); b.disabled = !e; b.textContent = l; } | |
| function showProgress(s) { document.getElementById('progress-wrap').style.display = s ? '' : 'none'; } | |
| function setStep(id, c) { document.getElementById(id).className = 'p-step ' + c; } | |
| function setAllSteps(c) { ['ps-upload','ps-asr','ps-translate','ps-tts','ps-done'].forEach(id => setStep(id, c)); } | |
| function fmt(s) { return String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); } | |
| async function safeJson(r) { try { return await r.json(); } catch(e) { return {ok:false}; } } | |
| function playBack() { document.getElementById('audio-preview').play(); } | |
| function clearRec() { stopRec(); audioBlob = null; document.getElementById('audio-preview').style.display='none'; setState('ready', 'Cleared'); setBtn('btn-play', false); } | |
| </script> | |
| </body> | |
| </html> |