361 lines
14 KiB
Bash
Executable File
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/"
|