musicSlip / static /loopscan.js
MattSymetry's picture
Upload 11 files
552b5b3 verified
Raw History Blame Contribute Delete
10.7 kB
// Loop-scan player (waveform): upload a slip image, scroll it past a fixed centre
// line, and read each row back as raw PCM samples (pixel brightness = amplitude).
// Fed straight to the PCM player worklet -- no STFT, no phase reconstruction.
// At speed = sample_rate / row_width rows/sec the audio plays at real-time pitch.
const SR_SLIP = 44100;
let img = null, imgData = null, imgW = 0, imgH = 0, white = 242, black = 13;
let readPos = 0, fracOut = 0, lastT = 0, readRow = 0;
let speed = 100, gainPct = 100, reverse = false, playing = true;
let decodeMode = "gray"; // auto-detected from the uploaded slip
let canvas, ctx, off, octx, audioCtx = null, node = null;
// Left/right borders (fractions of image width): only this horizontal span is
// read as samples, so a printed slip's blank side-margins can be cropped out.
// Default lined up with the downloaded slip's waveform band -- the Print
// composite adds a 6% side margin, so the band spans ~0.054..0.946 of the width.
const DEFAULT_BORDERS = [0.054, 0.946];
const B = loadBorders() || DEFAULT_BORDERS.slice();
let borderL = B[0], borderR = B[1], dragBorder = null;
const BORDER_GRAB_PX = 26, BORDER_MIN_GAP = 0.04;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
// Tell the worklet whether to apply its thermal reconstruction low-pass (on the
// continuous stream, so there's no per-row seam buzz).
function applyMode() {
if (node) node.port.postMessage({ type: "lowpass", on: decodeMode === "thermal" });
}
function loadBorders() {
try { const b = JSON.parse(localStorage.getItem("loopscanBorders")); if (b?.length === 2) return b; } catch {}
return null;
}
function saveBorders() { try { localStorage.setItem("loopscanBorders", JSON.stringify([borderL, borderR])); } catch {} }
function resize() {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(innerWidth * dpr);
canvas.height = Math.round(innerHeight * dpr);
}
function initAudio() {
if (audioCtx) { if (audioCtx.state === "suspended") audioCtx.resume(); return; }
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SR_SLIP }); }
catch { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
audioCtx.audioWorklet.addModule("/static/scanner-worklet.js").then(() => {
node = new AudioWorkletNode(audioCtx, "pcm", { outputChannelCount: [2] });
node.connect(audioCtx.destination);
node.port.postMessage({ type: "gain", v: gainPct / 100 });
applyMode();
}).catch((e) => { document.getElementById("hint").textContent = "audio error: " + e.message + " (use the HTTPS URL)"; });
if (audioCtx.state === "suspended") audioCtx.resume();
}
function loadImage(file) {
const im = new Image();
im.onload = () => {
img = im; imgW = im.naturalWidth; imgH = im.naturalHeight;
off.width = imgW; off.height = imgH;
octx.drawImage(im, 0, 0);
imgData = octx.getImageData(0, 0, imgW, imgH).data;
computeLevels(); decodeMode = detectMode(); readPos = 0; applyMode();
document.getElementById("hint").textContent = `${imgW}x${imgH} · ${decodeMode}`;
};
im.src = URL.createObjectURL(file);
}
// Detect the quality mode from the (pristine) slip: colour pixels => 16-bit
// packed; only black/white => thermal 1-bit; otherwise => 8-bit grayscale.
function detectMode() {
let color = 0, mids = 0, n = 0;
const sx = Math.max(1, imgW / 60 | 0), sy = Math.max(1, imgH / 300 | 0);
for (let y = 0; y < imgH; y += sy) for (let x = 0; x < imgW; x += sx) {
const j = (y * imgW + x) * 4, R = imgData[j], G = imgData[j + 1], Bc = imgData[j + 2];
if (Math.abs(R - G) > 6) color++;
const g = 0.299 * R + 0.587 * G + 0.114 * Bc;
if (g > 24 && g < 231) mids++;
n++;
}
if (color > n * 0.03) return "digital";
if (mids < n * 0.02) return "thermal";
return "gray";
}
// A freshly generated slip uses the exact mapping pixel = (sample*0.5+0.5)*255,
// so the inverse is fixed (no estimation): silence = mid-gray, +-1 = +-127.5.
// This is exact for the generated PNG (the normal loopscan input). `calibrate`
// switches to measured brightest/darkest levels for a faded photo of a print.
let calibrate = false;
function computeLevels() {
const s = [];
const sx = Math.max(1, imgW / 80 | 0), sy = Math.max(1, imgH / 400 | 0);
for (let y = 0; y < imgH; y += sy) for (let x = 0; x < imgW; x += sx) {
const j = (y * imgW + x) * 4;
s.push(0.299 * imgData[j] + 0.587 * imgData[j + 1] + 0.114 * imgData[j + 2]);
}
s.sort((a, b) => a - b);
white = s[s.length - 1];
black = s[0];
}
// Decode one sample at native-sample index k within the [L..R] band, per mode.
function pixelSample(k, bandN, x0) {
const total = bandN * imgH;
k = ((k % total) + total) % total;
const row = (k / bandN) | 0, col = x0 + (k - row * bandN);
const j = (row * imgW + col) * 4;
if (decodeMode === "digital") {
return clamp((imgData[j] * 256 + imgData[j + 1]) / 65535 * 2 - 1, -1, 1);
}
const lum = 0.299 * imgData[j] + 0.587 * imgData[j + 1] + 0.114 * imgData[j + 2];
if (decodeMode === "thermal") return (lum / 255 - 0.5) * 2; // worklet low-passes
const center = calibrate ? (white + black) / 2 : 127.5;
const half = calibrate ? Math.max((white - black) / 2, 1) : 127.5;
return clamp((lum - center) / half, -1, 1);
}
function loop(t) {
const dt = lastT ? (t - lastT) / 1000 : 0;
lastT = t;
if (img && node && playing) {
const Fc = audioCtx.sampleRate;
const x0 = clamp(Math.round(borderL * (imgW - 1)), 0, imgW - 1);
const x1 = clamp(Math.round(borderR * (imgW - 1)), 0, imgW - 1);
const bandN = Math.max(1, x1 - x0 + 1), total = bandN * imgH;
// Produce EXACTLY Fc samples/sec so the worklet never over/underflows (the
// mismatch is what caused the steady whine). speed = playback %, and the
// SR_SLIP/Fc term keeps pitch correct if the context isn't 44.1 kHz.
fracOut += dt * Fc;
let nOut = Math.floor(fracOut); fracOut -= nOut; if (nOut > Fc) nOut = Fc;
const ratio = (speed / 100) * (SR_SLIP / Fc) * (reverse ? -1 : 1);
const out = new Float32Array(nOut);
for (let i = 0; i < nOut; i++) {
const i0 = Math.floor(readPos), f = readPos - i0; // linear interp
const s0 = pixelSample(i0, bandN, x0), s1 = pixelSample(i0 + 1, bandN, x0);
out[i] = s0 + (s1 - s0) * f;
readPos += ratio;
}
readPos = ((readPos % total) + total) % total;
readRow = readPos / bandN; // for the display
node.port.postMessage({ type: "samples", data: out }, [out.buffer]);
}
draw();
requestAnimationFrame(loop);
}
function draw() {
const W = canvas.width, H = canvas.height;
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, W, H);
if (img) {
const scale = W / imgW, dh = imgH * scale, centerY = H / 2;
ctx.imageSmoothingEnabled = false;
let y = (centerY - readRow * scale) % dh; if (y > 0) y -= dh;
for (; y < H; y += dh) ctx.drawImage(img, 0, 0, imgW, imgH, 0, y, W, dh);
ctx.strokeStyle = "#ffa000"; ctx.lineWidth = Math.max(2, W / 500);
ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(W, centerY); ctx.stroke();
}
drawBorders(W, H);
}
// Two draggable green vertical lines marking the sample span that is read.
function drawBorders(W, H) {
const lw = Math.max(2, W / 400);
const hr = Math.max(14, W / 60);
for (const [frac, isL] of [[borderL, true], [borderR, false]]) {
const x = frac * W;
ctx.strokeStyle = (dragBorder === (isL ? "L" : "R")) ? "#ffa000" : "#00ff66";
ctx.lineWidth = lw;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
ctx.fillStyle = ctx.strokeStyle;
ctx.beginPath(); ctx.arc(x, H / 2, hr, 0, 7); ctx.fill();
ctx.fillStyle = "#000";
ctx.font = `bold ${Math.max(12, W / 70)}px monospace`;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(isL ? "L" : "R", x, H / 2);
}
ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
}
window.addEventListener("DOMContentLoaded", () => {
canvas = document.getElementById("canvas"); ctx = canvas.getContext("2d");
off = document.createElement("canvas"); octx = off.getContext("2d", { willReadFrequently: true });
resize(); window.addEventListener("resize", resize);
const fileInput = document.getElementById("fileInput");
document.getElementById("uploadBtn").onclick = () => { initAudio(); fileInput.click(); };
fileInput.onchange = (e) => { const f = e.target.files && e.target.files[0]; if (f) loadImage(f); };
const bind = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener("input", fn); };
bind("speed", (e) => { speed = +e.target.value; document.getElementById("speedVal").textContent = speed; });
bind("gain", (e) => { gainPct = +e.target.value; document.getElementById("gainVal").textContent = gainPct; if (node) node.port.postMessage({ type: "gain", v: gainPct / 100 }); });
const rev = document.getElementById("reverse"); if (rev) rev.addEventListener("change", (e) => { reverse = e.target.checked; });
const cal = document.getElementById("flip"); if (cal) cal.addEventListener("change", (e) => { calibrate = e.target.checked; });
document.getElementById("playBtn").addEventListener("click", (e) => { initAudio(); playing = !playing; e.target.textContent = playing ? "Pause" : "Play"; });
document.getElementById("hideBtn").addEventListener("click", () => document.getElementById("panel").classList.toggle("hidden"));
// Drag the L/R border lines; an empty tap toggles the panel.
const nxOf = (e) => { const r = canvas.getBoundingClientRect(); return clamp((e.clientX - r.left) / r.width, 0, 1); };
const borderAt = (e) => {
const r = canvas.getBoundingClientRect(), px = nxOf(e) * r.width;
const dL = Math.abs(px - borderL * r.width), dR = Math.abs(px - borderR * r.width);
if (dL <= BORDER_GRAB_PX && dL <= dR) return "L";
if (dR <= BORDER_GRAB_PX) return "R";
return null;
};
canvas.addEventListener("pointerdown", (e) => { dragBorder = borderAt(e); if (dragBorder) e.preventDefault(); });
canvas.addEventListener("pointermove", (e) => {
if (!dragBorder) return;
const nx = nxOf(e);
if (dragBorder === "L") borderL = clamp(nx, 0, borderR - BORDER_MIN_GAP);
else borderR = clamp(nx, borderL + BORDER_MIN_GAP, 1);
e.preventDefault();
});
const endDrag = () => {
if (dragBorder) { dragBorder = null; saveBorders(); }
else document.getElementById("panel").classList.toggle("hidden");
};
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", () => { dragBorder = null; });
requestAnimationFrame(loop);
});