Spaces:
Running
Running
Download static/scanner.js from MattSymetry/musicSlip: direct link, hf CLI and curl.
- Browser
- Download file 8.89 kB
-
https://huggingface.co/spaces/MattSymetry/musicSlip/resolve/main/static/scanner.js
- Command line
-
hf download hf://spaces/MattSymetry/musicSlip/static/scanner.js
-
curl -L -o scanner.js https://huggingface.co/spaces/MattSymetry/musicSlip/resolve/main/static/scanner.js
8.89 kB
| // Browser scanner (waveform): read a printed slip with the (phone) camera and | |
| // play it. A sweeping line reads a row inside a draggable quad; each step samples | |
| // WIDTH points across the quad as raw PCM (pixel brightness = amplitude) and | |
| // feeds them to the PCM player worklet (scanner-worklet.js). No STFT / phase -- | |
| // the waveform is the signal. Everything runs on-device; no upload. | |
| const WIDTH = 512; // samples per read-row (== generator row width) | |
| const SR_SLIP = 44100; // slip's native rate (ask the AudioContext for it) | |
| const H_REF = 1000; // "speed" is px/s over this virtual slip height | |
| const PROC_W = 480, PROC_H = 270; | |
| const GRAB_PX = 30; | |
| const DEFAULT_CORNERS = [[0.2, 0.15], [0.8, 0.15], [0.8, 0.85], [0.2, 0.85]]; | |
| const els = {}; | |
| let corners = loadCorners() || DEFAULT_CORNERS.map((c) => c.slice()); | |
| let dragIdx = -1; | |
| let scanPos = 0, fracRows = 0, lastT = 0, rowsPerSec = SR_SLIP / WIDTH; | |
| let speed = 200, rangeDb = 80, gainPct = 100, reverse = false, playing = true; | |
| let white = 242, black = 13; | |
| let video, disp, dctx, proc, pctx; | |
| let audioCtx = null, node = null; | |
| // ---- persistence ---- | |
| function loadCorners() { | |
| try { const c = JSON.parse(localStorage.getItem("scannerCorners")); if (c?.length === 4) return c; } catch {} | |
| return null; | |
| } | |
| function saveCorners() { try { localStorage.setItem("scannerCorners", JSON.stringify(corners)); } catch {} } | |
| // ---- helpers ---- | |
| const lerp2 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; | |
| const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); | |
| const pct = (sorted, p) => sorted[clamp((p * sorted.length) | 0, 0, sorted.length - 1)]; | |
| // ---- start (needs a user gesture on iOS) ---- | |
| async function start() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: { ideal: "environment" }, width: { ideal: 1280 }, height: { ideal: 720 } }, | |
| audio: false, | |
| }); | |
| video.srcObject = stream; | |
| await video.play(); | |
| } catch (e) { | |
| els.err.textContent = "Camera error: " + e.message; | |
| els.err.hidden = false; | |
| return; | |
| } | |
| try { audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SR_SLIP }); } | |
| catch { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } | |
| rowsPerSec = audioCtx.sampleRate / WIDTH; | |
| await audioCtx.audioWorklet.addModule("/static/scanner-worklet.js"); | |
| node = new AudioWorkletNode(audioCtx, "pcm", { outputChannelCount: [2] }); | |
| node.connect(audioCtx.destination); | |
| node.port.postMessage({ type: "gain", v: gainPct / 100 }); | |
| if (audioCtx.state === "suspended") await audioCtx.resume(); | |
| els.overlay.hidden = true; | |
| layout(); | |
| video.addEventListener("loadedmetadata", layout); | |
| window.addEventListener("resize", layout); | |
| window.addEventListener("orientationchange", () => setTimeout(layout, 300)); | |
| requestAnimationFrame(loop); | |
| } | |
| // Size + position the video and transparent overlay to the same letterboxed rect. | |
| function layout() { | |
| const vw = video.videoWidth || 16, vh = video.videoHeight || 9; | |
| const sw = window.innerWidth, sh = window.innerHeight; | |
| const scale = Math.min(sw / vw, sh / vh); | |
| const w = vw * scale, h = vh * scale, left = (sw - w) / 2, top = (sh - h) / 2; | |
| for (const el of [video, disp]) { | |
| el.style.left = left + "px"; el.style.top = top + "px"; | |
| el.style.width = w + "px"; el.style.height = h + "px"; | |
| } | |
| disp.width = vw; disp.height = vh; | |
| } | |
| // ---- main loop ---- | |
| function loop(t) { | |
| const dt = lastT ? (t - lastT) / 1000 : 0; | |
| lastT = t; | |
| pctx.drawImage(video, 0, 0, PROC_W, PROC_H); | |
| const data = pctx.getImageData(0, 0, PROC_W, PROC_H).data; | |
| const lumAt = (nx, ny) => { | |
| const sx = clamp((nx * PROC_W) | 0, 0, PROC_W - 1); | |
| const sy = clamp((ny * PROC_H) | 0, 0, PROC_H - 1); | |
| const j = (sy * PROC_W + sx) * 4; | |
| return 0.299 * data[j] + 0.587 * data[j + 1] + 0.114 * data[j + 2]; | |
| }; | |
| // paper/ink levels from a grid in the quad (EMA) | |
| const samp = []; | |
| for (let gy = 0; gy < 16; gy++) { | |
| const l = lerp2(corners[0], corners[3], gy / 15), r = lerp2(corners[1], corners[2], gy / 15); | |
| for (let gx = 0; gx < 24; gx++) samp.push(lumAt(...lerp2(l, r, gx / 23))); | |
| } | |
| samp.sort((a, b) => a - b); | |
| white += 0.12 * (pct(samp, 0.99) - white); // brightest pixel = +amplitude | |
| black += 0.12 * (pct(samp, 0.01) - black); // darkest pixel = -amplitude | |
| const center = (white + black) / 2, half = Math.max((white - black) / 2, 1); | |
| // emit rows of PCM samples on a steady clock (real-time), advancing the sweep | |
| if (playing) { | |
| fracRows += dt * rowsPerSec; | |
| let nrow = Math.floor(fracRows); | |
| fracRows -= nrow; | |
| if (nrow > 256) nrow = 256; | |
| const stepFrac = (speed / rowsPerSec) / H_REF * (reverse ? -1 : 1); | |
| for (let c = 0; c < nrow; c++) { | |
| scanPos += stepFrac; | |
| if (scanPos >= 1) scanPos -= 1; | |
| else if (scanPos < 0) scanPos += 1; | |
| const left = lerp2(corners[0], corners[3], scanPos); | |
| const right = lerp2(corners[1], corners[2], scanPos); | |
| const s = new Float32Array(WIDTH); | |
| for (let k = 0; k < WIDTH; k++) { | |
| const [nx, ny] = lerp2(left, right, k / (WIDTH - 1)); | |
| s[k] = clamp((lumAt(nx, ny) - center) / half, -1, 1); | |
| } | |
| node.port.postMessage({ type: "samples", data: s }, [s.buffer]); | |
| } | |
| } else { | |
| fracRows = 0; | |
| } | |
| draw(); | |
| requestAnimationFrame(loop); | |
| } | |
| // ---- display overlay (transparent; the <video> shows the feed) ---- | |
| function draw() { | |
| const W = disp.width, H = disp.height; | |
| dctx.clearRect(0, 0, W, H); | |
| const P = corners.map(([x, y]) => [x * W, y * H]); | |
| dctx.lineWidth = Math.max(2, W / 400); | |
| dctx.strokeStyle = "#00ff66"; | |
| dctx.beginPath(); | |
| for (let i = 0; i < 4; i++) { const a = P[i], b = P[(i + 1) % 4]; dctx.moveTo(a[0], a[1]); dctx.lineTo(b[0], b[1]); } | |
| dctx.stroke(); | |
| for (let i = 0; i < 4; i++) { | |
| dctx.fillStyle = dragIdx === i ? "#ffa000" : "#00ff66"; | |
| dctx.beginPath(); dctx.arc(P[i][0], P[i][1], Math.max(8, W / 90), 0, 7); dctx.fill(); | |
| } | |
| const l = lerp2(P[0], P[3], scanPos), r = lerp2(P[1], P[2], scanPos); | |
| dctx.strokeStyle = "#ffa000"; | |
| dctx.beginPath(); dctx.moveTo(l[0], l[1]); dctx.lineTo(r[0], r[1]); dctx.stroke(); | |
| dctx.fillStyle = "#00ff66"; | |
| dctx.font = `${Math.max(14, W / 50)}px monospace`; | |
| dctx.fillText("low", P[0][0] + 6, P[0][1] - 8); | |
| dctx.fillText("high", P[1][0] - 48, P[1][1] - 8); | |
| } | |
| // ---- corner dragging ---- | |
| function evNorm(e) { | |
| const rect = disp.getBoundingClientRect(); | |
| return [clamp((e.clientX - rect.left) / rect.width, 0, 1), clamp((e.clientY - rect.top) / rect.height, 0, 1)]; | |
| } | |
| function onDown(e) { | |
| const rect = disp.getBoundingClientRect(); | |
| const n = evNorm(e); | |
| let best = -1, bestD = 1e9; | |
| corners.forEach((c, i) => { | |
| const d = Math.hypot((c[0] - n[0]) * rect.width, (c[1] - n[1]) * rect.height); | |
| if (d < bestD) { bestD = d; best = i; } | |
| }); | |
| if (bestD <= GRAB_PX * 2) { dragIdx = best; e.preventDefault(); } | |
| } | |
| function onMove(e) { if (dragIdx >= 0) { corners[dragIdx] = evNorm(e); e.preventDefault(); } } | |
| function onUp() { if (dragIdx >= 0) { dragIdx = -1; saveCorners(); } } | |
| // ---- wire up ---- | |
| window.addEventListener("DOMContentLoaded", () => { | |
| video = document.getElementById("video"); | |
| video.setAttribute("playsinline", ""); video.muted = true; | |
| disp = document.getElementById("disp"); dctx = disp.getContext("2d"); | |
| proc = document.createElement("canvas"); proc.width = PROC_W; proc.height = PROC_H; | |
| pctx = proc.getContext("2d", { willReadFrequently: true }); | |
| els.overlay = document.getElementById("overlay"); | |
| els.err = document.getElementById("err"); | |
| const bind = (id, fn) => document.getElementById(id).addEventListener("input", fn); | |
| bind("speed", (e) => { speed = +e.target.value; document.getElementById("speedVal").textContent = speed; }); | |
| bind("range", (e) => { rangeDb = +e.target.value; document.getElementById("rangeVal").textContent = rangeDb; }); | |
| bind("gain", (e) => { gainPct = +e.target.value; document.getElementById("gainVal").textContent = gainPct; if (node) node.port.postMessage({ type: "gain", v: gainPct / 100 }); }); | |
| document.getElementById("reverse").addEventListener("change", (e) => { reverse = e.target.checked; }); | |
| document.getElementById("playBtn").addEventListener("click", (e) => { playing = !playing; e.target.textContent = playing ? "Pause" : "Play"; }); | |
| document.getElementById("resetBtn").addEventListener("click", () => { corners = DEFAULT_CORNERS.map((c) => c.slice()); saveCorners(); }); | |
| document.getElementById("hideBtn").addEventListener("click", () => document.getElementById("panel").classList.toggle("hidden")); | |
| disp.addEventListener("pointerdown", onDown); | |
| disp.addEventListener("pointermove", onMove); | |
| disp.addEventListener("pointerup", onUp); | |
| disp.addEventListener("pointercancel", onUp); | |
| els.overlay.addEventListener("click", start); | |
| }); | |