#!/usr/bin/env bash set -euo pipefail # Builds a single self-contained ES module: # web/dist/yr_crystals_web.js # WASM is base64-inlined inside the JS. Caller dynamic-imports this URL # and calls `mount(canvas)` to start the visualizer on any canvas element. # Also emits dist/index.html for local testing via python3 -m http.server in dist/. ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 not found. $2" >&2; exit 1; }; } need wasm-pack "cargo install wasm-pack" need rustup "install rustup from https://rustup.rs" need python3 "macOS ships it; brew install python3 otherwise" rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true DIST="$ROOT/web/dist" TMP="$ROOT/web/.wasm-pack-tmp" rm -rf "$DIST" "$TMP" mkdir -p "$DIST" echo "==> [1/2] wasm-pack -> $TMP" (cd "$ROOT/web" && wasm-pack build --release --target web --out-dir "$TMP" --quiet) echo "==> [2/2] write self-contained yr_crystals_web.js (WASM inlined as base64)" python3 - < push_bins_db(db), resize: (w, h) => resize(w, h), }; } console.log("[yrxtls] WebGPU unavailable, mounting canvas2D fallback"); return __mountFallback(canvas); } function __isMobileOrTablet() { if (typeof navigator === "undefined") return false; const ua = navigator.userAgent || ""; if (/Mobi|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|webOS/i.test(ua)) return true; // iPads since iOS 13 report as Macintosh with multi-touch present. if (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) return true; return false; } async function __hasWebGPU() { if (__isMobileOrTablet()) return false; if (typeof navigator === "undefined" || !("gpu" in navigator) || !navigator.gpu) { return false; } try { const adapter = await navigator.gpu.requestAdapter(); return !!adapter; } catch { return false; } } function __mountFallback(canvas) { const ctx = canvas.getContext("2d"); if (!ctx) return { pushBins: () => {} }; const NUM_BINS = 26; const FADE_BINS = 4; const HUE_HISTORY_LEN = 40; const HUE_PARAM = 0.9; const bins = new Array(NUM_BINS).fill(0); const brightMod = new Float32Array(NUM_BINS); const alphaMod = new Float32Array(NUM_BINS); const hueHistory = []; let hueSumCos = 0; let hueSumSin = 0; let unifiedHue = 0; let liveDb = null; const t0 = performance.now() / 1000; // bins log-spaced from 40Hz to 11kHz projected onto a 20Hz-20kHz log axis, normalized to max 1.0. const logX = new Float32Array(NUM_BINS); { const log40 = Math.log10(40); const log11k = Math.log10(11000); const log20 = Math.log10(20); const log20k = Math.log10(20000); const range = log20k - log20; for (let i = 0; i < NUM_BINS; i++) { const band = i / (NUM_BINS - 1); const f = Math.pow(10, log40 + band * (log11k - log40)); logX[i] = (Math.log10(f) - log20) / range; } const m = logX[NUM_BINS - 1]; for (let i = 0; i < NUM_BINS; i++) logX[i] /= m; } // freq_norm and amp_weight constants matching the desktop glass-color formula. const MID_BAND = Math.floor(NUM_BINS / 2) / (NUM_BINS - 1); const MID_FREQ = Math.pow(10, Math.log10(40) + MID_BAND * (Math.log10(11000) - Math.log10(40))); const FREQ_NORM = (Math.log10(MID_FREQ) - Math.log10(20)) / (Math.log10(20000) - Math.log10(20)); const AMP_WEIGHT = Math.max(0.5, Math.min(6, Math.pow(1 / (FREQ_NORM + 1e-4), 5) * 2)); // per-bin hue with the mirror-inverted mapping the desktop ingest applies in default config. const binHues = new Float32Array(NUM_BINS); for (let i = 0; i < NUM_BINS; i++) { binHues[i] = 1 - i / (NUM_BINS - 1); } // drives unifiedHue from amp-weighted freq norm through a circular running mean. function updateUnifiedHue() { let sum = 0; for (let i = 0; i < NUM_BINS; i++) sum += bins[i]; const ampNorm = Math.max(0, Math.min(1, sum / NUM_BINS)); let hue = (FREQ_NORM + ampNorm * AMP_WEIGHT * HUE_PARAM) % 1; if (hue < 0) hue += 1; hue = 1 - hue; if (hue < 0) hue += 1; const angle = hue * Math.PI * 2; const c = Math.cos(angle); const s = Math.sin(angle); hueHistory.push([c, s]); hueSumCos += c; hueSumSin += s; if (hueHistory.length > HUE_HISTORY_LEN) { const old = hueHistory.shift(); hueSumCos -= old[0]; hueSumSin -= old[1]; } if (Math.abs(hueSumCos) + Math.abs(hueSumSin) > 0.01) { const smoothed = Math.atan2(hueSumSin, hueSumCos); unifiedHue = ((smoothed / (Math.PI * 2)) + 1) % 1; } } // stamps the three-step bright/alpha pattern outward from a peak bin with distance-decayed intensity. function applyPattern(centre, dist, isBrightSide, direction, peakIntensity, decayBase) { const target = direction === -1 ? centre - dist : centre + dist - 1; if (target < 0 || target >= NUM_BINS) return; const cycle = Math.floor((dist - 1) / 3); const step = (dist - 1) % 3; const decay = Math.pow(decayBase, cycle); const intensity = peakIntensity * decay; if (intensity < 0.01) return; const ty = isBrightSide ? (step + 2) % 3 : step; if (ty === 0) { brightMod[target] += 0.8 * intensity; alphaMod[target] -= 0.8 * intensity; } else if (ty === 1) { brightMod[target] -= 0.8 * intensity; alphaMod[target] += 0.2 * intensity; } else { brightMod[target] += 0.8 * intensity; alphaMod[target] += 0.2 * intensity; } } // recomputes per-bin bright/alpha modulations from local-maxima peaks. function updatePeakMods() { for (let i = 0; i < NUM_BINS; i++) { brightMod[i] = 0; alphaMod[i] = 0; } for (let i = 1; i < NUM_BINS - 1; i++) { const curr = bins[i]; const prev = bins[i - 1]; const next = bins[i + 1]; if (curr > prev && curr > next) { const leftDominant = prev > next; const sharpness = Math.min(curr - prev, curr - next); const peakIntensity = Math.max(0, Math.min(1, Math.pow(Math.max(0, sharpness) * 10, 0.3))); const decayBase = 0.65 - Math.max(0, Math.min(0.35, sharpness * 3)); for (let d = 1; d <= 12; d++) { applyPattern(i, d, leftDominant, -1, peakIntensity, decayBase); applyPattern(i, d, !leftDominant, 1, peakIntensity, decayBase); } } } } // quadratic fade ramp over the last FADE_BINS positions. function segFade(idx, count) { const fromEnd = count - 1 - idx; if (fromEnd < FADE_BINS) { const f = (fromEnd + 1) / (FADE_BINS + 1); return f * f; } return 1; } // log-x trapezoid fills under the glass hue with per-bin rainbow spike accents. function drawSpectrum(baseW, baseH) { const denom = NUM_BINS - 1; const fillHue = unifiedHue * 360; for (let i = 0; i < denom; i++) { const x1 = logX[i] * baseW; const x2 = logX[i + 1] * baseW; const y1 = baseH - bins[i] * baseH; const y2 = baseH - bins[i + 1] * baseH; const a = bins[i]; const bm = brightMod[i]; const bMult = bm >= 0 ? 1 + bm : 1 / (1 - bm * 2); const brightness = Math.max(0, Math.min(1, Math.sqrt(a) * bMult)); const lum = brightness * 50; const am = alphaMod[i]; const aMult = Math.max(0.1, am >= 0 ? 1 + am * 0.5 : 1 + am); const alphaBase = Math.max(0, Math.min(1, (0.4 + (a - 0.5)) * aMult)); const alpha = alphaBase * segFade(i, denom); ctx.fillStyle = "hsla(" + fillHue + ", 100%, " + lum + "%, " + alpha + ")"; ctx.beginPath(); ctx.moveTo(x1, baseH); ctx.lineTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x2, baseH); ctx.closePath(); ctx.fill(); } const lineW = Math.max(1, baseW * 0.004); for (let i = 0; i < NUM_BINS; i++) { const x = logX[i] * baseW; const y = baseH - bins[i] * baseH; const a = bins[i]; const bm = brightMod[i]; const bMult = bm >= 0 ? 1 + bm : 1 / (1 - bm * 2); const brightness = Math.max(0, Math.min(1, Math.sqrt(a) * bMult)); const lineHue = binHues[i] * 360; const lum = brightness * 50; const am = alphaMod[i]; const aMult = Math.max(0.1, am >= 0 ? 1 + am * 0.5 : 1 + am); const alphaBase = Math.max(0, Math.min(0.9, (0.4 + (a - 0.5)) * aMult)); const alpha = alphaBase * segFade(i, NUM_BINS); ctx.strokeStyle = "hsla(" + lineHue + ", 90%, " + lum + "%, " + alpha + ")"; ctx.lineWidth = lineW; ctx.beginPath(); ctx.moveTo(x, baseH); ctx.lineTo(x, y); ctx.stroke(); } } // reflects the base-rect spectrum into one canvas quadrant. function drawQuadrant(baseW, baseH, flipX, flipY, w, h) { ctx.save(); ctx.translate(flipX ? w : 0, flipY ? h : 0); ctx.scale(flipX ? -1 : 1, flipY ? -1 : 1); drawSpectrum(baseW, baseH); ctx.restore(); } function frame() { const t = performance.now() / 1000 - t0; const w = canvas.width; const h = canvas.height; if (w === 0 || h === 0) { requestAnimationFrame(frame); return; } if (liveDb) { for (let i = 0; i < NUM_BINS; i++) { const db = i < liveDb.length ? liveDb[i] : -80; const norm = Math.max(0, Math.min(1, (db + 80) / 80)); bins[i] = bins[i] * 0.55 + norm * 0.45; } } else { // log-frequency sweep with a beat envelope. for (let i = 0; i < NUM_BINS; i++) { const band = i / (NUM_BINS - 1); const sweep = Math.sin(t * 0.4) * 0.5 + 0.5; const bump = Math.exp(-((band - sweep) ** 2) * 20); const beat = (Math.sin(t * 2) * 0.5 + 0.5) * 0.4 + 0.6; const target = bump * beat; bins[i] = bins[i] * 0.8 + target * 0.2; } } updateUnifiedHue(); updatePeakMods(); ctx.fillStyle = "#000"; ctx.fillRect(0, 0, w, h); // (N-1)/(2(N-1)-1) yields a one-bin horizontal mirror overlap for N=26 bins. const baseW = w * (NUM_BINS - 1) / (2 * (NUM_BINS - 1) - 1); const baseH = h * 0.5; drawQuadrant(baseW, baseH, false, false, w, h); drawQuadrant(baseW, baseH, true, false, w, h); drawQuadrant(baseW, baseH, false, true, w, h); drawQuadrant(baseW, baseH, true, true, w, h); requestAnimationFrame(frame); } requestAnimationFrame(frame); return { pushBins: (db) => { liveDb = db; }, resize: () => {}, }; } ''' (dist / "yr_crystals_web.js").write_text(shim + wrapper) size = (dist / "yr_crystals_web.js").stat().st_size print(f" wrote {dist / 'yr_crystals_web.js'} ({size / 1024:.0f} KB)") # minimal local-test page that imports the same JS by sibling path. test_html = '''YrXtls local test ''' (dist / "index.html").write_text(test_html) print(f" wrote {dist / 'index.html'} (local test page)") PY cp -R "$ROOT/assets" "$DIST/assets" echo " copied $ROOT/assets -> $DIST/assets" cp "$ROOT/web/yr_crystals_embed.js" "$DIST/yr_crystals_embed.js" echo " copied $ROOT/web/yr_crystals_embed.js -> $DIST/yr_crystals_embed.js" cp "$ROOT/web/yr_crystals_embed.css" "$DIST/yr_crystals_embed.css" echo " copied $ROOT/web/yr_crystals_embed.css -> $DIST/yr_crystals_embed.css" rm -rf "$TMP" echo echo "deploy: upload the contents of web/dist/ to your file host (yr_crystals_web.js, yr_crystals_embed.js, yr_crystals_embed.css, assets/)." echo "test locally: (cd web/dist && python3 -m http.server 8080) then open http://localhost:8080/"