Spaces:
Running
Running
Download static/app.js from MattSymetry/musicSlip: direct link, hf CLI and curl.
- Browser
- Download file 11.2 kB
-
https://huggingface.co/spaces/MattSymetry/musicSlip/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/MattSymetry/musicSlip/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/MattSymetry/musicSlip/resolve/main/static/app.js
11.2 kB
| ; | |
| 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]) => | |
| `<div class="totals__row"><span>${k}</span><span>${v}</span></div>` | |
| ) | |
| .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" | |
| ); | |
| }); | |
| } | |