YrXtals/scripts/web/build.sh

361 lines
14 KiB
Bash
Executable File

#!/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 - <<PY
import base64, pathlib
root = pathlib.Path("$ROOT/web")
tmp = pathlib.Path("$TMP")
dist = pathlib.Path("$DIST")
shim = (tmp / "yr_crystals_web.js").read_text()
wasm_b64 = base64.b64encode((tmp / "yr_crystals_web_bg.wasm").read_bytes()).decode()
wrapper = '''
const __INLINE_WASM_B64 = "''' + wasm_b64 + '''";
let __initPromise = null;
// mounts the wasm visualizer or canvas2D fallback against the canvas.
export async function mount(canvas) {
if (await __hasWebGPU()) {
console.log("[yrxtls] WebGPU available, mounting wasm visualizer");
if (!__initPromise) {
const bin = atob(__INLINE_WASM_B64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
__initPromise = __wbg_init({ module_or_path: bytes });
}
await __initPromise;
start_on_canvas(canvas);
return {
pushBins: (db) => 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 = '''<!DOCTYPE html><html><head><meta charset="utf-8"><title>YrXtls local test</title>
<style>html,body{margin:0;height:100%;background:#111}canvas{display:block;width:100vw;height:100vh}</style>
</head><body><canvas id="stage"></canvas><script type="module">
import { mount } from "./yr_crystals_web.js";
const c = document.getElementById("stage");
const dpr = devicePixelRatio || 1;
const fit = () => {
c.style.width = innerWidth + "px";
c.style.height = innerHeight + "px";
c.width = Math.floor(innerWidth * dpr);
c.height = Math.floor(innerHeight * dpr);
};
fit();
addEventListener("resize", fit);
await mount(c);
</script></body></html>
'''
(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/"