Spaces:
Running
Running
Download specimg-static/static/loopscan.js from MattSymetry/musicSlip: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/MattSymetry/musicSlip/resolve/main/specimg-static/static/loopscan.js
- Command line
-
hf download hf://spaces/MattSymetry/musicSlip/specimg-static/static/loopscan.js
-
curl -L -o loopscan.js https://huggingface.co/spaces/MattSymetry/musicSlip/resolve/main/specimg-static/static/loopscan.js
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); | |
| }); | |