// 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); });