CARD-Audio-Captioning / index.html
KarthikKB1998's picture
Deploy CARD static demo
894d9b4 verified
Raw History Blame Contribute Delete
30.6 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CARD Audio Captioning</title>
<meta name="description" content="Demo of CARD, an encoder-free audio captioning model (IEEE SLT 2026). Listen to a clip and compare the model's caption with human captions.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="431.72 529.91 95.94 95.94" role="img" aria-label="CARD"%3E%3Ctitle%3ECARD%3C/title%3E%3Cg fill="none" stroke-linecap="round" stroke-width="11.99"%3E%3Cpath stroke="%23D07A22" d="M443.72 565.89L443.72 589.87"/%3E%3Cpath stroke="%234543C4" d="M467.7 541.9L467.7 613.86 M491.69 553.9L515.67 553.9 M491.69 577.88L509.67 577.88 M491.69 601.87L497.68 601.87"/%3E%3C/g%3E%3C/svg%3E">
<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=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f5f6fb;
--surface: #ffffff;
--surface-2: #f2f4fa;
--text: #0f172a;
--logo-amber: #D07A22; --logo-indigo: #4543C4;
--muted: #5b6478;
--border: #e2e6f0;
--accent: #6366f1;
--accent-2: #8b5cf6;
--accent-3: #ec4899;
--accent-soft: rgba(99, 102, 241, .10);
--accent-line: rgba(99, 102, 241, .45);
--mark: rgba(139, 92, 246, .20);
--wave: #c7cbe0;
--shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 14px 36px rgba(15, 23, 42, .06);
--hero-glow-1: rgba(99, 102, 241, .20);
--hero-glow-2: rgba(236, 72, 153, .12);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0a0e1c; --surface: #111729; --surface-2: #161d33; --text: #e7eaf3; --muted: #9aa3b9;
--border: #252d46; --accent-soft: rgba(99, 102, 241, .16); --accent-line: rgba(129, 140, 248, .55);
--logo-amber: #F2A650; --logo-indigo: #A3A6FF;
--mark: rgba(167, 139, 250, .28); --wave: #3a4363;
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 14px 36px rgba(0, 0, 0, .35);
--hero-glow-1: rgba(99, 102, 241, .28); --hero-glow-2: rgba(236, 72, 153, .16);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0a0e1c; --surface: #111729; --surface-2: #161d33; --text: #e7eaf3; --muted: #9aa3b9;
--border: #252d46; --accent-soft: rgba(99, 102, 241, .16); --accent-line: rgba(129, 140, 248, .55);
--logo-amber: #F2A650; --logo-indigo: #A3A6FF;
--mark: rgba(167, 139, 250, .28); --wave: #3a4363;
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 14px 36px rgba(0, 0, 0, .35);
--hero-glow-1: rgba(99, 102, 241, .28); --hero-glow-2: rgba(236, 72, 153, .16);
color-scheme: dark;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
button { font: inherit; color: inherit; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 28px 16px 40px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow); }
/* hero */
.hero {
position: relative; overflow: hidden; border-radius: 24px; padding: 34px 34px 30px;
border: 1px solid var(--border); box-shadow: var(--shadow);
background:
radial-gradient(720px 280px at 0% 0%, var(--hero-glow-1), transparent 70%),
radial-gradient(620px 260px at 100% 0%, var(--hero-glow-2), transparent 70%),
var(--surface);
}
.hero-top { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.eyebrow {
display: inline-block; font-size: .7rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase;
padding: 4px 10px; border-radius: 999px; color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-line);
}
.theme-btn {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface); color: var(--muted); font-size: .78rem;
}
.theme-btn svg { width: 15px; height: 15px; }
h1 { margin: 16px 0 8px; font-size: clamp(2rem, 5vw, 2.8rem); line-height: 1.05; font-weight: 800; letter-spacing: -.025em; }
.mark { height: .8em; width: auto; margin-right: .3em; vertical-align: -.02em; }
h1 span { background: linear-gradient(90deg, var(--accent), var(--accent-2) 55%, var(--accent-3)); -webkit-background-clip: text; background-clip: text; color: transparent; }
.hero p { max-width: 760px; margin: 0 0 20px; color: var(--muted); font-size: 1.02rem; }
.links { display: flex; flex-wrap: wrap; gap: 10px; }
.pill {
display: inline-flex; align-items: center; gap: 8px; padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: .9rem;
text-decoration: none; color: var(--text); border: 1px solid var(--border); background: var(--surface);
transition: transform .15s ease, box-shadow .15s ease;
}
.pill svg { width: 16px; height: 16px; }
.pill:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(99, 102, 241, .18); }
.pill.primary { color: #fff; border-color: transparent; background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
.notice { margin: 16px 0 0; padding: 11px 14px; border-radius: 14px; font-size: .9rem; border: 1px solid var(--accent-line); background: var(--accent-soft); }
.notice b { font-weight: 600; }
/* demo */
.demo { display: grid; grid-template-columns: 5fr 6fr; gap: 20px; margin-top: 26px; align-items: start; }
.section-k { font-size: .74rem; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); margin: 0 2px 10px; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.tile {
position: relative; text-align: left; padding: 14px 14px 14px 42px; border-radius: 14px; cursor: pointer; font-weight: 600;
border: 1px solid var(--border); background: var(--surface); transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.tile::before { content: ""; position: absolute; left: 14px; top: 50%; width: 16px; height: 16px; margin-top: -8px; border-radius: 50%; border: 2px solid var(--border); }
.tile:hover { transform: translateY(-1px); border-color: var(--accent-line); }
.tile[aria-checked="true"] { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 3px rgba(99, 102, 241, .16); }
.tile[aria-checked="true"]::before { border-color: var(--accent); background: radial-gradient(circle, var(--accent) 45%, transparent 50%); }
.tile:focus-visible, .play:focus-visible, .cta:focus-visible, .theme-btn:focus-visible, canvas:focus-visible { outline: 3px solid var(--accent-line); outline-offset: 2px; }
.player { margin-top: 12px; padding: 14px 16px; }
.player-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.player-name { font-weight: 700; }
.player-time { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: .8rem; color: var(--muted); }
.wave-row { display: flex; align-items: center; gap: 12px; }
.play {
flex: 0 0 44px; width: 44px; height: 44px; border-radius: 50%; border: 0; cursor: pointer; color: #fff;
display: grid; place-items: center; background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 6px 16px rgba(99, 102, 241, .35);
}
.play svg { width: 18px; height: 18px; }
.play:disabled { opacity: .5; cursor: default; }
canvas { flex: 1; width: 100%; height: 64px; cursor: pointer; border-radius: 8px; }
audio.fallback { width: 100%; margin-top: 8px; }
.cta {
width: 100%; margin-top: 14px; padding: 15px; border: 0; border-radius: 14px; cursor: pointer; color: #fff; font-weight: 700; font-size: 1.02rem;
background: linear-gradient(90deg, var(--accent), var(--accent-2)); box-shadow: 0 10px 24px rgba(99, 102, 241, .28);
transition: transform .15s ease, filter .15s ease;
}
.cta:hover { transform: translateY(-1px); filter: brightness(1.05); }
.cta:disabled { opacity: .55; cursor: default; transform: none; }
.result { position: relative; padding: 22px 24px; min-height: 360px; display: flex; flex-direction: column; }
.result::before { content: ""; position: absolute; left: 0; top: 20px; bottom: 20px; width: 4px; border-radius: 0 4px 4px 0; background: linear-gradient(180deg, var(--accent), var(--accent-3)); }
.k { font-size: .72rem; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.result-name { font-size: 1.35rem; font-weight: 700; margin-top: 6px; }
.src { margin-top: 10px; font-size: .8rem; color: var(--muted); }
.empty-hint { margin-top: auto; padding: 18px; border-radius: 14px; text-align: center; color: var(--muted); border: 1px dashed var(--border); }
.caption { font-size: 1.5rem; line-height: 1.35; font-weight: 700; letter-spacing: -.01em; margin: 8px 0 12px; }
mark { background: var(--mark); color: inherit; border-radius: 5px; padding: 0 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { font-size: .75rem; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); background: var(--surface-2); }
.refs { margin: 8px 0 0; padding-left: 20px; }
.refs li { margin: 4px 0; }
.legend { margin-top: 12px; font-size: .78rem; color: var(--muted); }
.reveal { animation: reveal .35s ease both; }
@keyframes reveal { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .reveal { animation: none; } .tile, .pill, .cta { transition: none; } }
/* how it works */
.how { margin-top: 30px; }
.pipeline { padding: 18px 20px; }
.steps { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.step { flex: 1 1 130px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--surface-2); }
.step.accent { border-color: var(--accent-line); background: var(--accent-soft); }
.step-k { font-size: .68rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
.step-v { font-weight: 700; margin-top: 2px; }
.arrow { width: 18px; height: 18px; flex: 0 0 18px; color: var(--accent-2); }
.teacher { margin-top: 12px; padding: 10px 12px; border-radius: 12px; font-size: .88rem; color: var(--muted); border: 1px dashed var(--border); }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 12px; }
.stat { padding: 16px; }
.stat-v { font-size: 1.75rem; font-weight: 800; letter-spacing: -.02em; background: linear-gradient(90deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
.stat-k { font-size: .82rem; color: var(--muted); margin-top: 2px; line-height: 1.35; }
.note { font-size: .75rem; color: var(--muted); margin: 8px 2px 0; }
details { margin-top: 20px; padding: 0; overflow: hidden; }
summary { cursor: pointer; padding: 14px 18px; font-weight: 600; list-style: none; display: flex; justify-content: space-between; align-items: center; }
summary::-webkit-details-marker { display: none; }
summary::after { content: "+"; font-size: 1.2rem; color: var(--muted); }
details[open] summary::after { content: "-"; }
.bib { position: relative; margin: 0 18px 18px; }
pre { margin: 0; padding: 14px; border-radius: 12px; overflow-x: auto; background: var(--surface-2); border: 1px solid var(--border); font: .8rem/1.5 "JetBrains Mono", ui-monospace, monospace; }
.copy { position: absolute; top: 8px; right: 8px; padding: 4px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); cursor: pointer; font-size: .75rem; }
footer { margin-top: 26px; text-align: center; font-size: .78rem; color: var(--muted); }
@media (max-width: 820px) {
.demo { grid-template-columns: 1fr; }
.stats { grid-template-columns: repeat(2, 1fr); }
.result { min-height: 0; }
}
@media (max-width: 560px) {
.hero { padding: 24px 18px; }
.steps { flex-direction: column; align-items: stretch; }
.step { flex: 0 0 auto; }
.arrow { align-self: center; transform: rotate(90deg); }
.caption { font-size: 1.3rem; }
}
</style>
</head>
<body>
<main class="wrap">
<header class="hero">
<div class="hero-top">
<span class="eyebrow">IEEE SLT 2026</span>
<button class="theme-btn" id="theme-btn" type="button" aria-label="Switch color theme">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
<span id="theme-label">Auto</span>
</button>
</div>
<h1><svg class="mark" viewBox="110.74 292.69 144.28 90.69" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-width="12.37"><path style="stroke: var(--logo-amber)" d="M116.93 323.61V352.46M139.6 314.33V361.74"/><path style="stroke: var(--logo-indigo)" d="M162.27 306.09V369.98M184.94 298.87V377.2M209.68 316.39H248.84M209.68 338.03H238.54M209.68 359.68H228.23"/></g></svg>CARD <span>audio captioning</span></h1>
<p>Cross-component Audio Representation Distillation for Encoder-Free Audio Captioning. Listen to a clip,
reveal the model's caption, and compare it with what human annotators wrote.</p>
<nav class="links" aria-label="Project links">
<a class="pill primary" href="https://huggingface.co/KarthikKB1998/CARD-Qwen3-4B-AudioCaps" target="_blank" rel="noopener">Model on the Hub</a>
<a class="pill" href="https://colab.research.google.com/github/KarthikKolluriKB/CARD/blob/main/notebooks/CARD_demo.ipynb" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>Run it live in Colab</a>
<a class="pill" href="https://github.com/KarthikKolluriKB/CARD" target="_blank" rel="noopener">Code on GitHub</a>
</nav>
</header>
<div class="notice" role="note">
<b>How this page works:</b> the captions below were generated beforehand by the released model
<code>CARD-Qwen3-4B-AudioCaps</code> with the paper's decoding settings, and are identical to its live output
for these clips. To caption your own audio, run the model live in Colab.
</div>
<section class="demo" aria-label="Demo">
<div>
<div class="section-k" id="pick-label">1. Choose a clip</div>
<div class="tiles" id="tiles" role="radiogroup" aria-labelledby="pick-label"></div>
<div class="card player" id="player">
<div class="player-head">
<span class="player-name" id="player-name">No clip selected</span>
<span class="player-time" id="player-time">0:00 / 0:00</span>
</div>
<div class="wave-row">
<button class="play" id="play" type="button" aria-label="Play" disabled>
<svg id="play-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
</button>
<canvas id="wave" tabindex="0" aria-label="Waveform. Click to seek; left and right arrow keys skip."></canvas>
</div>
</div>
<button class="cta" id="reveal" type="button" disabled>Show CARD caption</button>
</div>
<div>
<div class="section-k">2. Compare with the human captions</div>
<div class="card result" id="result" aria-live="polite"></div>
</div>
</section>
<section class="how" aria-label="How CARD works">
<div class="section-k">How CARD works</div>
<div class="card pipeline">
<div class="steps" id="steps"></div>
<div class="teacher">Training only: a frozen CLAP-HTSAT teacher is distilled by component, its early stages into the
projector and its later stages into the language model. The teacher is removed, so there is no audio encoder at inference.</div>
</div>
<div class="stats">
<div class="card stat"><div class="stat-v">55.4</div><div class="stat-k">CIDEr-D on AudioCaps</div></div>
<div class="card stat"><div class="stat-v">13.2 M</div><div class="stat-k">audio interface parameters, 34.4 M with the encoder</div></div>
<div class="card stat"><div class="stat-v">0.4 ms</div><div class="stat-k">audio interface latency, 11.1 ms with the encoder</div></div>
<div class="card stat"><div class="stat-v">0</div><div class="stat-k">audio encoders at inference</div></div>
</div>
<p class="note">Numbers from the paper (Table II and Section IV-B).</p>
</section>
<details class="card">
<summary>Cite this work</summary>
<div class="bib">
<button class="copy" id="copy" type="button">Copy</button>
<pre id="bibtex">@inproceedings{kolluri2026card,
title = {{CARD}: Cross-component Audio Representation Distillation for Encoder-Free Audio Captioning},
author = {Kolluri, Ganesh Pavan Kartikeya Bharadwaj and Zhang, Yuchen and Kampouridis, Michael and Shekhar, Ravi},
booktitle = {Proceedings of the IEEE Spoken Language Technology Workshop (SLT)},
year = {2026}
}</pre>
</div>
</details>
<footer>Model and code released under Apache-2.0. Each sample clip keeps its original source and license, shown with its caption.</footer>
</main>
<script>
(function () {
"use strict";
var STOPWORDS = new Set(["a", "an", "the", "and", "or", "is", "are", "be", "being", "in", "on", "of", "with", "as", "to",
"by", "while", "then", "some", "it", "its", "at", "from", "for", "into", "followed", "there", "this", "that",
"several", "multiple", "times", "time"]);
var ARROW = '<svg class="arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h13m-5-6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
var PLAY = '<path d="M8 5v14l11-7z" fill="currentColor"/>';
var PAUSE = '<path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/>';
var $ = function (id) { return document.getElementById(id); };
var state = { samples: [], index: -1, revealed: false, peaks: null, loadToken: 0 };
var audio = new Audio();
audio.preload = "auto";
var decodeCtx = null;
/* ---------- theme ---------- */
var THEMES = ["auto", "light", "dark"];
function applyTheme(t) {
if (t === "auto") document.documentElement.removeAttribute("data-theme");
else document.documentElement.setAttribute("data-theme", t);
$("theme-label").textContent = t.charAt(0).toUpperCase() + t.slice(1);
requestAnimationFrame(drawWave);
}
var theme = "auto";
try { theme = localStorage.getItem("card-theme") || "auto"; } catch (e) {}
if (THEMES.indexOf(theme) < 0) theme = "auto";
applyTheme(theme);
$("theme-btn").addEventListener("click", function () {
theme = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
try { localStorage.setItem("card-theme", theme); } catch (e) {}
applyTheme(theme);
});
if (window.matchMedia) {
var mq = window.matchMedia("(prefers-color-scheme: dark)");
if (mq.addEventListener) mq.addEventListener("change", function () { requestAnimationFrame(drawWave); });
}
/* ---------- pipeline ---------- */
var steps = [["Input", "Audio clip", false], ["Front end, no parameters", "Log-Mel spectrogram", false],
["13.2 M parameters", "Audio projector", true], ["LoRA merged", "Qwen3-4B", true], ["Output", "Caption", false]];
$("steps").innerHTML = steps.map(function (s, i) {
return (i ? ARROW : "") + '<div class="step' + (s[2] ? " accent" : "") + '"><div class="step-k">' + s[0] +
'</div><div class="step-v">' + s[1] + "</div></div>";
}).join("");
/* ---------- helpers ---------- */
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
function fmt(t) {
if (!isFinite(t) || t < 0) t = 0;
var m = Math.floor(t / 60), s = Math.floor(t % 60);
return m + ":" + (s < 10 ? "0" : "") + s;
}
function stem(w) {
w = w.toLowerCase();
return (w.length > 3 && /s$/.test(w) && !/ss$/.test(w)) ? w.slice(0, -1) : w;
}
function highlighted(caption, refs) {
var refWords = new Set();
refs.forEach(function (r) { (r.match(/[A-Za-z]+/g) || []).forEach(function (w) { refWords.add(stem(w)); }); });
var frag = document.createDocumentFragment();
caption.split(/([A-Za-z]+)/).forEach(function (part) {
if (!part) return;
if (/^[A-Za-z]+$/.test(part) && !STOPWORDS.has(part.toLowerCase()) && refWords.has(stem(part))) {
frag.appendChild(el("mark", null, part));
} else {
frag.appendChild(document.createTextNode(part));
}
});
return frag;
}
function sourceLine(s) {
var line = el("div", "src");
var bits = [];
if (s.dataset) bits.push(document.createTextNode(s.dataset));
if (s.source) {
if (/^https?:\/\//.test(s.source)) {
var a = el("a", null, "source");
a.href = s.source; a.target = "_blank"; a.rel = "noopener";
bits.push(a);
} else {
bits.push(document.createTextNode(s.source));
}
}
if (s.license) bits.push(document.createTextNode("license: " + s.license));
bits.forEach(function (b, i) {
if (i) line.appendChild(document.createTextNode(" \u00b7 "));
line.appendChild(b);
});
return line;
}
/* ---------- result card ---------- */
function renderPlaceholder() {
var box = $("result");
box.textContent = "";
var s = state.samples[state.index];
box.appendChild(el("div", "k", "Selected clip"));
box.appendChild(el("div", "result-name", s ? s.name : "No clip selected"));
if (s) box.appendChild(sourceLine(s));
var hint = el("div", "empty-hint");
if (s) {
hint.appendChild(document.createTextNode("Press "));
hint.appendChild(el("b", null, "Show CARD caption"));
hint.appendChild(document.createTextNode(" to see what the model wrote for this clip."));
} else {
hint.textContent = state.samples.length ? "Choose a clip to begin." : "Sample clips are not installed yet.";
}
box.appendChild(hint);
}
function renderResult() {
var s = state.samples[state.index];
if (!s) return;
var refs = s.references || [];
var box = $("result");
box.textContent = "";
var inner = el("div", "reveal");
inner.appendChild(el("div", "k", "CARD caption"));
var cap = el("div", "caption");
cap.appendChild(highlighted(s.caption || s.expected_caption || "", refs));
inner.appendChild(cap);
var chips = el("div", "chips");
["Released model", "Beam search, 4 beams", "Generated beforehand"].forEach(function (c) { chips.appendChild(el("span", "chip", c)); });
inner.appendChild(chips);
var rk = el("div", "k", "Human reference captions");
rk.style.marginTop = "18px";
inner.appendChild(rk);
var ol = el("ol", "refs");
(refs.length ? refs : ["No reference captions."]).forEach(function (r) { ol.appendChild(el("li", null, r)); });
inner.appendChild(ol);
var legend = el("div", "legend");
legend.appendChild(el("mark", null, "highlighted"));
legend.appendChild(document.createTextNode(" words also appear in a human reference."));
inner.appendChild(legend);
var src = sourceLine(s);
src.insertBefore(document.createTextNode(s.name + " \u00b7 "), src.firstChild);
inner.appendChild(src);
box.appendChild(inner);
}
/* ---------- tiles ---------- */
function renderTiles() {
var wrap = $("tiles");
wrap.textContent = "";
state.samples.forEach(function (s, i) {
var b = el("button", "tile", s.name);
b.type = "button";
b.setAttribute("role", "radio");
b.setAttribute("aria-checked", i === state.index ? "true" : "false");
b.tabIndex = i === state.index ? 0 : -1;
b.addEventListener("click", function () { select(i); });
b.addEventListener("keydown", function (e) {
var d = (e.key === "ArrowRight" || e.key === "ArrowDown") ? 1 : (e.key === "ArrowLeft" || e.key === "ArrowUp") ? -1 : 0;
if (!d) return;
e.preventDefault();
var n = (i + d + state.samples.length) % state.samples.length;
select(n);
wrap.children[n].focus();
});
wrap.appendChild(b);
});
}
function select(i) {
if (i === state.index) return;
state.index = i;
state.revealed = false;
renderTiles();
renderPlaceholder();
loadClip(state.samples[i]);
$("reveal").disabled = false;
}
/* ---------- player ---------- */
function setPlayIcon(playing) {
$("play-icon").innerHTML = playing ? PAUSE : PLAY;
$("play").setAttribute("aria-label", playing ? "Pause" : "Play");
}
function updateTime() {
$("player-time").textContent = fmt(audio.currentTime) + " / " + fmt(audio.duration);
}
function loadClip(s) {
audio.pause();
setPlayIcon(false);
audio.src = s.url;
audio.load();
$("player-name").textContent = s.name;
$("play").disabled = false;
updateTime();
state.peaks = null;
drawWave();
var token = ++state.loadToken;
fetch(s.url).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.arrayBuffer();
}).then(function (buf) {
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) throw new Error("no Web Audio");
decodeCtx = decodeCtx || new Ctx();
return new Promise(function (res, rej) {
var p = decodeCtx.decodeAudioData(buf, res, rej);
if (p && p.then) p.then(res, rej);
});
}).then(function (decoded) {
if (token !== state.loadToken) return;
state.peaks = peaks(decoded, 400);
drawWave();
}).catch(function () {
if (token === state.loadToken) { state.peaks = "flat"; drawWave(); }
});
}
function peaks(buf, n) {
var ch = buf.getChannelData(0), step = Math.max(1, Math.floor(ch.length / n)), out = new Float32Array(n), max = 0;
for (var i = 0; i < n; i++) {
var m = 0, end = Math.min(ch.length, (i + 1) * step);
for (var j = i * step; j < end; j += 8) { var v = Math.abs(ch[j]); if (v > m) m = v; }
out[i] = m; if (m > max) max = m;
}
if (max > 0) for (var k = 0; k < n; k++) out[k] = out[k] / max;
return out;
}
function drawWave() {
var c = $("wave"), dpr = window.devicePixelRatio || 1;
var w = c.clientWidth, h = c.clientHeight;
if (!w || !h) return;
c.width = Math.round(w * dpr); c.height = Math.round(h * dpr);
var g = c.getContext("2d");
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
var css = getComputedStyle(document.documentElement);
var base = css.getPropertyValue("--wave").trim() || "#c7cbe0";
var grad = g.createLinearGradient(0, 0, w, 0);
grad.addColorStop(0, css.getPropertyValue("--accent").trim() || "#6366f1");
grad.addColorStop(1, css.getPropertyValue("--accent-3").trim() || "#ec4899");
var bar = 3, gap = 2, count = Math.max(1, Math.floor(w / (bar + gap)));
var progress = (audio.duration && isFinite(audio.duration)) ? audio.currentTime / audio.duration : 0;
for (var i = 0; i < count; i++) {
var v;
if (state.peaks && state.peaks !== "flat") {
v = state.peaks[Math.floor(i / count * state.peaks.length)];
} else {
v = state.index < 0 ? 0.06 : 0.12 + 0.05 * Math.sin(i * 0.6);
}
var bh = Math.max(3, v * (h - 6));
var x = i * (bar + gap), y = (h - bh) / 2;
g.fillStyle = (i / count) < progress ? grad : base;
if (g.roundRect) { g.beginPath(); g.roundRect(x, y, bar, bh, 1.5); g.fill(); } else { g.fillRect(x, y, bar, bh); }
}
}
var raf = 0;
function tick() { drawWave(); updateTime(); if (!audio.paused) raf = requestAnimationFrame(tick); }
audio.addEventListener("play", function () { setPlayIcon(true); cancelAnimationFrame(raf); raf = requestAnimationFrame(tick); });
audio.addEventListener("pause", function () { setPlayIcon(false); drawWave(); updateTime(); });
audio.addEventListener("ended", function () { setPlayIcon(false); drawWave(); updateTime(); });
audio.addEventListener("loadedmetadata", updateTime);
audio.addEventListener("error", function () {
if (state.index >= 0) $("player-time").textContent = "audio unavailable";
});
$("play").addEventListener("click", function () {
if (audio.paused) { var p = audio.play(); if (p && p.catch) p.catch(function () {}); } else { audio.pause(); }
});
function seekTo(ratio) {
if (!audio.duration || !isFinite(audio.duration)) return;
audio.currentTime = Math.min(Math.max(ratio, 0), 1) * audio.duration;
drawWave(); updateTime();
}
$("wave").addEventListener("pointerdown", function (e) {
var r = e.currentTarget.getBoundingClientRect();
seekTo((e.clientX - r.left) / r.width);
});
$("wave").addEventListener("keydown", function (e) {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
audio.currentTime = Math.max(0, audio.currentTime + (e.key === "ArrowRight" ? 1 : -1));
drawWave(); updateTime();
} else if (e.key === " " || e.key === "Enter") {
e.preventDefault(); $("play").click();
}
});
if (window.ResizeObserver) new ResizeObserver(drawWave).observe($("wave"));
else window.addEventListener("resize", drawWave);
/* ---------- reveal + copy ---------- */
$("reveal").addEventListener("click", function () {
if (state.index < 0) return;
state.revealed = true;
renderResult();
});
$("copy").addEventListener("click", function () {
var btn = this, text = $("bibtex").textContent;
var done = function () { btn.textContent = "Copied"; setTimeout(function () { btn.textContent = "Copy"; }, 1500); };
try { navigator.clipboard.writeText(text).then(done, function () {}); } catch (e) {}
});
/* ---------- boot ---------- */
renderPlaceholder();
drawWave();
fetch("samples/samples.json", { cache: "no-cache" }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}).then(function (data) {
state.samples = (data.samples || []).filter(function (s) { return s.file && (s.caption || s.expected_caption); })
.map(function (s) { return Object.assign({}, s, { url: "samples/" + encodeURIComponent(s.file) }); });
renderTiles();
if (state.samples.length) select(0); else renderPlaceholder();
}).catch(function () {
renderPlaceholder();
});
})();
</script>
</body>
</html>