musicSlip / static /scanner.js
MattSymetry's picture
Upload 11 files
552b5b3 verified
Raw History Blame Contribute Delete
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);
});