/* ============================================================ 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(); }, }; }