App / index.html
Akshitkt001's picture
Update index.html
875bd31 verified
<!DOCTYPE html>
<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 &amp; 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">&#128266;</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>