voxsplit / frontend /index.html
Ajay
Add processing-time metrics
25bd0ba
Raw
History Blame Contribute Delete
19.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VoxSplit · Transcribe + Gender</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #07070d;
--text: #f4f5fb;
--muted: #9ea3bb;
--glass: rgba(255, 255, 255, 0.045);
--glass-2: rgba(255, 255, 255, 0.03);
--stroke: rgba(255, 255, 255, 0.09);
--stroke-strong: rgba(255, 255, 255, 0.18);
--violet: #a855f7;
--pink: #ec4899;
--cyan: #22d3ee;
--male: #38bdf8;
--female: #fb7185;
--uncertain: #9ea3bb;
--grad: linear-gradient(115deg, #a855f7 0%, #ec4899 50%, #22d3ee 100%);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
/* animated gradient mesh backdrop */
.blob { position: fixed; border-radius: 50%; filter: blur(90px); opacity: .55; z-index: 0; pointer-events: none; }
.b1 { width: 520px; height: 520px; background: #7c3aed; top: -160px; left: -120px; animation: float1 16s ease-in-out infinite; }
.b2 { width: 480px; height: 480px; background: #ec4899; top: 10%; right: -160px; animation: float2 19s ease-in-out infinite; }
.b3 { width: 460px; height: 460px; background: #06b6d4; bottom: -200px; left: 25%; animation: float3 22s ease-in-out infinite; }
@keyframes float1 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(60px,50px)} }
@keyframes float2 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(-50px,40px)} }
@keyframes float3 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(40px,-50px)} }
.wrap { position: relative; z-index: 1; max-width: 880px; margin: 0 auto; padding: 56px 20px 100px; }
header { text-align: center; margin-bottom: 36px; }
.logo {
display: inline-flex; align-items: center; gap: 9px; padding: 7px 16px; border-radius: 999px;
background: var(--glass); border: 1px solid var(--stroke); backdrop-filter: blur(12px);
font-family: "Space Grotesk"; font-weight: 600; font-size: 13px; letter-spacing: .04em;
margin-bottom: 22px;
}
.logo .ping { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 12px var(--cyan); }
header h1 {
font-family: "Space Grotesk", sans-serif; font-weight: 700; font-size: 46px; line-height: 1.05;
margin: 0 0 14px; letter-spacing: -0.03em;
background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
}
header p { color: var(--muted); margin: 0 auto; max-width: 520px; font-size: 16px; line-height: 1.5; }
.card {
position: relative;
background: var(--glass);
border: 1px solid var(--stroke);
border-radius: 24px;
padding: 26px;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 24px 60px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.05);
margin-bottom: 24px;
}
.drop {
border: 1.5px dashed var(--stroke-strong); border-radius: 20px; padding: 40px 24px;
text-align: center; color: var(--muted); cursor: pointer; transition: .25s cubic-bezier(.2,.8,.2,1);
background: var(--glass-2);
}
.drop:hover, .drop.over {
border-color: var(--violet); color: var(--text);
background: rgba(168,85,247,.07); transform: translateY(-2px);
box-shadow: 0 12px 30px rgba(168,85,247,.16);
}
.drop .ic {
width: 56px; height: 56px; margin: 0 auto 14px; border-radius: 16px; display: grid; place-items: center;
background: var(--grad); box-shadow: 0 10px 26px rgba(236,72,153,.35); font-size: 26px;
}
.drop .big { font-family: "Space Grotesk"; font-size: 17px; font-weight: 600; color: var(--text); }
.drop .big span { background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.fileName { font-size: 13px; color: var(--cyan); margin-top: 12px; font-weight: 500; }
.btn {
margin-top: 22px; width: 100%; padding: 16px; border: none; border-radius: 16px; position: relative;
background: var(--grad); background-size: 200% 200%; color: white; font-size: 15.5px; font-weight: 700;
font-family: "Space Grotesk"; letter-spacing: .01em; cursor: pointer; transition: .2s;
box-shadow: 0 14px 34px rgba(168,85,247,.32); animation: shimmer 6s ease infinite;
}
@keyframes shimmer { 0%,100%{background-position:0% 50%} 50%{background-position:100% 50%} }
.btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; animation: none; }
.btn:not(:disabled):hover { transform: translateY(-2px); box-shadow: 0 18px 44px rgba(236,72,153,.42); }
.btn:not(:disabled):active { transform: translateY(0); }
.hint { font-size: 12.5px; color: var(--muted); margin-top: 14px; text-align: center; }
.status { display: none; align-items: center; justify-content: center; gap: 12px; color: var(--text); margin-top: 18px; font-size: 14px; }
.status.show { display: flex; }
.spinner { width: 20px; height: 20px; border: 2.5px solid var(--stroke); border-top-color: var(--violet); border-right-color: var(--pink); border-radius: 50%; animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.error { display: none; color: #fecaca; background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3);
padding: 13px 16px; border-radius: 14px; margin-top: 16px; font-size: 14px; }
.error.show { display: block; }
audio { width: 100%; margin-bottom: 20px; border-radius: 12px; filter: saturate(1.1); }
audio::-webkit-media-controls-panel { background: var(--glass-2); }
.meta { display: flex; gap: 9px; flex-wrap: wrap; margin-bottom: 18px; }
.pill { font-size: 12px; padding: 6px 13px; border-radius: 999px; background: var(--glass-2);
border: 1px solid var(--stroke); color: var(--muted); font-weight: 500; }
.timing { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 24px; }
.tstat { background: var(--glass-2); border: 1px solid var(--stroke); border-radius: 16px; padding: 14px 16px; }
.tstat .k { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
.tstat .v { font-family: "Space Grotesk"; font-weight: 700; font-size: 19px; font-variant-numeric: tabular-nums; }
.tstat.total { border-color: rgba(168,85,247,.4); background: rgba(168,85,247,.08); }
.tstat.total .v { background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.section-title { font-family: "Space Grotesk"; font-size: 13px; letter-spacing: .12em; text-transform: uppercase;
color: var(--muted); margin: 0 0 12px; }
.speakers { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 26px; }
.spk {
display: flex; align-items: flex-start; gap: 13px; padding: 15px 18px; border-radius: 18px;
background: var(--glass-2); border: 1px solid var(--stroke); width: 280px; flex: 1 1 260px; max-width: 340px; transition: .2s;
}
.spk:hover { border-color: var(--stroke-strong); transform: translateY(-2px); }
.spk .info { flex: 1; min-width: 0; }
.avatar { width: 40px; height: 40px; border-radius: 50%; flex: none; display: grid; place-items: center;
font-family: "Space Grotesk"; font-weight: 700; font-size: 15px; color: #fff; position: relative; margin-top: 2px; }
.dot { width: 11px; height: 11px; border-radius: 50%; flex: none; }
.spk .name { font-family: "Space Grotesk"; font-weight: 600; font-size: 14.5px; display: flex; align-items: center; gap: 8px; }
.spk .sub { font-size: 12px; color: var(--muted); margin-top: 8px; font-variant-numeric: tabular-nums; }
.bars { margin-top: 10px; display: flex; flex-direction: column; gap: 7px; }
.bar { display: flex; align-items: center; gap: 9px; font-size: 11.5px; }
.bar .lab { width: 48px; color: var(--muted); text-transform: capitalize; }
.bar .track { flex: 1; height: 7px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; }
.bar .fill { height: 100%; width: 0; border-radius: 999px; transition: width .8s cubic-bezier(.2,.85,.25,1); }
.bar .pct { width: 38px; text-align: right; color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.bar.female .fill { background: linear-gradient(90deg, #fb7185, #f472b6); }
.bar.male .fill { background: linear-gradient(90deg, #38bdf8, #22d3ee); }
.bar.top .lab { color: var(--text); font-weight: 600; }
.badge { font-size: 10.5px; padding: 3px 10px; border-radius: 999px; font-weight: 700; text-transform: capitalize; letter-spacing: .02em; }
.badge.male { background: rgba(56,189,248,.16); color: var(--male); border: 1px solid rgba(56,189,248,.3); }
.badge.female { background: rgba(251,113,133,.16); color: var(--female); border: 1px solid rgba(251,113,133,.3); }
.badge.uncertain { background: rgba(158,163,187,.14); color: var(--uncertain); border: 1px solid rgba(158,163,187,.25); }
.transcript { display: flex; flex-direction: column; gap: 10px; }
.seg {
display: grid; grid-template-columns: 58px 1fr; gap: 16px; padding: 15px 18px; position: relative;
border-radius: 16px; border: 1px solid var(--stroke); cursor: pointer; transition: .18s cubic-bezier(.2,.8,.2,1);
background: var(--glass-2); overflow: hidden;
}
.seg::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: transparent; transition: .18s; }
.seg:hover { border-color: var(--stroke-strong); transform: translateX(2px); }
.seg.active { border-color: rgba(168,85,247,.5); background: rgba(168,85,247,.09); box-shadow: 0 8px 24px rgba(168,85,247,.18); }
.seg.active::before { background: var(--grad); }
.seg .time { font-variant-numeric: tabular-nums; font-size: 12px; color: var(--muted); padding-top: 3px; font-weight: 500; }
.seg .who { display: flex; align-items: center; gap: 9px; margin-bottom: 6px; }
.seg .who b { font-family: "Space Grotesk"; font-size: 12.5px; }
.seg .txt { font-size: 15px; line-height: 1.55; color: #e9eaf4; }
.hidden { display: none; }
@media (max-width: 560px) {
header h1 { font-size: 34px; }
.wrap { padding-top: 40px; }
}
</style>
</head>
<body>
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="blob b3"></div>
<div class="wrap">
<header>
<div class="logo"><span class="ping"></span> VoxSplit</div>
<h1>Hear who said what.</h1>
<p>Drop a WAV and get a diarized, timestamped transcript with per-speaker gender — synced live to playback.</p>
</header>
<div class="card">
<div id="drop" class="drop">
<div class="ic">🎙️</div>
<div class="big">Drop your <span>.wav</span> here, or click to browse</div>
<div id="fileName" class="fileName"></div>
</div>
<input id="fileInput" type="file" accept=".wav,audio/wav,audio/x-wav" class="hidden" />
<button id="goBtn" class="btn" disabled>Transcribe &amp; Analyze ✨</button>
<div class="hint">Diarized, timestamped transcription with per-speaker gender. Longer files take a bit.</div>
<div id="status" class="status"><div class="spinner"></div><span id="statusText">Working…</span></div>
<div id="error" class="error"></div>
</div>
<div id="results" class="card hidden">
<audio id="player" controls preload="auto"></audio>
<div class="meta" id="meta"></div>
<div class="section-title">Processing time</div>
<div class="timing" id="timing"></div>
<div id="speakersWrap">
<div class="section-title">Speakers</div>
<div class="speakers" id="speakers"></div>
</div>
<div class="section-title">Transcript</div>
<div class="transcript" id="transcript"></div>
</div>
</div>
<script>
const SPEAKER_COLORS = ["#a855f7", "#22d3ee", "#fb7185", "#34d399", "#fbbf24", "#60a5fa", "#f472b6", "#c084fc"];
const fileInput = document.getElementById("fileInput");
const drop = document.getElementById("drop");
const fileName = document.getElementById("fileName");
const goBtn = document.getElementById("goBtn");
const statusEl = document.getElementById("status");
const statusText = document.getElementById("statusText");
const errorEl = document.getElementById("error");
const results = document.getElementById("results");
const player = document.getElementById("player");
const meta = document.getElementById("meta");
const timingEl = document.getElementById("timing");
const speakersEl = document.getElementById("speakers");
const transcriptEl = document.getElementById("transcript");
let selectedFile = null;
let segEls = [];
const colorFor = (id, order) => SPEAKER_COLORS[order % SPEAKER_COLORS.length];
const fmt = (s) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
};
drop.addEventListener("click", () => fileInput.click());
["dragover", "dragenter"].forEach(e =>
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.add("over"); }));
["dragleave", "drop"].forEach(e =>
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.remove("over"); }));
drop.addEventListener("drop", (ev) => {
if (ev.dataTransfer.files.length) setFile(ev.dataTransfer.files[0]);
});
fileInput.addEventListener("change", () => { if (fileInput.files.length) setFile(fileInput.files[0]); });
function setFile(f) {
selectedFile = f;
fileName.textContent = `✓ ${f.name} · ${(f.size / 1048576).toFixed(2)} MB`;
goBtn.disabled = false;
}
goBtn.addEventListener("click", async () => {
if (!selectedFile) return;
errorEl.classList.remove("show");
results.classList.add("hidden");
statusEl.classList.add("show");
statusText.textContent = "Uploading & transcribing…";
goBtn.disabled = true;
const fd = new FormData();
fd.append("file", selectedFile);
try {
const res = await fetch("/api/transcribe", { method: "POST", body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Request failed");
render(data);
} catch (e) {
errorEl.textContent = "Error: " + e.message;
errorEl.classList.add("show");
} finally {
statusEl.classList.remove("show");
goBtn.disabled = false;
}
});
function render(data) {
results.classList.remove("hidden");
player.src = data.audio_url + "?t=" + Date.now();
meta.innerHTML = "";
if (data.language_code) meta.appendChild(pill("🌐 " + data.language_code));
meta.appendChild(pill(data.segments.length + " segments"));
meta.appendChild(pill(Object.keys(data.speakers).length + " speakers"));
renderTiming(data.timing);
const order = {};
Object.keys(data.speakers).sort().forEach((id, i) => (order[id] = i));
speakersEl.innerHTML = "";
Object.keys(data.speakers).sort().forEach((id) => {
const s = data.speakers[id];
const c = colorFor(id, order[id]);
const div = document.createElement("div");
div.className = "spk";
div.innerHTML = `
<span class="avatar" style="background:${c}33; color:${c}; box-shadow:0 0 0 1px ${c}55, 0 6px 18px ${c}33">${escapeHtml(String(id))}</span>
<div class="info">
<div class="name">Speaker ${escapeHtml(String(id))}
<span class="badge ${s.label}">${s.label}</span>
</div>
${buildBars(s)}
</div>`;
speakersEl.appendChild(div);
});
// Animate the bar fills in after they're in the DOM.
requestAnimationFrame(() => {
speakersEl.querySelectorAll(".fill").forEach((el) => { el.style.width = el.dataset.w + "%"; });
});
transcriptEl.innerHTML = "";
segEls = data.segments.map((seg) => {
const c = colorFor(seg.speaker_id, order[seg.speaker_id]);
const el = document.createElement("div");
el.className = "seg";
el.dataset.start = seg.start;
el.dataset.end = seg.end;
el.innerHTML = `
<div class="time">${fmt(seg.start)}</div>
<div class="body">
<div class="who">
<span class="dot" style="background:${c}; box-shadow:0 0 10px ${c}88"></span>
<b style="color:${c}">Speaker ${escapeHtml(String(seg.speaker_id))}</b>
<span class="badge ${seg.gender}">${seg.gender}</span>
</div>
<div class="txt">${escapeHtml(seg.text)}</div>
</div>`;
el.addEventListener("click", () => {
if (seg.end > seg.start) { player.currentTime = seg.start; player.play(); }
});
transcriptEl.appendChild(el);
return el;
});
results.scrollIntoView({ behavior: "smooth" });
}
player.addEventListener("timeupdate", () => {
const t = player.currentTime;
let active = -1;
segEls.forEach((el, i) => {
const s = parseFloat(el.dataset.start), e = parseFloat(el.dataset.end);
if (e > s && t >= s && t < e) active = i;
});
segEls.forEach((el, i) => el.classList.toggle("active", i === active));
const cur = segEls[active];
if (cur) cur.scrollIntoView({ block: "nearest", behavior: "smooth" });
});
function renderTiming(t) {
timingEl.innerHTML = "";
if (!t) return;
const stats = [
{ k: "Total", v: fmtDur(t.total_seconds), cls: "total" },
{ k: "Transcription", v: fmtDur(t.transcription_seconds) },
{ k: "Gender detection", v: fmtDur(t.gender_seconds) },
{ k: "Started", v: fmtClock(t.started_at) },
{ k: "Finished", v: fmtClock(t.finished_at) },
];
stats.forEach((s) => {
const d = document.createElement("div");
d.className = "tstat" + (s.cls ? " " + s.cls : "");
d.innerHTML = `<div class="k">${s.k}</div><div class="v">${s.v}</div>`;
timingEl.appendChild(d);
});
}
function fmtDur(sec) {
if (sec == null) return "—";
if (sec < 60) return sec.toFixed(1) + "s";
const m = Math.floor(sec / 60);
const s = (sec % 60).toFixed(1);
return `${m}m ${s}s`;
}
function fmtClock(iso) {
if (!iso) return "—";
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
catch (e) { return iso; }
}
function buildBars(s) {
const probs = s.probs || {};
const keys = Object.keys(probs);
if (!keys.length) return `<div class="sub">not enough audio to analyze</div>`;
const top = keys.reduce((a, b) => (probs[b] > probs[a] ? b : a), keys[0]);
const rows = keys
.sort((a, b) => probs[b] - probs[a])
.map((k) => {
const pct = Math.round(probs[k] * 100);
return `<div class="bar ${k} ${k === top ? "top" : ""}">
<span class="lab">${escapeHtml(k)}</span>
<span class="track"><span class="fill" data-w="${pct}"></span></span>
<span class="pct">${pct}%</span>
</div>`;
})
.join("");
return `<div class="bars">${rows}</div>`;
}
function pill(t) { const s = document.createElement("span"); s.className = "pill"; s.textContent = t; return s; }
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
</script>
</body>
</html>