/* ============================================================
SoundCloud-Style Audio Recorder + Waveform Display + Analyzer
============================================================
*/
// Global variables
let audioContext;
let gumStream;
let rec;
let input;
// DOM Elements
const recordBtn = document.getElementById("recordButton");
const stopBtn = document.getElementById("stopButton");
const pauseBtn = document.getElementById("pauseButton");
const recordingsList = document.getElementById("recordingsList");
const uploadButton = document.getElementById("upload-button");
const uploadInput = document.getElementById("audio-file");
const responseBox = document.getElementById("response");
const metadataBox = document.getElementById("metadata-display");
// ==============================
// Recording Controls
// ==============================
recordBtn.addEventListener("click", startRecording);
stopBtn.addEventListener("click", stopRecording);
pauseBtn.addEventListener("click", pauseRecording);
// Start recording
function startRecording() {
console.log("Recording started...");
recordBtn.disabled = true;
stopBtn.disabled = false;
pauseBtn.disabled = false;
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
document.getElementById("formats").innerText =
"Sample rate: " + audioContext.sampleRate + " Hz";
gumStream = stream;
input = audioContext.createMediaStreamSource(stream);
rec = new Recorder(input, { numChannels: 1 });
rec.record();
}).catch(function (e) {
console.error("Could not start recording:", e);
recordBtn.disabled = false;
stopBtn.disabled = true;
pauseBtn.disabled = true;
});
}
// Pause recording
function pauseRecording() {
if (rec.recording) {
rec.stop();
pauseBtn.innerText = "Resume";
} else {
rec.record();
pauseBtn.innerText = "Pause";
}
}
// Stop recording
function stopRecording() {
console.log("Recording stopped.");
stopBtn.disabled = true;
recordBtn.disabled = false;
pauseBtn.disabled = true;
pauseBtn.innerText = "Pause";
rec.stop();
gumStream.getAudioTracks()[0].stop();
rec.exportWAV(createWaveformItem);
}
// ==============================
// File Upload
// ==============================
uploadButton.addEventListener("click", () => {
if (!uploadInput.files.length) {
alert("Select at least one file.");
return;
}
Array.from(uploadInput.files).forEach((file) => handleUploadedFile(file));
});
function handleUploadedFile(file) {
const url = URL.createObjectURL(file);
createWaveformItem(file, url);
}
// ==============================
// Create SoundCloud-like Waveform
// ==============================
function createWaveformItem(blob, forcedUrl = null) {
const url = forcedUrl || URL.createObjectURL(blob);
const li = document.createElement("li");
li.className = "wave-item";
const container = document.createElement("div");
container.className = "wave-canvas";
// Waveform and its temporal analysis share one column, so both use the same time axis
const waveCol = document.createElement("div");
waveCol.className = "wave-col";
const timelineEl = document.createElement("div");
timelineEl.className = "timeline";
waveCol.appendChild(container);
waveCol.appendChild(timelineEl);
const meta = document.createElement("div");
meta.className = "wave-meta";
meta.innerHTML = `
${blob.name || "recording.wav"}
`;
// Play button
const playBtn = document.createElement("button");
playBtn.className = "btn btn-play btn-sm";
playBtn.innerText = "Play";
// Analyze button
const analyzeBtn = document.createElement("button");
analyzeBtn.className = "btn btn-outline-secondary btn-sm";
analyzeBtn.innerText = "Analyze";
const controls = document.createElement("div");
controls.className = "wave-controls";
controls.appendChild(analyzeBtn);
controls.appendChild(playBtn);
li.appendChild(controls);
li.appendChild(waveCol);
li.appendChild(meta);
recordingsList.appendChild(li);
// Create WaveSurfer
const wavesurfer = WaveSurfer.create({
container,
autoCenter: true,
waveColor: "#bbb",
progressColor: "#ff5500",
cursorColor: "#333",
barWidth: 2,
height: 70,
responsive: true,
});
// Analyze
const item = { blob, wavesurfer, timelineEl, abort: null, timeline: null };
analyzeBtn.addEventListener("click", () => {
analyzeAudio(item);
});
// Toggle play/pause
playBtn.addEventListener("click", () => {
wavesurfer.playPause();
playBtn.innerText = wavesurfer.isPlaying() ? "Pause" : "Play";
});
wavesurfer.on("finish", () => { playBtn.innerText = "Play"; });
// Keep the timeline's playback cursor in sync with the audio
const syncCursor = () => item.timeline && item.timeline.setCursor(wavesurfer.getCurrentTime());
wavesurfer.on("audioprocess", syncCursor);
wavesurfer.on("seek", syncCursor);
wavesurfer.load(url);
// Metadata
if (metadataBox) blob.arrayBuffer().then((buffer) => {
const ctx = new AudioContext();
ctx.decodeAudioData(buffer).then((decoded) => {
metadataBox.innerHTML = `
Channels: ${decoded.numberOfChannels}
Duration: ${decoded.duration.toFixed(2)}s
Sample Rate: ${decoded.sampleRate} Hz
`;
});
});
}
// ==============================
// Audio Analysis API (placeholder)
// ==============================
async function analyzeAudio(item) {
const blob = item.blob;
// Check if the response element exists
const responseBox = document.getElementById('response');
if (!responseBox) return;
responseBox.innerHTML = "Analyzing...";
// Convert Blob to File
const file = new File([blob], blob.name || "recording.wav", { type: blob.type });
const formData = new FormData();
formData.append("files", file);
try {
// Replace the URL with your real backend endpoint
const res = await fetch("/predict/", {
method: "POST",
body: formData,
});
const data = await res.json();
let message = "Prediction Failed!";
if (data[0]?.status === "success") {
const item = data[0];
const confidencePercent = Math.round(item.confidence * 100);
message = `${item.filename} is ${item.label} with confidence of ${confidencePercent}%`;
}
let messageVolume = "... ";
if (data[0]?.volume === "silent") {
messageVolume = "The audio appears to be silent or contains no detectable speech. Please record/select another file! ";
message = ""
}
if (data[0]?.volume === "augmented") {
messageVolume = "The detected speech volume was low, so a volume augmentation was applied before analysis. ";
}
const volumeColor = "orange";
let predictionColor = message.includes("Genuine") ? "green" : "red";
const silent = data[0]?.volume === "silent";
responseBox.innerHTML = `
${messageVolume}
${message}
`;
// Temporal analysis runs after the global score (one CPU-heavy job at a time)
if (data[0]?.status === "success" && !silent) {
runTimeline(item, file);
}
} catch (err) {
responseBox.innerHTML = `/!\ Analysis failed.
`;
console.error(err);
}
}
// ==============================
// Temporal Analysis (sliding window)
// ==============================
const SVG_NS = "http://www.w3.org/2000/svg";
const TL = {
height: 150,
padTop: 10,
padBottom: 22, // room for the time axis labels
ink: "#2b3a55", // the P(genuine) line
genuineTint: "rgba(12, 163, 12, 0.08)",
spoofTint: "rgba(208, 59, 59, 0.08)",
grid: "#e6e6e6",
cursor: "#ff5500", // same orange as the waveform progress
};
function svgEl(tag, attrs = {}, parent = null) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
if (parent) parent.appendChild(el);
return el;
}
const fmtPct = (p) => `${(p * 100).toFixed(1)}%`;
const fmtSec = (t) => `${t.toFixed(t < 10 ? 2 : 1)} s`;
function niceTimeStep(duration, widthPx) {
const maxTicks = Math.max(2, Math.floor(widthPx / 60));
const steps = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120];
return steps.find((s) => duration / s <= maxTicks) || 300;
}
// Read an NDJSON stream line by line
async function* readNdjson(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (line) yield JSON.parse(line);
}
}
if (buffer.trim()) yield JSON.parse(buffer);
}
async function runTimeline(item, file) {
// A new analysis of the same item replaces the running one
if (item.abort) item.abort.abort();
item.abort = new AbortController();
const windowSec = document.getElementById("tl-window")?.value || "4";
const hopSec = document.getElementById("tl-hop")?.value || "1";
const chart = createTimelineChart(item);
item.timeline = chart;
chart.setStatus("Temporal analysis: uploading…");
const formData = new FormData();
formData.append("file", file);
formData.append("window_sec", windowSec);
formData.append("hop_sec", hopSec);
try {
const res = await fetch("/timeline/", {
method: "POST",
body: formData,
signal: item.abort.signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
for await (const msg of readNdjson(res)) {
if (msg.n_windows !== undefined) chart.setHeader(msg);
else chart.addSegment(msg);
}
chart.finish();
} catch (err) {
if (err.name === "AbortError") return;
console.error(err);
chart.setStatus(`Temporal analysis failed: ${err.message}`);
}
}
function createTimelineChart(item) {
const root = item.timelineEl;
root.replaceChildren();
root.classList.add("active");
const status = document.createElement("div");
status.className = "timeline-status";
const plot = document.createElement("div");
plot.className = "timeline-plot";
const svg = svgEl("svg", { height: TL.height, role: "img" }, plot);
const tip = document.createElement("div");
tip.className = "timeline-tip";
plot.appendChild(tip);
// Table view: the same numbers without hovering
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "Segment values";
const table = document.createElement("table");
const thead = table.createTHead().insertRow();
["Start", "End", "P(genuine)", "Decision"].forEach((h) => {
const th = document.createElement("th");
th.textContent = h;
thead.appendChild(th);
});
const tbody = table.createTBody();
details.append(summary, table);
root.append(status, plot, details);
const state = { header: null, segments: [], cursor: null, hover: null, done: false };
const duration = () => {
const d = item.wavesurfer.getDuration();
return d > 0 ? d : state.header?.duration || 1;
};
const geom = () => {
const w = plot.clientWidth || 600;
const h = TL.height;
const y0 = TL.padTop, y1 = h - TL.padBottom;
return {
w, h, y0, y1,
x: (t) => (t / duration()) * w,
y: (p) => y1 - p * (y1 - y0),
t: (px) => Math.min(Math.max(px / w, 0), 1) * duration(),
};
};
const center = (s) => (s.start + s.end) / 2;
function render() {
const g = geom();
svg.replaceChildren();
svg.setAttribute("viewBox", `0 0 ${g.w} ${g.h}`);
// Zones: above 50% = genuine, below = spoof
svgEl("rect", { x: 0, y: g.y0, width: g.w, height: g.y(0.5) - g.y0, fill: TL.genuineTint }, svg);
svgEl("rect", { x: 0, y: g.y(0.5), width: g.w, height: g.y1 - g.y(0.5), fill: TL.spoofTint }, svg);
// Hovered window span
if (state.hover !== null) {
const s = state.segments[state.hover];
svgEl("rect", {
x: g.x(s.start), y: g.y0, width: Math.max(1, g.x(s.end) - g.x(s.start)),
height: g.y1 - g.y0, fill: "rgba(43, 58, 85, 0.07)",
}, svg);
}
// Grid at 0 / 50 / 100 %, decision threshold dashed
[0, 0.5, 1].forEach((p) => {
svgEl("line", {
x1: 0, x2: g.w, y1: g.y(p), y2: g.y(p),
stroke: p === 0.5 ? "#9aa0a6" : TL.grid,
"stroke-width": 1, "stroke-dasharray": p === 0.5 ? "4 3" : "",
}, svg);
});
const ylab = (txt, p, dy) => {
const t = svgEl("text", { x: g.w - 4, y: g.y(p) + dy, "text-anchor": "end" }, svg);
t.textContent = txt;
};
ylab("100%", 1, 11);
ylab("50%", 0.5, -4);
ylab("0%", 0, -4);
const zlab = (txt, y) => {
const t = svgEl("text", { x: 4, y, "font-weight": 600 }, svg);
t.textContent = txt;
};
zlab("▲ Genuine", g.y0 + 12);
zlab("▼ Spoof", g.y1 - 5);
// Time axis
const step = niceTimeStep(duration(), g.w);
for (let t = 0; t <= duration() + 1e-9; t += step) {
const x = g.x(t);
svgEl("line", { x1: x, x2: x, y1: g.y1, y2: g.y1 + 4, stroke: "#bbb" }, svg);
const lbl = svgEl("text", {
x, y: g.h - 6,
"text-anchor": t === 0 ? "start" : x > g.w - 20 ? "end" : "middle",
}, svg);
lbl.textContent = `${+t.toFixed(2)}s`;
}
// P(genuine) line through window centres; silent windows break the line
let d = "";
let pen = false;
state.segments.forEach((s) => {
if (s.silent) { pen = false; return; }
d += `${pen ? "L" : "M"}${g.x(center(s)).toFixed(1)},${g.y(s.bonafide_prob).toFixed(1)}`;
pen = true;
});
if (d) {
svgEl("path", {
d, fill: "none", stroke: TL.ink, "stroke-width": 2,
"stroke-linejoin": "round", "stroke-linecap": "round",
}, svg);
}
// Markers (only when sparse enough to stay readable)
const showDots = state.segments.length <= 60;
state.segments.forEach((s, i) => {
const cx = g.x(center(s));
if (s.silent) {
svgEl("line", {
x1: cx - 4, x2: cx + 4, y1: g.y1 - 3, y2: g.y1 - 3,
stroke: "#adb5bd", "stroke-width": 2, "stroke-linecap": "round",
}, svg);
return;
}
if (!showDots && i !== state.hover) return;
svgEl("circle", {
cx, cy: g.y(s.bonafide_prob), r: i === state.hover ? 5 : 3.5,
fill: TL.ink, stroke: "#fff", "stroke-width": 2,
}, svg);
});
// Crosshair on hover
if (state.hover !== null) {
const x = g.x(center(state.segments[state.hover]));
svgEl("line", { x1: x, x2: x, y1: g.y0, y2: g.y1, stroke: TL.ink, "stroke-width": 1, opacity: 0.4 }, svg);
}
// Playback cursor
if (state.cursor !== null) {
const x = g.x(state.cursor);
svgEl("line", { x1: x, x2: x, y1: 0, y2: g.y1, stroke: TL.cursor, "stroke-width": 2 }, svg);
}
svg.setAttribute("aria-label", ariaSummary());
}
function ariaSummary() {
const scored = state.segments.filter((s) => !s.silent);
if (!scored.length) return "Temporal analysis: no scored segments yet";
const spoofed = scored.filter((s) => s.bonafide_prob < 0.5).length;
return `Probability of genuine speech over time: ${spoofed} of ${scored.length} segments below 50%`;
}
function updateStatus() {
const h = state.header;
if (!h) return;
const n = state.segments.length;
const cfg = `window ${h.window_sec} s · hop ${h.hop_sec} s`;
status.replaceChildren();
const strong = document.createElement("strong");
if (!state.done) {
strong.textContent = `Temporal analysis: ${n} / ${h.n_windows} segments`;
status.append(strong, ` · ${cfg} · computing…`);
return;
}
const scored = state.segments.filter((s) => !s.silent);
const spoofed = scored.filter((s) => s.bonafide_prob < 0.5).length;
const silent = n - scored.length;
strong.textContent = `${spoofed} of ${scored.length} segments look spoofed`;
status.append(
strong,
` · ${cfg}${silent ? ` · ${silent} silent segment${silent > 1 ? "s" : ""} not scored` : ""}`,
);
}
function addRow(s) {
const row = tbody.insertRow();
[
fmtSec(s.start),
fmtSec(s.end),
s.silent ? "—" : fmtPct(s.bonafide_prob),
s.silent ? "silent" : s.bonafide_prob >= 0.5 ? "Genuine" : "Spoof",
].forEach((v) => { row.insertCell().textContent = v; });
}
// Hover: snap to the nearest window centre
function showTip(i) {
const s = state.segments[i];
const g = geom();
tip.replaceChildren();
const value = document.createElement("div");
value.className = "tip-value";
value.textContent = s.silent ? "silent" : `${fmtPct(s.bonafide_prob)} genuine`;
const label = document.createElement("div");
label.className = "tip-label";
label.textContent = `${fmtSec(s.start)} – ${fmtSec(s.end)}`
+ (s.silent ? " · not scored" : ` · ${s.bonafide_prob >= 0.5 ? "Genuine" : "Spoof"}`);
tip.append(value, label);
tip.style.display = "block";
const x = g.x(center(s));
const tw = tip.offsetWidth;
tip.style.left = `${Math.min(Math.max(x - tw / 2, 0), g.w - tw)}px`;
tip.style.top = `${Math.max(0, (s.silent ? g.y1 : g.y(s.bonafide_prob)) - tip.offsetHeight - 10)}px`;
}
svg.addEventListener("pointermove", (e) => {
if (!state.segments.length) return;
const t = geom().t(e.offsetX);
let best = 0;
state.segments.forEach((s, i) => {
if (Math.abs(center(s) - t) < Math.abs(center(state.segments[best]) - t)) best = i;
});
if (best !== state.hover) {
state.hover = best;
render();
}
showTip(best);
});
svg.addEventListener("pointerleave", () => {
state.hover = null;
tip.style.display = "none";
render();
});
// Click to play from that point
svg.addEventListener("click", (e) => {
const g = geom();
item.wavesurfer.seekTo(g.t(e.offsetX) / duration());
});
new ResizeObserver(() => render()).observe(plot);
render();
return {
setStatus(text) { status.textContent = text; },
setHeader(h) { state.header = h; updateStatus(); render(); },
addSegment(s) { state.segments.push(s); addRow(s); updateStatus(); render(); },
finish() { state.done = true; updateStatus(); render(); },
setCursor(t) { state.cursor = t; render(); },
};
}