File size: 10,681 Bytes
552b5b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// 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);
});