| <!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; |
| } |
| |
| .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 & 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); |
| }); |
| |
| |
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); |
| } |
| </script> |
| </body> |
| </html> |
|
|