"use strict"; const els = { slip: document.getElementById("slip"), title: document.getElementById("title"), meta: document.getElementById("meta"), dropzone: document.getElementById("dropzone"), fileInput: document.getElementById("fileInput"), spectro: document.getElementById("spectro"), spectroImg: document.getElementById("spectroImg"), status: document.getElementById("status"), statusText: document.getElementById("statusText"), foot: document.getElementById("foot"), totals: document.getElementById("totals"), controls: document.getElementById("controls"), printBtn: document.getElementById("printBtn"), }; // State of the most recently rendered slip. let currentImageUrl = null; let currentData = null; // --- receipt date line ------------------------------------------------------ (function stampMeta() { const now = new Date(); const d = now.toLocaleDateString(undefined, { year: "numeric", month: "2-digit", day: "2-digit", }); const t = now.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", }); els.meta.textContent = `${d} ${t}`; })(); // --- upload flow ------------------------------------------------------------ els.dropzone.addEventListener("click", () => els.fileInput.click()); els.fileInput.addEventListener("change", () => { const file = els.fileInput.files && els.fileInput.files[0]; if (file) render(file); }); // "Print" = download the ENTIRE slip (title, meta, rules, spectrogram, totals) // composited as one PNG. els.printBtn.addEventListener("click", async () => { if (!currentData) return; const name = slugify(els.title.textContent) || "slip"; try { const blob = await buildSlipPng(); const href = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = href; a.download = `${name}.png`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(href); } catch (err) { // Fallback: open the raw spectrogram so the user can at least save it. window.open(currentImageUrl, "_blank"); } }); function slugify(s) { return (s || "") .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } function show(el, visible) { el.hidden = !visible; } function setStatus(text, isError) { els.status.classList.toggle("is-error", !!isError); els.statusText.textContent = text; show(els.status, true); } function fmtDuration(sec) { const m = Math.floor(sec / 60); const s = Math.round(sec % 60); return `${m}:${String(s).padStart(2, "0")}`; } async function render(file) { // Enter "printing" state: hide placeholder + previous output, show spinner. show(els.dropzone, false); show(els.spectro, false); show(els.foot, false); show(els.controls, false); setStatus("GENERATING…", false); const q = document.getElementById("quality"); const mode = q ? q.value : "gray"; try { const { mono, sr } = await decodeAudioFile(file); // in-browser audio decode const data = buildSlip(mono, sr, mode); // in-browser slip render await swapInImage(data); } catch (err) { setStatus(String(err.message || err).toUpperCase(), true); // Let the user try again. show(els.dropzone, true); } } // --- in-browser generator (replaces the Python /api/render) ---------------- async function decodeAudioFile(file) { const AC = window.AudioContext || window.webkitAudioContext; const ctx = new AC(); const buf = await ctx.decodeAudioData(await file.arrayBuffer()); const n = buf.length, chs = buf.numberOfChannels; const mono = new Float32Array(n); for (let c = 0; c < chs; c++) { const d = buf.getChannelData(c); for (let i = 0; i < n; i++) mono[i] += d[i]; } if (chs > 1) for (let i = 0; i < n; i++) mono[i] /= chs; const sr = buf.sampleRate; try { ctx.close(); } catch (_) {} return { mono, sr }; } // One RBJ biquad low-pass (band-limits thermal before 1-bit dithering). function lowpass(x, sr, fc) { const w0 = 2 * Math.PI * fc / sr, cs = Math.cos(w0), sn = Math.sin(w0); const alpha = sn / (2 * 0.707), a0 = 1 + alpha; const B0 = (1 - cs) / 2 / a0, B1 = (1 - cs) / a0, B2 = (1 - cs) / 2 / a0; const A1 = -2 * cs / a0, A2 = (1 - alpha) / a0; const y = new Float32Array(x.length); let x1 = 0, x2 = 0, y1 = 0, y2 = 0; for (let i = 0; i < x.length; i++) { const xi = x[i], yi = B0 * xi + B1 * x1 + B2 * x2 - A1 * y1 - A2 * y2; x2 = x1; x1 = xi; y2 = y1; y1 = yi; y[i] = yi; } return y; } const WAVEFORM_W = 512, PEAK_TARGET = 0.9, THERMAL_FC = 2500; // mono Float32 -> waveform-raster slip PNG (data URL) + stats. Mirrors render.py. function buildSlip(mono, sr, mode) { let peak = 1e-9; for (let i = 0; i < mono.length; i++) { const a = Math.abs(mono[i]); if (a > peak) peak = a; } const g = PEAK_TARGET / peak; let y = new Float32Array(mono.length); for (let i = 0; i < mono.length; i++) y[i] = mono[i] * g; if (mode === "thermal") y = lowpass(y, sr, THERMAL_FC); const W = WAVEFORM_W, N = y.length, H = Math.max(1, Math.ceil(N / W)); const cv = document.createElement("canvas"); cv.width = W; cv.height = H; const cx = cv.getContext("2d"); const img = cx.createImageData(W, H), px = img.data; if (mode === "digital") { // 16-bit packed R(hi)/G(lo) for (let i = 0; i < W * H; i++) { const s = i < N ? y[i] : 0; const v = Math.max(0, Math.min(65535, Math.round((s * 0.5 + 0.5) * 65535))); const j = i * 4; px[j] = v >> 8; px[j + 1] = v & 255; px[j + 2] = 0; px[j + 3] = 255; } } else if (mode === "thermal") { // 1-bit error diffusion along each row for (let row = 0; row < H; row++) { let err = 0; for (let x = 0; x < W; x++) { const i = row * W + x, s = i < N ? y[i] : 0; const v = (s * 0.5 + 0.5) * 255 + err, o = v >= 127.5 ? 255 : 0; err = v - o; const j = i * 4; px[j] = px[j + 1] = px[j + 2] = o; px[j + 3] = 255; } } } else { // 8-bit grayscale for (let i = 0; i < W * H; i++) { const s = i < N ? y[i] : 0; const v = Math.max(0, Math.min(255, Math.round((s * 0.5 + 0.5) * 255))); const j = i * 4; px[j] = px[j + 1] = px[j + 2] = v; px[j + 3] = 255; } } cx.putImageData(img, 0, 0); return { image_url: cv.toDataURL("image/png"), width_px: W, height_px: H, duration_sec: N / sr, sample_rate: sr, mode, title: (els.title.textContent || "").trim(), }; } function swapInImage(data) { return new Promise((resolve) => { els.spectroImg.onload = () => { show(els.status, false); show(els.spectro, true); renderTotals(data); show(els.foot, true); currentData = data; show(els.controls, true); resolve(); }; els.spectroImg.onerror = () => { setStatus("IMAGE LOAD FAILED", true); show(els.dropzone, true); resolve(); }; // cache-bust not needed: filenames are unique per render currentImageUrl = data.image_url; els.spectroImg.src = data.image_url; }); } function totalsRows(data) { return [ ["DURATION", fmtDuration(data.duration_sec)], ["SAMPLE RATE", `${data.sample_rate} Hz`], ["QUALITY", (data.mode || "gray").toUpperCase()], ["SIZE", `${data.width_px} × ${data.height_px} px`], ]; } function renderTotals(data) { els.totals.innerHTML = totalsRows(data) .map( ([k, v]) => `
${k}${v}
` ) .join(""); } // --- full-slip compositor (for the Print download) -------------------------- const PAPER = "#fbfbf7"; const INK = "#111"; const MONO = '"SFMono-Regular", ui-monospace, Menlo, Consolas, monospace'; function wrapText(ctx, text, maxWidth) { if (!text) return [""]; const words = text.split(/\s+/); const lines = []; let line = ""; for (const w of words) { const test = line ? `${line} ${w}` : w; if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = w; } else { line = test; } } if (line) lines.push(line); return lines; } function dashedLine(ctx, x1, x2, y, W) { ctx.save(); ctx.strokeStyle = "#000"; ctx.globalAlpha = 0.6; ctx.lineWidth = Math.max(1, Math.round(W * 0.002)); ctx.setLineDash([W * 0.012, W * 0.012]); ctx.beginPath(); ctx.moveTo(x1, y); ctx.lineTo(x2, y); ctx.stroke(); ctx.restore(); } function buildSlipPng() { const img = els.spectroImg; const specW = img.naturalWidth; const specH = img.naturalHeight; const MX = Math.round(specW * 0.06); // paper side margin const W = specW + MX * 2; // full paper width const innerW = W - MX * 2; const titleSize = Math.round(W * 0.055); const metaSize = Math.round(W * 0.026); const totalsSize = Math.round(W * 0.03); const thanksSize = Math.round(W * 0.026); const gap = Math.round(W * 0.035); const padY = Math.round(W * 0.05); const titleLineH = Math.round(titleSize * 1.25); const rowH = Math.round(totalsSize * 1.55); const meas = document.createElement("canvas").getContext("2d"); meas.font = `700 ${titleSize}px ${MONO}`; const title = (els.title.textContent || "").trim().toUpperCase() || "UNTITLED"; const titleLines = wrapText(meas, title, innerW); const meta = els.meta.textContent || ""; const rows = totalsRows(currentData); // Height budget. let h = padY; h += titleLines.length * titleLineH + gap; // title h += metaSize + gap; // meta h += gap; // top rule h += specH; // spectrogram h += gap; // bottom rule h += rows.length * rowH + gap; // totals h += thanksSize; // thanks h += padY; const cv = document.createElement("canvas"); cv.width = W; cv.height = h; const ctx = cv.getContext("2d"); ctx.fillStyle = PAPER; ctx.fillRect(0, 0, W, h); ctx.fillStyle = INK; ctx.textBaseline = "top"; let y = padY; ctx.font = `700 ${titleSize}px ${MONO}`; ctx.textAlign = "center"; for (const line of titleLines) { ctx.fillText(line, W / 2, y); y += titleLineH; } y += gap; ctx.font = `${metaSize}px ${MONO}`; ctx.fillStyle = "#555"; ctx.fillText(meta, W / 2, y); ctx.fillStyle = INK; y += metaSize + gap; dashedLine(ctx, MX, W - MX, y, W); y += gap; ctx.drawImage(img, MX, y, specW, specH); y += specH + gap; dashedLine(ctx, MX, W - MX, y, W); y += gap; for (const [k, v] of rows) { ctx.font = `${totalsSize}px ${MONO}`; ctx.textAlign = "left"; ctx.fillText(k, MX, y); ctx.font = `700 ${totalsSize}px ${MONO}`; ctx.textAlign = "right"; ctx.fillText(v, W - MX, y); y += rowH; } y += gap; ctx.font = `${thanksSize}px ${MONO}`; ctx.fillStyle = "#555"; ctx.textAlign = "center"; ctx.fillText("*** SOUND PRINTED AS LIGHT ***", W / 2, y); return new Promise((resolve, reject) => { cv.toBlob( (blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), "image/png" ); }); }