// 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