musicSlip / static /app.js
MattSymetry's picture
Upload 11 files
552b5b3 verified
Raw History Blame Contribute Delete
11.2 kB
"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]) =>
`<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"
);
});
}