Web Visualizer:
- WGPU/WGSL through WebGPU in WASM - Canvas2d Variant for unsupported and mobile devices. 1.0.5
This commit is contained in:
parent
7a9126f626
commit
43d65d751a
|
|
@ -36,4 +36,4 @@ android/app/src/main/jniLibs/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
web/
|
web/dist/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,360 @@
|
||||||
|
#!/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/"
|
||||||
|
|
@ -131,7 +131,7 @@ impl Processor {
|
||||||
self.rebuild_bins();
|
self.rebuild_bins();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// rebuilds the FFT sampling targets (linear at small FFT for low-end coverage, log otherwise) and the always-log display centers.
|
/// rebuilds the FFT sample frequencies and the log-spaced display centers.
|
||||||
fn rebuild_bins(&mut self) {
|
fn rebuild_bins(&mut self) {
|
||||||
self.sample_freqs.clear();
|
self.sample_freqs.clear();
|
||||||
self.freqs_const.clear();
|
self.freqs_const.clear();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
[package]
|
||||||
|
name = "yr_crystals_web"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "yrxtls-serve"
|
||||||
|
path = "src/server.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
wgpu = "27"
|
||||||
|
bytemuck = { version = "1", features = ["derive"] }
|
||||||
|
num-complex = "0.4"
|
||||||
|
rustfft = "6"
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
js-sys = "0.3"
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
console_log = "1"
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys]
|
||||||
|
version = "0.3"
|
||||||
|
features = [
|
||||||
|
"Document",
|
||||||
|
"Element",
|
||||||
|
"HtmlCanvasElement",
|
||||||
|
"Navigator",
|
||||||
|
"Window",
|
||||||
|
"Performance",
|
||||||
|
"console",
|
||||||
|
"AudioContext",
|
||||||
|
"AudioContextOptions",
|
||||||
|
"AudioBuffer",
|
||||||
|
"AudioBufferSourceNode",
|
||||||
|
"AudioDestinationNode",
|
||||||
|
"AudioNode",
|
||||||
|
"AnalyserNode",
|
||||||
|
"MediaElementAudioSourceNode",
|
||||||
|
"HtmlAudioElement",
|
||||||
|
"GpuCanvasContext",
|
||||||
|
]
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
tiny_http = "0.12"
|
||||||
|
env_logger = "0.11"
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// drops empty placeholders into dist/ if the wasm-pack output is missing, so the server bin compiles standalone via cargo check.
|
||||||
|
// the real bytes get written by scripts/web/build.sh phase 1 (wasm-pack) right before phase 2 (cargo build) embeds them.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let dist = std::path::Path::new("dist");
|
||||||
|
let _ = std::fs::create_dir_all(dist);
|
||||||
|
|
||||||
|
let path = dist.join("index.html");
|
||||||
|
if !path.exists() {
|
||||||
|
let _ = std::fs::write(&path, b"");
|
||||||
|
println!(
|
||||||
|
"cargo:warning=missing {}, wrote empty placeholder. run scripts/web/build.sh to produce the real bundle.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>YrXtls</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
html { height: 100%; }
|
||||||
|
body { height: 100%; }
|
||||||
|
#stage {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
opacity: 0.85;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#status.ok { color: #6f6; }
|
||||||
|
#status.warn { color: #fc6; }
|
||||||
|
#status.err { color: #f66; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="stage"></canvas>
|
||||||
|
<pre id="status">booting...</pre>
|
||||||
|
<script type="module">
|
||||||
|
// these two strings get filled in by scripts/web/build.sh; the unbuilt template ships them empty so editors don't choke on multi-MB lines.
|
||||||
|
const __JS_B64 = "__JS_BASE64__";
|
||||||
|
const __WASM_B64 = "__WASM_BASE64__";
|
||||||
|
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
const log = (msg, cls = "") => {
|
||||||
|
status.textContent += "\n" + msg;
|
||||||
|
if (cls) status.className = cls;
|
||||||
|
console.log("[yrxtls]", msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
function sizeCanvas() {
|
||||||
|
const canvas = document.getElementById("stage");
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
canvas.style.width = w + "px";
|
||||||
|
canvas.style.height = h + "px";
|
||||||
|
canvas.width = Math.max(1, Math.floor(w * dpr));
|
||||||
|
canvas.height = Math.max(1, Math.floor(h * dpr));
|
||||||
|
return [canvas.width, canvas.height];
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64ToBytes(b64) {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
status.textContent = "booting...";
|
||||||
|
log(`location: ${location.href}`);
|
||||||
|
log(`secure context: ${window.isSecureContext}`);
|
||||||
|
log(`navigator.gpu: ${typeof navigator.gpu}`);
|
||||||
|
|
||||||
|
if (!("gpu" in navigator) || !navigator.gpu) {
|
||||||
|
log("navigator.gpu missing -> browser has no WebGPU.", "err");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = await navigator.gpu.requestAdapter();
|
||||||
|
if (!adapter) {
|
||||||
|
log("requestAdapter() returned null -> WebGPU rejected.", "err");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`adapter: ${adapter.info?.vendor ?? "?"} / ${adapter.info?.architecture ?? "?"}`, "ok");
|
||||||
|
|
||||||
|
const [w, h] = sizeCanvas();
|
||||||
|
log(`canvas: ${w}x${h}`);
|
||||||
|
|
||||||
|
if (!__JS_B64 || !__WASM_B64) {
|
||||||
|
log("inlined wasm/js bundles are empty -> this is the unbuilt template, run scripts/web/build.sh.", "err");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("decoding inlined bundles...");
|
||||||
|
const jsBlob = new Blob([b64ToBytes(__JS_B64)], { type: "text/javascript" });
|
||||||
|
const jsUrl = URL.createObjectURL(jsBlob);
|
||||||
|
const mod = await import(jsUrl);
|
||||||
|
URL.revokeObjectURL(jsUrl);
|
||||||
|
|
||||||
|
await mod.default({ module_or_path: b64ToBytes(__WASM_B64) });
|
||||||
|
log("wasm loaded, starting...", "ok");
|
||||||
|
|
||||||
|
mod.start("#stage");
|
||||||
|
|
||||||
|
const propagateResize = () => {
|
||||||
|
const [w, h] = sizeCanvas();
|
||||||
|
mod.resize(w, h);
|
||||||
|
};
|
||||||
|
// ResizeObserver fires immediately for current size and on every layout change.
|
||||||
|
// critical for iframe embeds where the iframe element grows from its 300x150 default to its real size AFTER the inner script first runs.
|
||||||
|
const ro = new ResizeObserver(propagateResize);
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
window.addEventListener("resize", propagateResize);
|
||||||
|
log("running.", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
log("FATAL: " + (e?.stack ?? e), "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
// synthesizes triangle and line vertices per-bin from a storage buffer, with mirrors as an instance axis.
|
||||||
|
|
||||||
|
struct Globals {
|
||||||
|
bounds: vec2<f32>, // full canvas in pixels
|
||||||
|
base: vec2<f32>, // building rect in pixels, 0.55w by 0.5h when mirrored
|
||||||
|
num_bins: u32,
|
||||||
|
num_channels: u32,
|
||||||
|
flags: u32, // 1=glass, 2=mirrored, 4=inverted, 8=stereo
|
||||||
|
fade_bins: u32, // count of tail bins fading toward zero alpha when mirrored
|
||||||
|
hue_param: f32,
|
||||||
|
contrast: f32,
|
||||||
|
brightness: f32,
|
||||||
|
_pad0: f32,
|
||||||
|
unified_hue: f32,
|
||||||
|
unified_sat: f32,
|
||||||
|
unified_val: f32,
|
||||||
|
_pad1: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Bin {
|
||||||
|
log_x: f32, // 0..1 along the log-frequency axis
|
||||||
|
visual_norm: f32, // smoothed dB on a 0..1 scale
|
||||||
|
primary_norm: f32, // primary dB on a 0..1 scale
|
||||||
|
bright_mod: f32,
|
||||||
|
alpha_mod: f32,
|
||||||
|
hue: f32,
|
||||||
|
sat: f32,
|
||||||
|
val: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||||
|
@group(0) @binding(1) var<storage, read> bins: array<Bin>;
|
||||||
|
|
||||||
|
struct VertexOut {
|
||||||
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
|
@location(0) color: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn flag(bit: u32) -> bool {
|
||||||
|
return (globals.flags & bit) != 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3<f32> {
|
||||||
|
let hh = fract(fract(h) + 1.0) * 6.0;
|
||||||
|
let i = floor(hh);
|
||||||
|
let f = hh - i;
|
||||||
|
let p = v * (1.0 - s);
|
||||||
|
let q = v * (1.0 - s * f);
|
||||||
|
let t = v * (1.0 - s * (1.0 - f));
|
||||||
|
let ii = i32(i) % 6;
|
||||||
|
if (ii == 0) { return vec3<f32>(v, t, p); }
|
||||||
|
if (ii == 1) { return vec3<f32>(q, v, p); }
|
||||||
|
if (ii == 2) { return vec3<f32>(p, v, t); }
|
||||||
|
if (ii == 3) { return vec3<f32>(p, q, v); }
|
||||||
|
if (ii == 4) { return vec3<f32>(t, p, v); }
|
||||||
|
return vec3<f32>(v, p, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_brightness(b: Bin) -> f32 {
|
||||||
|
let base_b = sqrt(b.primary_norm);
|
||||||
|
let bm = b.bright_mod;
|
||||||
|
var b_mult: f32;
|
||||||
|
if (bm >= 0.0) {
|
||||||
|
b_mult = 1.0 + bm;
|
||||||
|
} else {
|
||||||
|
b_mult = 1.0 / (1.0 - bm * 2.0);
|
||||||
|
}
|
||||||
|
return clamp(base_b * b_mult * globals.brightness, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alpha_for(b: Bin, fade: f32) -> f32 {
|
||||||
|
let am = b.alpha_mod;
|
||||||
|
var a_mult: f32;
|
||||||
|
if (am >= 0.0) {
|
||||||
|
a_mult = 1.0 + am * 0.5;
|
||||||
|
} else {
|
||||||
|
a_mult = 1.0 + am;
|
||||||
|
}
|
||||||
|
a_mult = max(a_mult, 0.1);
|
||||||
|
var a = 0.4 + (b.primary_norm - 0.5) * globals.contrast;
|
||||||
|
a = clamp(a * a_mult, 0.0, 1.0);
|
||||||
|
return a * fade;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dyn_rgb(b: Bin) -> vec3<f32> {
|
||||||
|
let fb = final_brightness(b);
|
||||||
|
let s = clamp(b.sat * globals.hue_param, 0.0, 1.0);
|
||||||
|
let v = clamp(b.val * fb, 0.0, 1.0);
|
||||||
|
return hsv_to_rgb(b.hue, s, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_rgb(b: Bin) -> vec3<f32> {
|
||||||
|
if (flag(1u)) {
|
||||||
|
let fb = final_brightness(b);
|
||||||
|
let v = clamp(globals.unified_val * fb, 0.0, 1.0);
|
||||||
|
return hsv_to_rgb(globals.unified_hue, globals.unified_sat, v);
|
||||||
|
}
|
||||||
|
return dyn_rgb(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_offset(rgb: vec3<f32>, ch: u32) -> vec3<f32> {
|
||||||
|
if (ch == 1u && flag(8u)) {
|
||||||
|
let off = 40.0 / 255.0;
|
||||||
|
return vec3<f32>(
|
||||||
|
max(rgb.x - off, 0.0),
|
||||||
|
max(rgb.y - off, 0.0),
|
||||||
|
min(rgb.z + off, 1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fade_factor(seg: u32) -> f32 {
|
||||||
|
if (!flag(2u)) { return 1.0; }
|
||||||
|
let from_end = i32(globals.num_bins) - 2 - i32(seg);
|
||||||
|
if (from_end < i32(globals.fade_bins)) {
|
||||||
|
var f = f32(from_end + 1) / f32(globals.fade_bins + 1u);
|
||||||
|
f = f * f;
|
||||||
|
return max(f, 0.0);
|
||||||
|
}
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pixel_to_clip(p: vec2<f32>) -> vec4<f32> {
|
||||||
|
let nx = (p.x / max(globals.bounds.x, 1.0)) * 2.0 - 1.0;
|
||||||
|
let ny = 1.0 - (p.y / max(globals.bounds.y, 1.0)) * 2.0;
|
||||||
|
return vec4<f32>(nx, ny, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mirror_xform(iid: u32, p: vec2<f32>) -> vec2<f32> {
|
||||||
|
var sx: f32 = 1.0;
|
||||||
|
var sy: f32 = 1.0;
|
||||||
|
var tx: f32 = 0.0;
|
||||||
|
var ty: f32 = 0.0;
|
||||||
|
if (iid == 1u) {
|
||||||
|
sx = -1.0; tx = globals.bounds.x;
|
||||||
|
} else if (iid == 2u) {
|
||||||
|
sy = -1.0; ty = globals.bounds.y;
|
||||||
|
} else if (iid == 3u) {
|
||||||
|
sx = -1.0; sy = -1.0;
|
||||||
|
tx = globals.bounds.x; ty = globals.bounds.y;
|
||||||
|
}
|
||||||
|
return vec2<f32>(p.x * sx + tx, p.y * sy + ty);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_fill(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VertexOut {
|
||||||
|
let nb = globals.num_bins;
|
||||||
|
let segs = max(nb, 1u) - 1u;
|
||||||
|
let per_ch = segs * 6u;
|
||||||
|
let ch = vid / per_ch;
|
||||||
|
let in_ch = vid % per_ch;
|
||||||
|
let seg = in_ch / 6u;
|
||||||
|
let corner = in_ch % 6u;
|
||||||
|
|
||||||
|
var i = seg;
|
||||||
|
var j = seg + 1u;
|
||||||
|
if (flag(4u)) {
|
||||||
|
i = nb - 1u - seg;
|
||||||
|
j = nb - 2u - seg;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = ch * nb;
|
||||||
|
let bi = bins[base + i];
|
||||||
|
let bj = bins[base + j];
|
||||||
|
|
||||||
|
let w = globals.base.x;
|
||||||
|
let h = globals.base.y;
|
||||||
|
let x1 = bi.log_x * w;
|
||||||
|
let x2 = bj.log_x * w;
|
||||||
|
let y1 = h - bi.visual_norm * h;
|
||||||
|
let y2 = h - bj.visual_norm * h;
|
||||||
|
let anchor_y = h;
|
||||||
|
|
||||||
|
var p: vec2<f32>;
|
||||||
|
switch corner {
|
||||||
|
case 0u: { p = vec2<f32>(x1, anchor_y); }
|
||||||
|
case 1u: { p = vec2<f32>(x1, y1); }
|
||||||
|
case 2u: { p = vec2<f32>(x2, y2); }
|
||||||
|
case 3u: { p = vec2<f32>(x1, anchor_y); }
|
||||||
|
case 4u: { p = vec2<f32>(x2, y2); }
|
||||||
|
default: { p = vec2<f32>(x2, anchor_y); }
|
||||||
|
}
|
||||||
|
let pw = mirror_xform(iid, p);
|
||||||
|
|
||||||
|
var rgb = fill_rgb(bi);
|
||||||
|
rgb = channel_offset(rgb, ch);
|
||||||
|
let a = alpha_for(bi, fade_factor(seg));
|
||||||
|
|
||||||
|
var out: VertexOut;
|
||||||
|
out.clip_position = pixel_to_clip(pw);
|
||||||
|
out.color = vec4<f32>(rgb, a);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_line(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VertexOut {
|
||||||
|
let nb = globals.num_bins;
|
||||||
|
let per_ch = nb * 2u;
|
||||||
|
let ch = vid / per_ch;
|
||||||
|
let in_ch = vid % per_ch;
|
||||||
|
let seg = in_ch / 2u;
|
||||||
|
let endpoint = in_ch % 2u;
|
||||||
|
|
||||||
|
var i = seg;
|
||||||
|
if (flag(4u)) {
|
||||||
|
i = nb - 1u - seg;
|
||||||
|
}
|
||||||
|
let bi = bins[ch * nb + i];
|
||||||
|
|
||||||
|
let w = globals.base.x;
|
||||||
|
let h = globals.base.y;
|
||||||
|
let x = bi.log_x * w;
|
||||||
|
let y_top = h - bi.visual_norm * h;
|
||||||
|
let anchor_y = h;
|
||||||
|
var p: vec2<f32>;
|
||||||
|
if (endpoint == 0u) {
|
||||||
|
p = vec2<f32>(x, anchor_y);
|
||||||
|
} else {
|
||||||
|
p = vec2<f32>(x, y_top);
|
||||||
|
}
|
||||||
|
let pw = mirror_xform(iid, p);
|
||||||
|
|
||||||
|
var rgb = dyn_rgb(bi);
|
||||||
|
rgb = channel_offset(rgb, ch);
|
||||||
|
var a = alpha_for(bi, fade_factor(seg));
|
||||||
|
a = min(a, 0.9);
|
||||||
|
|
||||||
|
var out: VertexOut;
|
||||||
|
out.clip_position = pixel_to_clip(pw);
|
||||||
|
out.color = vec4<f32>(rgb, a);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cepstrum line strip in pixel space.
|
||||||
|
struct CepIn {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_cep(in: CepIn) -> VertexOut {
|
||||||
|
var out: VertexOut;
|
||||||
|
out.clip_position = pixel_to_clip(in.position);
|
||||||
|
out.color = in.color;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
|
||||||
|
return in.color;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,372 @@
|
||||||
|
#![cfg(target_arch = "wasm32")]
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
use web_sys::HtmlCanvasElement;
|
||||||
|
|
||||||
|
mod palette;
|
||||||
|
mod visualizer;
|
||||||
|
|
||||||
|
use visualizer::pipeline::{
|
||||||
|
BinGpu, ClipRect, GlobalsGpu, VisPipeline, FLAG_GLASS, FLAG_INVERTED, FLAG_MIRRORED,
|
||||||
|
FLAG_STEREO,
|
||||||
|
};
|
||||||
|
use visualizer::{build as cep_build, FrameData, VizParams};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// shared handle to the live App so wasm-bindgen entry points can reach it after start() returns.
|
||||||
|
static APP_HANDLE: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
|
||||||
|
/// live per-bin dB values from JS audio analysis.
|
||||||
|
static LIVE_BINS_DB: RefCell<Option<Vec<f32>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// per-canvas wgpu state plus the visualizer pipeline and the smoothing state it ingests into.
|
||||||
|
struct App {
|
||||||
|
surface: wgpu::Surface<'static>,
|
||||||
|
device: wgpu::Device,
|
||||||
|
queue: wgpu::Queue,
|
||||||
|
config: wgpu::SurfaceConfiguration,
|
||||||
|
pipeline: VisPipeline,
|
||||||
|
params: VizParams,
|
||||||
|
frames: Vec<FrameData>,
|
||||||
|
palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||||
|
start_time: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// reconfigures the wgpu surface against new dimensions in physical pixels.
|
||||||
|
fn resize(&mut self, width: u32, height: u32) {
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.config.width = width;
|
||||||
|
self.config.height = height;
|
||||||
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// renders one frame: refreshes synthetic spectrum frames, ingests them into the pipeline state, uploads globals and bin packs, and dispatches the render pass.
|
||||||
|
fn render(&mut self) {
|
||||||
|
let now = web_sys::window()
|
||||||
|
.and_then(|w| w.performance())
|
||||||
|
.map(|p| p.now() / 1000.0)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let t = (now - self.start_time) as f32;
|
||||||
|
synth_frames(&mut self.frames, t);
|
||||||
|
|
||||||
|
let Ok(frame) = self.surface.get_current_texture() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let view = frame
|
||||||
|
.texture
|
||||||
|
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
|
||||||
|
let palette = self.palette.as_deref().map(|v| v.as_slice());
|
||||||
|
let frames_arc: Arc<Vec<FrameData>> = Arc::new(self.frames.clone());
|
||||||
|
let frames_id = Arc::as_ptr(&frames_arc) as usize;
|
||||||
|
self.pipeline
|
||||||
|
.state
|
||||||
|
.ingest(&self.frames, frames_id, &self.params, palette);
|
||||||
|
|
||||||
|
let w_px = self.config.width as f32;
|
||||||
|
let h_px = self.config.height as f32;
|
||||||
|
let stereo = self.pipeline.state.channels.len() > 1;
|
||||||
|
let num_channels = self.pipeline.state.channels.len() as u32;
|
||||||
|
let num_bins = self
|
||||||
|
.pipeline
|
||||||
|
.state
|
||||||
|
.channels
|
||||||
|
.first()
|
||||||
|
.map(|c| c.bins.len() as u32)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let (base_w, base_h, instances) = if self.params.mirrored {
|
||||||
|
// (N-1)/(2(N-1)-1) yields a one-bin horizontal mirror overlap for N=26 bins.
|
||||||
|
let frac = 25.0_f32 / 49.0_f32;
|
||||||
|
(w_px * frac, h_px * 0.5, 4u32)
|
||||||
|
} else {
|
||||||
|
(w_px, h_px, 1u32)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut flags = 0u32;
|
||||||
|
if self.params.glass {
|
||||||
|
flags |= FLAG_GLASS;
|
||||||
|
}
|
||||||
|
if self.params.mirrored {
|
||||||
|
flags |= FLAG_MIRRORED;
|
||||||
|
}
|
||||||
|
if self.params.inverted {
|
||||||
|
flags |= FLAG_INVERTED;
|
||||||
|
}
|
||||||
|
if stereo {
|
||||||
|
flags |= FLAG_STEREO;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uc = self.pipeline.state.unified_color;
|
||||||
|
let globals = GlobalsGpu {
|
||||||
|
bounds: [w_px, h_px],
|
||||||
|
base: [base_w, base_h],
|
||||||
|
num_bins,
|
||||||
|
num_channels,
|
||||||
|
flags,
|
||||||
|
fade_bins: if self.params.mirrored { 4 } else { 0 },
|
||||||
|
hue_param: self.params.hue,
|
||||||
|
contrast: self.params.contrast,
|
||||||
|
brightness: self.params.brightness,
|
||||||
|
_pad0: 0.0,
|
||||||
|
unified_hue: uc[0],
|
||||||
|
unified_sat: uc[1],
|
||||||
|
unified_val: uc[2],
|
||||||
|
_pad1: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut scratch_bins: Vec<BinGpu> = std::mem::take(&mut self.pipeline.scratch_bins);
|
||||||
|
let mut scratch_cep = std::mem::take(&mut self.pipeline.scratch_cep);
|
||||||
|
self.pipeline
|
||||||
|
.state
|
||||||
|
.pack_bins(&self.frames, stereo, &mut scratch_bins);
|
||||||
|
scratch_cep.clear();
|
||||||
|
if self.params.mirrored {
|
||||||
|
cep_build::build_cepstrum(&mut scratch_cep, &self.pipeline.state, w_px, h_px);
|
||||||
|
}
|
||||||
|
self.pipeline.scratch_bins = scratch_bins;
|
||||||
|
self.pipeline.scratch_cep = scratch_cep;
|
||||||
|
|
||||||
|
self.pipeline
|
||||||
|
.upload(&self.device, &self.queue, &globals, num_channels, num_bins, instances);
|
||||||
|
|
||||||
|
let mut encoder = self
|
||||||
|
.device
|
||||||
|
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("yr_crystals_web.encoder"),
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("yr_crystals_web.clear"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: &view,
|
||||||
|
depth_slice: None,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let clip = ClipRect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: self.config.width,
|
||||||
|
height: self.config.height,
|
||||||
|
};
|
||||||
|
self.pipeline.render_into(&mut encoder, &view, &clip);
|
||||||
|
|
||||||
|
self.queue.submit(std::iter::once(encoder.finish()));
|
||||||
|
frame.present();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fills the per-channel spectrum frames from live audio bins, falling back to a synthetic log-sweep.
|
||||||
|
fn synth_frames(frames: &mut Vec<FrameData>, t: f32) {
|
||||||
|
const NUM_BINS: usize = 26;
|
||||||
|
if frames.len() != 2 {
|
||||||
|
frames.clear();
|
||||||
|
frames.push(FrameData::default());
|
||||||
|
frames.push(FrameData::default());
|
||||||
|
}
|
||||||
|
let log_min = 40.0_f32.log10();
|
||||||
|
let log_max = 11_000.0_f32.log10();
|
||||||
|
let live = LIVE_BINS_DB.with(|c| c.borrow().clone());
|
||||||
|
for (channel_idx, frame) in frames.iter_mut().enumerate() {
|
||||||
|
frame.freqs.clear();
|
||||||
|
frame.db.clear();
|
||||||
|
frame.primary_db.clear();
|
||||||
|
frame.cepstrum.clear();
|
||||||
|
for i in 0..NUM_BINS {
|
||||||
|
let band = i as f32 / (NUM_BINS as f32 - 1.0);
|
||||||
|
let freq = 10f32.powf(log_min + (log_max - log_min) * band);
|
||||||
|
let db = match &live {
|
||||||
|
Some(bins) if i < bins.len() => bins[i],
|
||||||
|
_ => {
|
||||||
|
let sweep = ((t * 0.4 + channel_idx as f32 * 0.2) * std::f32::consts::TAU).sin() * 0.5 + 0.5;
|
||||||
|
let bump = (-((band - sweep).powi(2)) * 20.0).exp();
|
||||||
|
let beat = ((t * 2.0).sin() * 0.5 + 0.5) * 0.4 + 0.6;
|
||||||
|
-80.0 + 70.0 * bump * beat
|
||||||
|
}
|
||||||
|
};
|
||||||
|
frame.freqs.push(freq);
|
||||||
|
frame.db.push(db);
|
||||||
|
frame.primary_db.push(db);
|
||||||
|
}
|
||||||
|
for i in 0..NUM_BINS / 2 {
|
||||||
|
frame.cepstrum.push(0.1 * ((t * 4.0 + i as f32 * 0.5).sin()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stores a slice of per-bin dB values as the live frame source.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn push_bins_db(bins_db: &[f32]) {
|
||||||
|
LIVE_BINS_DB.with(|c| {
|
||||||
|
let mut slot = c.borrow_mut();
|
||||||
|
match slot.as_mut() {
|
||||||
|
Some(v) => {
|
||||||
|
v.clear();
|
||||||
|
v.extend_from_slice(bins_db);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
*slot = Some(bins_db.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// attaches the visualizer to a canvas resolved by the given CSS selector, starting the rAF loop.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn start(canvas_selector: &str) -> Result<(), JsValue> {
|
||||||
|
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
|
||||||
|
let document = window
|
||||||
|
.document()
|
||||||
|
.ok_or_else(|| JsValue::from_str("no document"))?;
|
||||||
|
let canvas: HtmlCanvasElement = document
|
||||||
|
.query_selector(canvas_selector)?
|
||||||
|
.ok_or_else(|| JsValue::from_str("canvas selector matched nothing"))?
|
||||||
|
.dyn_into::<HtmlCanvasElement>()?;
|
||||||
|
start_on_canvas(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// attaches the visualizer to the given canvas element directly, starting the rAF loop.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn start_on_canvas(canvas: HtmlCanvasElement) -> Result<(), JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let _ = console_log::init_with_level(log::Level::Info);
|
||||||
|
|
||||||
|
let width = canvas.width().max(1);
|
||||||
|
let height = canvas.height().max(1);
|
||||||
|
let start_time = web_sys::window()
|
||||||
|
.and_then(|w| w.performance())
|
||||||
|
.map(|p| p.now() / 1000.0)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
match build_app(canvas, width, height, start_time).await {
|
||||||
|
Ok(app) => run_loop(app),
|
||||||
|
Err(e) => log::error!("renderer init failed: {e:?}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// constructs the wgpu instance, requests an adapter against the canvas, configures the surface, and builds the visualizer pipeline.
|
||||||
|
async fn build_app(
|
||||||
|
canvas: HtmlCanvasElement,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
start_time: f64,
|
||||||
|
) -> Result<App, JsValue> {
|
||||||
|
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
|
||||||
|
backends: wgpu::Backends::BROWSER_WEBGPU,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let surface = instance
|
||||||
|
.create_surface(wgpu::SurfaceTarget::Canvas(canvas))
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("create_surface: {e}")))?;
|
||||||
|
|
||||||
|
let adapter = instance
|
||||||
|
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||||
|
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||||
|
compatible_surface: Some(&surface),
|
||||||
|
force_fallback_adapter: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("request_adapter: {e}")))?;
|
||||||
|
|
||||||
|
let (device, queue) = adapter
|
||||||
|
.request_device(&wgpu::DeviceDescriptor::default())
|
||||||
|
.await
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("request_device: {e}")))?;
|
||||||
|
|
||||||
|
let caps = surface.get_capabilities(&adapter);
|
||||||
|
let format = caps
|
||||||
|
.formats
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|f| !f.is_srgb())
|
||||||
|
.unwrap_or_else(|| caps.formats[0]);
|
||||||
|
|
||||||
|
let config = wgpu::SurfaceConfiguration {
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
present_mode: wgpu::PresentMode::AutoVsync,
|
||||||
|
alpha_mode: wgpu::CompositeAlphaMode::Auto,
|
||||||
|
view_formats: vec![],
|
||||||
|
desired_maximum_frame_latency: 2,
|
||||||
|
};
|
||||||
|
surface.configure(&device, &config);
|
||||||
|
|
||||||
|
let pipeline = VisPipeline::for_format(&device, &queue, format);
|
||||||
|
|
||||||
|
Ok(App {
|
||||||
|
surface,
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
config,
|
||||||
|
pipeline,
|
||||||
|
params: VizParams::default(),
|
||||||
|
frames: Vec::new(),
|
||||||
|
palette: None,
|
||||||
|
start_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rAF-driven loop: the closure schedules its own re-fire after each render. publishes the App into APP_HANDLE so the resize export can reach it.
|
||||||
|
fn run_loop(app: App) {
|
||||||
|
let app = Rc::new(RefCell::new(app));
|
||||||
|
APP_HANDLE.with(|cell| {
|
||||||
|
*cell.borrow_mut() = Some(app.clone());
|
||||||
|
});
|
||||||
|
|
||||||
|
let cb: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = Rc::new(RefCell::new(None));
|
||||||
|
let cb_clone = cb.clone();
|
||||||
|
let app_clone = app.clone();
|
||||||
|
|
||||||
|
*cb.borrow_mut() = Some(Closure::wrap(Box::new(move || {
|
||||||
|
app_clone.borrow_mut().render();
|
||||||
|
request_frame(cb_clone.borrow().as_ref().unwrap());
|
||||||
|
}) as Box<dyn FnMut()>));
|
||||||
|
|
||||||
|
request_frame(cb.borrow().as_ref().unwrap());
|
||||||
|
std::mem::forget(cb);
|
||||||
|
let _ = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reconfigures the wgpu surface against new physical-pixel dimensions reported from JS on window resize.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn resize(width: u32, height: u32) {
|
||||||
|
APP_HANDLE.with(|cell| {
|
||||||
|
if let Some(app) = cell.borrow().as_ref() {
|
||||||
|
app.borrow_mut().resize(width, height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// schedules the next rAF tick.
|
||||||
|
fn request_frame(cb: &Closure<dyn FnMut()>) {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.request_animation_frame(cb.as_ref().unchecked_ref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/// converts an sRGB triple in 0..=1 to hue/saturation/value, all in 0..=1.
|
||||||
|
pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let v = max;
|
||||||
|
let d = max - min;
|
||||||
|
let s = if max <= 0.0 { 0.0 } else { d / max };
|
||||||
|
let h = if d <= 1e-6 {
|
||||||
|
0.0
|
||||||
|
} else if (max - r).abs() < f32::EPSILON {
|
||||||
|
((g - b) / d).rem_euclid(6.0) / 6.0
|
||||||
|
} else if (max - g).abs() < f32::EPSILON {
|
||||||
|
((b - r) / d + 2.0) / 6.0
|
||||||
|
} else {
|
||||||
|
((r - g) / d + 4.0) / 6.0
|
||||||
|
};
|
||||||
|
((h + 1.0).rem_euclid(1.0), s, v)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
//! standalone http server serving the embedded WASM visualizer bundle.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn main() {}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::env;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::process::ExitCode;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use tiny_http::{Header, Method, Response, Server, StatusCode};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
const INDEX_HTML: &[u8] = include_bytes!("../dist/index.html");
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
|
let mut bind = String::from("0.0.0.0");
|
||||||
|
let mut port: u16 = 8080;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--port" | "-p" => {
|
||||||
|
let Some(v) = args.get(i + 1) else {
|
||||||
|
eprintln!("--port needs a value");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
};
|
||||||
|
match v.parse() {
|
||||||
|
Ok(p) => port = p,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("invalid port: {v}");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--bind" | "-b" => {
|
||||||
|
let Some(v) = args.get(i + 1) else {
|
||||||
|
eprintln!("--bind needs a value");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
};
|
||||||
|
bind = v.clone();
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("unknown arg: {other}");
|
||||||
|
print_help();
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr = format!("{bind}:{port}");
|
||||||
|
let server = match Server::http(&addr) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("failed to bind {addr}: {e}");
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("yrxtls-serve listening on http://{addr}/");
|
||||||
|
|
||||||
|
for request in server.incoming_requests() {
|
||||||
|
if !matches!(request.method(), Method::Get | Method::Head) {
|
||||||
|
let _ = request.respond(Response::empty(StatusCode(405)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = request.url().split('?').next().unwrap_or("/");
|
||||||
|
let route = normalize(path);
|
||||||
|
match route {
|
||||||
|
"/" | "/index.html" => respond(request, INDEX_HTML, "text/html; charset=utf-8"),
|
||||||
|
_ => {
|
||||||
|
let _ = request.respond(Response::from_string("not found").with_status_code(404));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// strips a trailing slash unless the path is the root, and folds repeated slashes.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn normalize(path: &str) -> &str {
|
||||||
|
if path.is_empty() {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
if path.len() > 1 && path.ends_with('/') {
|
||||||
|
return &path[..path.len() - 1];
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// writes a static byte slice as a response with the supplied content-type header.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn respond(request: tiny_http::Request, body: &'static [u8], content_type: &str) {
|
||||||
|
let header = match Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let cache = Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=3600"[..]).unwrap();
|
||||||
|
let response = Response::from_data(body).with_header(header).with_header(cache);
|
||||||
|
let _ = request.respond(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn print_help() {
|
||||||
|
eprintln!("yrxtls-serve: single-binary http server for the YrXtls visualizer bundle.");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("usage: yrxtls-serve [--bind ADDR] [--port PORT]");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" --bind ADDR interface to listen on (default 0.0.0.0)");
|
||||||
|
eprintln!(" --port PORT port to listen on (default 8080)");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
|
||||||
|
|
||||||
|
use crate::visualizer::pipeline::CepVertex;
|
||||||
|
use crate::visualizer::state::VisState;
|
||||||
|
|
||||||
|
/// emits a vertical cepstrum line plot centered in the viewport and fades the top and bottom edges.
|
||||||
|
pub fn build_cepstrum(out: &mut Vec<CepVertex>, state: &VisState, w: f32, h: f32) {
|
||||||
|
out.clear();
|
||||||
|
if state.smoothed_cepstrum.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let q_start = 12_usize;
|
||||||
|
let q_end = 600_usize.min(state.smoothed_cepstrum.len());
|
||||||
|
if q_end <= q_start {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut peak = 0.0_f32;
|
||||||
|
for i in q_start..q_end {
|
||||||
|
peak = peak.max(state.smoothed_cepstrum[i].abs());
|
||||||
|
}
|
||||||
|
if peak < 1e-7 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let inv_peak = 1.0 / peak;
|
||||||
|
let max_disp = w * 0.06;
|
||||||
|
let cx = w * 0.5;
|
||||||
|
|
||||||
|
let uc = state.unified_color;
|
||||||
|
let (cr, cg, cb_) = hsv_to_rgb(uc[0], (uc[1] * 0.7).clamp(0.0, 1.0), uc[2]);
|
||||||
|
let ca = 0.45_f32;
|
||||||
|
let fade_margin = 0.08_f32;
|
||||||
|
|
||||||
|
let edge_fade = |t: f32| -> f32 {
|
||||||
|
if t < fade_margin {
|
||||||
|
t / fade_margin
|
||||||
|
} else if t > 1.0 - fade_margin {
|
||||||
|
(1.0 - t) / fade_margin
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut prev_x = cx + state.smoothed_cepstrum[q_start] * inv_peak * max_disp;
|
||||||
|
let mut prev_y = 0.0_f32;
|
||||||
|
let mut prev_t = 0.0_f32;
|
||||||
|
|
||||||
|
for i in q_start + 1..q_end {
|
||||||
|
let t = (i - q_start) as f32 / (q_end - q_start) as f32;
|
||||||
|
let y = t * h;
|
||||||
|
let x = cx + state.smoothed_cepstrum[i] * inv_peak * max_disp;
|
||||||
|
let a0 = ca * edge_fade(prev_t);
|
||||||
|
let a1 = ca * edge_fade(t);
|
||||||
|
out.push(CepVertex {
|
||||||
|
position: [prev_x, prev_y],
|
||||||
|
color: [cr, cg, cb_, a0],
|
||||||
|
});
|
||||||
|
out.push(CepVertex {
|
||||||
|
position: [x, y],
|
||||||
|
color: [cr, cg, cb_, a1],
|
||||||
|
});
|
||||||
|
prev_x = x;
|
||||||
|
prev_y = y;
|
||||||
|
prev_t = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// converts an hsv triple in 0..1 ranges into a linear rgb triple.
|
||||||
|
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
|
||||||
|
let h = (h.fract() + 1.0).fract() * 6.0;
|
||||||
|
let i = h.floor();
|
||||||
|
let f = h - i;
|
||||||
|
let p = v * (1.0 - s);
|
||||||
|
let q = v * (1.0 - s * f);
|
||||||
|
let t = v * (1.0 - s * (1.0 - f));
|
||||||
|
match i as i32 % 6 {
|
||||||
|
0 => (v, t, p),
|
||||||
|
1 => (q, v, p),
|
||||||
|
2 => (p, v, t),
|
||||||
|
3 => (p, q, v),
|
||||||
|
4 => (t, p, v),
|
||||||
|
_ => (v, p, q),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
pub mod build;
|
||||||
|
pub mod pipeline;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
/// per-channel analyzer output bundle consumed by the visualizer.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FrameData {
|
||||||
|
pub freqs: Vec<f32>,
|
||||||
|
pub db: Vec<f32>,
|
||||||
|
pub primary_db: Vec<f32>,
|
||||||
|
pub cepstrum: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// snapshot of every visualizer toggle and slider value.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct VizParams {
|
||||||
|
pub glass: bool,
|
||||||
|
pub entropy_on: bool,
|
||||||
|
pub entropy_strength: f32,
|
||||||
|
pub album_colors: bool,
|
||||||
|
pub mirrored: bool,
|
||||||
|
pub inverted: bool,
|
||||||
|
pub hue: f32,
|
||||||
|
pub contrast: f32,
|
||||||
|
pub brightness: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VizParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
glass: true,
|
||||||
|
entropy_on: false,
|
||||||
|
entropy_strength: 0.0,
|
||||||
|
album_colors: false,
|
||||||
|
mirrored: true,
|
||||||
|
inverted: true,
|
||||||
|
hue: 0.9,
|
||||||
|
contrast: 1.0,
|
||||||
|
brightness: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,453 @@
|
||||||
|
|
||||||
|
|
||||||
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
|
use crate::visualizer::state::VisState;
|
||||||
|
|
||||||
|
/// integer clip rectangle for the visualizer's render pass viewport+scissor.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ClipRect {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gpu-side per-bin record consumed by the visualizer storage buffer.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Clone, Copy, Pod, Zeroable, Default)]
|
||||||
|
pub struct BinGpu {
|
||||||
|
pub log_x: f32,
|
||||||
|
pub visual_norm: f32,
|
||||||
|
pub primary_norm: f32,
|
||||||
|
pub bright_mod: f32,
|
||||||
|
pub alpha_mod: f32,
|
||||||
|
pub hue: f32,
|
||||||
|
pub sat: f32,
|
||||||
|
pub val: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// uniform block holding viewport size, layout counts, render flags, and the unified glass color.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
|
||||||
|
pub struct GlobalsGpu {
|
||||||
|
pub bounds: [f32; 2],
|
||||||
|
pub base: [f32; 2],
|
||||||
|
pub num_bins: u32,
|
||||||
|
pub num_channels: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
pub fade_bins: u32,
|
||||||
|
pub hue_param: f32,
|
||||||
|
pub contrast: f32,
|
||||||
|
pub brightness: f32,
|
||||||
|
pub _pad0: f32,
|
||||||
|
pub unified_hue: f32,
|
||||||
|
pub unified_sat: f32,
|
||||||
|
pub unified_val: f32,
|
||||||
|
pub _pad1: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// vertex for the cepstrum line plot, carrying pixel position and rgba color.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
|
||||||
|
pub struct CepVertex {
|
||||||
|
pub position: [f32; 2],
|
||||||
|
pub color: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bitfield values packed into GlobalsGpu::flags.
|
||||||
|
pub const FLAG_GLASS: u32 = 1;
|
||||||
|
pub const FLAG_MIRRORED: u32 = 2;
|
||||||
|
pub const FLAG_INVERTED: u32 = 4;
|
||||||
|
pub const FLAG_STEREO: u32 = 8;
|
||||||
|
|
||||||
|
/// owns the wgpu render pipelines, gpu buffers, and cpu-side smoothing state.
|
||||||
|
pub struct VisPipeline {
|
||||||
|
fill_pipeline: wgpu::RenderPipeline,
|
||||||
|
line_pipeline: wgpu::RenderPipeline,
|
||||||
|
cep_pipeline: wgpu::RenderPipeline,
|
||||||
|
|
||||||
|
bind_group: wgpu::BindGroup,
|
||||||
|
globals_buf: wgpu::Buffer,
|
||||||
|
bins_buf: wgpu::Buffer,
|
||||||
|
bins_capacity: u64,
|
||||||
|
|
||||||
|
cep_buf: wgpu::Buffer,
|
||||||
|
cep_capacity: u64,
|
||||||
|
pub cep_count: u32,
|
||||||
|
|
||||||
|
pub state: VisState,
|
||||||
|
|
||||||
|
pub fill_verts: u32,
|
||||||
|
pub line_verts: u32,
|
||||||
|
pub instances: u32,
|
||||||
|
|
||||||
|
pub scratch_bins: Vec<BinGpu>,
|
||||||
|
pub scratch_cep: Vec<CepVertex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_BINS_CAPACITY: u64 = 256 * 2;
|
||||||
|
const INITIAL_CEP_CAPACITY: u64 = 1024;
|
||||||
|
|
||||||
|
impl VisPipeline {
|
||||||
|
/// builds the three render pipelines, allocates the uniform/storage/vertex buffers, and seeds the bind group for an arbitrary color format.
|
||||||
|
pub fn for_format(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
|
||||||
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.shader"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
|
include_str!("../../shaders/visualizer.wgsl").into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.bind_layout"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.pipeline_layout"),
|
||||||
|
bind_group_layouts: &[&bind_layout],
|
||||||
|
push_constant_ranges: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let blend = wgpu::BlendState {
|
||||||
|
color: wgpu::BlendComponent {
|
||||||
|
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||||
|
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||||
|
operation: wgpu::BlendOperation::Add,
|
||||||
|
},
|
||||||
|
alpha: wgpu::BlendComponent {
|
||||||
|
src_factor: wgpu::BlendFactor::One,
|
||||||
|
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||||
|
operation: wgpu::BlendOperation::Add,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = wgpu::ColorTargetState {
|
||||||
|
format,
|
||||||
|
blend: Some(blend),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fill_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.fill"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("vs_fill"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
targets: &[Some(target.clone())],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.line"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("vs_line"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
targets: &[Some(target.clone())],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::LineList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cep_attrs = [
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: 0,
|
||||||
|
shader_location: 0,
|
||||||
|
format: wgpu::VertexFormat::Float32x2,
|
||||||
|
},
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: 8,
|
||||||
|
shader_location: 1,
|
||||||
|
format: wgpu::VertexFormat::Float32x4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let cep_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.cep"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("vs_cep"),
|
||||||
|
buffers: &[wgpu::VertexBufferLayout {
|
||||||
|
array_stride: std::mem::size_of::<CepVertex>() as wgpu::BufferAddress,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &cep_attrs,
|
||||||
|
}],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
targets: &[Some(target)],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::LineList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.globals"),
|
||||||
|
size: std::mem::size_of::<GlobalsGpu>() as u64,
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let bins_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.bins"),
|
||||||
|
size: INITIAL_BINS_CAPACITY * std::mem::size_of::<BinGpu>() as u64,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.bind_group"),
|
||||||
|
layout: &bind_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: globals_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: bins_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let cep_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.cep"),
|
||||||
|
size: INITIAL_CEP_CAPACITY * std::mem::size_of::<CepVertex>() as u64,
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
fill_pipeline,
|
||||||
|
line_pipeline,
|
||||||
|
cep_pipeline,
|
||||||
|
bind_group,
|
||||||
|
globals_buf,
|
||||||
|
bins_buf,
|
||||||
|
bins_capacity: INITIAL_BINS_CAPACITY,
|
||||||
|
cep_buf,
|
||||||
|
cep_capacity: INITIAL_CEP_CAPACITY,
|
||||||
|
cep_count: 0,
|
||||||
|
state: VisState::default(),
|
||||||
|
fill_verts: 0,
|
||||||
|
line_verts: 0,
|
||||||
|
instances: 0,
|
||||||
|
scratch_bins: Vec::with_capacity((INITIAL_BINS_CAPACITY) as usize),
|
||||||
|
scratch_cep: Vec::with_capacity(INITIAL_CEP_CAPACITY as usize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisPipeline {
|
||||||
|
|
||||||
|
/// pushes globals, scratch bins, and cepstrum vertices to the gpu, growing buffers if outgrown.
|
||||||
|
pub fn upload(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
globals: &GlobalsGpu,
|
||||||
|
num_channels: u32,
|
||||||
|
num_bins: u32,
|
||||||
|
instances: u32,
|
||||||
|
) {
|
||||||
|
queue.write_buffer(&self.globals_buf, 0, bytemuck::bytes_of(globals));
|
||||||
|
|
||||||
|
if !self.scratch_bins.is_empty() {
|
||||||
|
let needed = self.scratch_bins.len() as u64;
|
||||||
|
if needed > self.bins_capacity {
|
||||||
|
let mut new_cap = self.bins_capacity.max(1);
|
||||||
|
while new_cap < needed {
|
||||||
|
new_cap *= 2;
|
||||||
|
}
|
||||||
|
self.bins_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.bins"),
|
||||||
|
size: new_cap * std::mem::size_of::<BinGpu>() as u64,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
self.bins_capacity = new_cap;
|
||||||
|
|
||||||
|
self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.bind_group"),
|
||||||
|
layout: &device.create_bind_group_layout(&bind_layout_descriptor()),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: self.globals_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: self.bins_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queue.write_buffer(&self.bins_buf, 0, bytemuck::cast_slice(&self.scratch_bins));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cep_count = self.scratch_cep.len() as u32;
|
||||||
|
if !self.scratch_cep.is_empty() {
|
||||||
|
let needed = self.scratch_cep.len() as u64;
|
||||||
|
if needed > self.cep_capacity {
|
||||||
|
let mut new_cap = self.cep_capacity.max(1);
|
||||||
|
while new_cap < needed {
|
||||||
|
new_cap *= 2;
|
||||||
|
}
|
||||||
|
self.cep_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.cep"),
|
||||||
|
size: new_cap * std::mem::size_of::<CepVertex>() as u64,
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
self.cep_capacity = new_cap;
|
||||||
|
}
|
||||||
|
queue.write_buffer(&self.cep_buf, 0, bytemuck::cast_slice(&self.scratch_cep));
|
||||||
|
}
|
||||||
|
|
||||||
|
let segs = num_bins.saturating_sub(1);
|
||||||
|
self.fill_verts = num_channels * segs * 6;
|
||||||
|
self.line_verts = num_channels * num_bins * 2;
|
||||||
|
self.instances = instances.max(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// records a single render pass over the fills, the bin outline, and any cepstrum overlay into the clip rect.
|
||||||
|
pub fn render_into(
|
||||||
|
&self,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
target: &wgpu::TextureView,
|
||||||
|
clip: &ClipRect,
|
||||||
|
) {
|
||||||
|
if self.fill_verts == 0 && self.line_verts == 0 && self.cep_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.pass"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: target,
|
||||||
|
depth_slice: None,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Load,
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
});
|
||||||
|
pass.set_viewport(
|
||||||
|
clip.x as f32,
|
||||||
|
clip.y as f32,
|
||||||
|
clip.width as f32,
|
||||||
|
clip.height as f32,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
pass.set_scissor_rect(clip.x, clip.y, clip.width, clip.height);
|
||||||
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
|
|
||||||
|
if self.fill_verts > 0 {
|
||||||
|
pass.set_pipeline(&self.fill_pipeline);
|
||||||
|
pass.draw(0..self.fill_verts, 0..self.instances);
|
||||||
|
}
|
||||||
|
if self.line_verts > 0 {
|
||||||
|
pass.set_pipeline(&self.line_pipeline);
|
||||||
|
pass.draw(0..self.line_verts, 0..self.instances);
|
||||||
|
}
|
||||||
|
if self.cep_count > 0 {
|
||||||
|
pass.set_pipeline(&self.cep_pipeline);
|
||||||
|
pass.set_vertex_buffer(0, self.cep_buf.slice(..));
|
||||||
|
pass.draw(0..self.cep_count, 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the bind-group layout that pairs the globals uniform with the bins storage buffer.
|
||||||
|
fn bind_layout_descriptor<'a>() -> wgpu::BindGroupLayoutDescriptor<'a> {
|
||||||
|
wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("yr_crystals.visualizer.bind_layout"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::palette;
|
||||||
|
use crate::visualizer::pipeline::BinGpu;
|
||||||
|
use crate::visualizer::{FrameData, VizParams};
|
||||||
|
|
||||||
|
const HUE_HISTORY_LEN: usize = 40;
|
||||||
|
const HISTORY_LEN: usize = 30;
|
||||||
|
|
||||||
|
/// per-bin smoothed magnitude, modulation offsets, palette color, and recent visual history.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct BinState {
|
||||||
|
pub visual_db: f32,
|
||||||
|
pub primary_visual_db: f32,
|
||||||
|
pub last_raw_db: f32,
|
||||||
|
pub bright_mod: f32,
|
||||||
|
pub alpha_mod: f32,
|
||||||
|
pub cached_color: [f32; 3],
|
||||||
|
pub history: VecDeque<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// row of bins for one audio channel.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct ChannelState {
|
||||||
|
pub bins: Vec<BinState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cpu-side smoothing state for the visualizer.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct VisState {
|
||||||
|
pub channels: Vec<ChannelState>,
|
||||||
|
pub hue_history: VecDeque<(f32, f32)>,
|
||||||
|
pub hue_sum_cos: f32,
|
||||||
|
pub hue_sum_sin: f32,
|
||||||
|
pub unified_color: [f32; 3],
|
||||||
|
pub smoothed_cepstrum: Vec<f32>,
|
||||||
|
|
||||||
|
pub last_frames_id: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisState {
|
||||||
|
|
||||||
|
/// folds a fresh analyzer frame into the smoothed bin, hue, and cepstrum tracks.
|
||||||
|
pub fn ingest(
|
||||||
|
&mut self,
|
||||||
|
frames: &[FrameData],
|
||||||
|
frames_id: usize,
|
||||||
|
params: &VizParams,
|
||||||
|
palette: Option<&[[f32; 3]]>,
|
||||||
|
) {
|
||||||
|
self.last_frames_id = frames_id;
|
||||||
|
|
||||||
|
if self.channels.len() != frames.len() {
|
||||||
|
self.channels.resize(frames.len(), ChannelState::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.glass {
|
||||||
|
if let Some(f0) = frames.first() {
|
||||||
|
self.unified_color = self.update_glass_color(f0, params);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.unified_color = [0.0, 0.0, 1.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ch_idx, frame) in frames.iter().enumerate() {
|
||||||
|
ingest_channel(&mut self.channels[ch_idx], frame, params, palette);
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.mirrored {
|
||||||
|
if let Some(f0) = frames.first() {
|
||||||
|
let raw = &f0.cepstrum;
|
||||||
|
if self.smoothed_cepstrum.len() != raw.len() {
|
||||||
|
self.smoothed_cepstrum = vec![0.0; raw.len()];
|
||||||
|
}
|
||||||
|
for (i, r) in raw.iter().enumerate() {
|
||||||
|
self.smoothed_cepstrum[i] = 0.15 * r + 0.85 * self.smoothed_cepstrum[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// flattens every channel's bins into the gpu-bound vector and applies a small x-shift to the right channel.
|
||||||
|
pub fn pack_bins(&self, frames: &[FrameData], stereo: bool, out: &mut Vec<BinGpu>) {
|
||||||
|
out.clear();
|
||||||
|
let n_bins = self.channels.first().map(|c| c.bins.len()).unwrap_or(0);
|
||||||
|
if n_bins == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ch_idx, channel) in self.channels.iter().enumerate() {
|
||||||
|
let freqs = frames
|
||||||
|
.get(ch_idx)
|
||||||
|
.map(|f| f.freqs.as_slice())
|
||||||
|
.unwrap_or(&[]);
|
||||||
|
let x_offset = if ch_idx == 1 && stereo { 1.005 } else { 1.0 };
|
||||||
|
for (i, b) in channel.bins.iter().enumerate() {
|
||||||
|
let freq = freqs.get(i).copied().unwrap_or(0.0);
|
||||||
|
let visual_norm = ((b.visual_db + 80.0) / 80.0).clamp(0.0, 1.0);
|
||||||
|
let primary_norm = ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0);
|
||||||
|
out.push(BinGpu {
|
||||||
|
log_x: log_x(freq * x_offset),
|
||||||
|
visual_norm,
|
||||||
|
primary_norm,
|
||||||
|
bright_mod: b.bright_mod,
|
||||||
|
alpha_mod: b.alpha_mod,
|
||||||
|
hue: b.cached_color[0],
|
||||||
|
sat: b.cached_color[1],
|
||||||
|
val: b.cached_color[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushes the rightmost bin to the viewport edge.
|
||||||
|
let max_x = out.iter().map(|b| b.log_x).fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
if max_x.is_finite() && max_x > 0.0 {
|
||||||
|
let inv = 1.0 / max_x;
|
||||||
|
for b in out.iter_mut() {
|
||||||
|
b.log_x *= inv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// derives a hue from spectral midpoint and mean amplitude, smoothed by a circular running mean.
|
||||||
|
fn update_glass_color(&mut self, f0: &FrameData, params: &VizParams) -> [f32; 3] {
|
||||||
|
let mid_freq = f0
|
||||||
|
.freqs
|
||||||
|
.get(f0.freqs.len() / 2)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(1000.0);
|
||||||
|
let mean_db = if f0.db.is_empty() {
|
||||||
|
-80.0
|
||||||
|
} else {
|
||||||
|
f0.db.iter().sum::<f32>() / f0.db.len() as f32
|
||||||
|
};
|
||||||
|
|
||||||
|
let log_min = 20.0_f32.log10();
|
||||||
|
let log_max = 20_000.0_f32.log10();
|
||||||
|
let freq_norm =
|
||||||
|
(mid_freq.max(1e-9).log10() - log_min) / (log_max - log_min);
|
||||||
|
|
||||||
|
let amp_norm = ((mean_db + 80.0) / 80.0).clamp(0.0, 1.0);
|
||||||
|
let amp_weight = (1.0 / (freq_norm + 1e-4).powf(5.0) * 2.0).clamp(0.5, 6.0);
|
||||||
|
|
||||||
|
let mut hue = (freq_norm + amp_norm * amp_weight * params.hue).rem_euclid(1.0);
|
||||||
|
if params.mirrored {
|
||||||
|
hue = 1.0 - hue;
|
||||||
|
}
|
||||||
|
if hue < 0.0 {
|
||||||
|
hue += 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let angle = hue * std::f32::consts::TAU;
|
||||||
|
let cos_v = angle.cos();
|
||||||
|
let sin_v = angle.sin();
|
||||||
|
|
||||||
|
self.hue_history.push_back((cos_v, sin_v));
|
||||||
|
self.hue_sum_cos += cos_v;
|
||||||
|
self.hue_sum_sin += sin_v;
|
||||||
|
if self.hue_history.len() > HUE_HISTORY_LEN {
|
||||||
|
if let Some((c, s)) = self.hue_history.pop_front() {
|
||||||
|
self.hue_sum_cos -= c;
|
||||||
|
self.hue_sum_sin -= s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let smoothed_angle = self.hue_sum_sin.atan2(self.hue_sum_cos);
|
||||||
|
let mut smoothed_hue = smoothed_angle / std::f32::consts::TAU;
|
||||||
|
if smoothed_hue < 0.0 {
|
||||||
|
smoothed_hue += 1.0;
|
||||||
|
}
|
||||||
|
[smoothed_hue, 1.0, 1.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// maps a frequency in hertz to a 0..1 horizontal position on the 20 hz to 20 khz log axis.
|
||||||
|
fn log_x(freq: f32) -> f32 {
|
||||||
|
let log_min = 20.0_f32.log10();
|
||||||
|
let log_max = 20_000.0_f32.log10();
|
||||||
|
if freq <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
(freq.max(1e-9).log10() - log_min) / (log_max - log_min)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// updates one channel's bin smoothing, peak modulations, treble compensation, and palette colors.
|
||||||
|
fn ingest_channel(
|
||||||
|
channel: &mut ChannelState,
|
||||||
|
frame: &FrameData,
|
||||||
|
params: &VizParams,
|
||||||
|
palette: Option<&[[f32; 3]]>,
|
||||||
|
) {
|
||||||
|
let n = frame.db.len();
|
||||||
|
if channel.bins.len() != n {
|
||||||
|
channel.bins.resize(n, BinState::default());
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let use_entropy = params.entropy_on;
|
||||||
|
|
||||||
|
let mut bin_entropy = vec![0.0_f32; n];
|
||||||
|
if use_entropy {
|
||||||
|
for (i, b) in channel.bins.iter().enumerate() {
|
||||||
|
bin_entropy[i] = calculate_entropy(&b.history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let median_entropy = if use_entropy {
|
||||||
|
median_of(&bin_entropy)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (i, b) in channel.bins.iter_mut().enumerate() {
|
||||||
|
let raw = frame.db[i];
|
||||||
|
let primary = frame.primary_db.get(i).copied().unwrap_or(raw);
|
||||||
|
|
||||||
|
let change = raw - b.visual_db;
|
||||||
|
if use_entropy {
|
||||||
|
let relative = median_entropy - bin_entropy[i];
|
||||||
|
let base = 1.5_f32;
|
||||||
|
let reward_gain = base + params.entropy_strength;
|
||||||
|
let penalty_gain = base - params.entropy_strength;
|
||||||
|
let gain = if relative >= 0.0 { reward_gain } else { penalty_gain };
|
||||||
|
let multiplier = (1.0 + relative * gain * 2.0).clamp(0.05, 4.0);
|
||||||
|
b.visual_db += change * multiplier;
|
||||||
|
b.history.push_back(b.visual_db);
|
||||||
|
while b.history.len() > HISTORY_LEN {
|
||||||
|
b.history.pop_front();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let resp = 0.2_f32;
|
||||||
|
b.visual_db = b.visual_db * (1.0 - resp) + raw * resp;
|
||||||
|
|
||||||
|
b.history.push_back(b.visual_db);
|
||||||
|
while b.history.len() > HISTORY_LEN {
|
||||||
|
b.history.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern_resp = 0.1_f32;
|
||||||
|
b.primary_visual_db = b.primary_visual_db * (1.0 - pattern_resp) + primary * pattern_resp;
|
||||||
|
b.last_raw_db = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut vertex_energy: Vec<f32> = channel
|
||||||
|
.bins
|
||||||
|
.iter()
|
||||||
|
.map(|b| ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let split = n / 2;
|
||||||
|
let mut max_low = 0.01_f32;
|
||||||
|
let mut max_high = 0.01_f32;
|
||||||
|
for v in vertex_energy.iter().take(split) {
|
||||||
|
max_low = max_low.max(*v);
|
||||||
|
}
|
||||||
|
for v in vertex_energy.iter().skip(split) {
|
||||||
|
max_high = max_high.max(*v);
|
||||||
|
}
|
||||||
|
let treble_boost = (max_low / max_high).clamp(1.0, 40.0);
|
||||||
|
|
||||||
|
let mut global_max = 0.001_f32;
|
||||||
|
for (j, v) in vertex_energy.iter_mut().enumerate() {
|
||||||
|
if j >= split {
|
||||||
|
let t = (j - split) as f32 / (n - split) as f32;
|
||||||
|
*v *= 1.0 + (treble_boost - 1.0) * t;
|
||||||
|
}
|
||||||
|
let compressed = v.tanh();
|
||||||
|
*v = compressed;
|
||||||
|
if compressed > global_max {
|
||||||
|
global_max = compressed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v in vertex_energy.iter_mut() {
|
||||||
|
*v = (*v / global_max).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for b in channel.bins.iter_mut() {
|
||||||
|
b.bright_mod = 0.0;
|
||||||
|
b.alpha_mod = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entropy_factor = if use_entropy {
|
||||||
|
params.entropy_strength.abs().max(0.1)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
if n >= 3 {
|
||||||
|
for i in 1..n - 1 {
|
||||||
|
let curr = vertex_energy[i];
|
||||||
|
let prev = vertex_energy[i - 1];
|
||||||
|
let next = vertex_energy[i + 1];
|
||||||
|
if curr > prev && curr > next {
|
||||||
|
let left_dominant = prev > next;
|
||||||
|
let sharpness = (curr - prev).min(curr - next);
|
||||||
|
let peak_intensity =
|
||||||
|
(sharpness * 10.0 * entropy_factor).powf(0.3).clamp(0.0, 1.0);
|
||||||
|
let decay_base = 0.65 - (sharpness * 3.0).clamp(0.0, 0.35);
|
||||||
|
|
||||||
|
for d in 1..=12_i32 {
|
||||||
|
apply_pattern(&mut channel.bins, i, d, left_dominant, -1, peak_intensity, decay_base);
|
||||||
|
apply_pattern(&mut channel.bins, i, d, !left_dominant, 1, peak_intensity, decay_base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let use_palette = params.album_colors
|
||||||
|
&& palette
|
||||||
|
.map(|p| !p.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let denom = (n as f32 - 1.0).max(1.0);
|
||||||
|
for (i, b) in channel.bins.iter_mut().enumerate() {
|
||||||
|
if use_palette {
|
||||||
|
let pal = palette.unwrap();
|
||||||
|
let plen = pal.len();
|
||||||
|
let raw_idx = if plen >= n {
|
||||||
|
i * (plen - 1) / (n - 1).max(1)
|
||||||
|
} else {
|
||||||
|
i * plen / n.max(1)
|
||||||
|
};
|
||||||
|
let pal_idx = if params.mirrored {
|
||||||
|
plen.saturating_sub(1).saturating_sub(raw_idx)
|
||||||
|
} else {
|
||||||
|
raw_idx
|
||||||
|
}
|
||||||
|
.min(plen - 1);
|
||||||
|
let rgb = pal[pal_idx];
|
||||||
|
let (h, s, v) = palette::rgb_to_hsv(rgb[0], rgb[1], rgb[2]);
|
||||||
|
b.cached_color = [h, s, v];
|
||||||
|
} else {
|
||||||
|
let mut hue = i as f32 / denom;
|
||||||
|
if params.mirrored {
|
||||||
|
hue = 1.0 - hue;
|
||||||
|
}
|
||||||
|
b.cached_color = [hue, 1.0, 1.0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stamps a three-step bright/alpha modulation pattern outward from a peak bin, decaying with distance.
|
||||||
|
fn apply_pattern(
|
||||||
|
bins: &mut [BinState],
|
||||||
|
centre: usize,
|
||||||
|
dist: i32,
|
||||||
|
is_bright_side: bool,
|
||||||
|
direction: i32,
|
||||||
|
peak_intensity: f32,
|
||||||
|
decay_base: f32,
|
||||||
|
) {
|
||||||
|
let target = if direction == -1 {
|
||||||
|
centre as isize - dist as isize
|
||||||
|
} else {
|
||||||
|
centre as isize + dist as isize - 1
|
||||||
|
};
|
||||||
|
if target < 0 || target as usize >= bins.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cycle = (dist - 1) / 3;
|
||||||
|
let step = (dist - 1) % 3;
|
||||||
|
let decay = decay_base.powi(cycle);
|
||||||
|
let intensity = peak_intensity * decay;
|
||||||
|
if intensity < 0.01 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut ty = step;
|
||||||
|
if is_bright_side {
|
||||||
|
ty = (ty + 2) % 3;
|
||||||
|
}
|
||||||
|
let bin = &mut bins[target as usize];
|
||||||
|
match ty {
|
||||||
|
0 => {
|
||||||
|
bin.bright_mod += 0.8 * intensity;
|
||||||
|
bin.alpha_mod -= 0.8 * intensity;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
bin.bright_mod -= 0.8 * intensity;
|
||||||
|
bin.alpha_mod += 0.2 * intensity;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
bin.bright_mod += 0.8 * intensity;
|
||||||
|
bin.alpha_mod += 0.2 * intensity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// scores deviation of a bin's recent history from a low-frequency reconstruction as the entropy proxy.
|
||||||
|
fn calculate_entropy(history: &VecDeque<f32>) -> f32 {
|
||||||
|
let buf: Vec<f32> = history.iter().copied().collect();
|
||||||
|
let n = buf.len();
|
||||||
|
if n < 4 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let nf = n as f64;
|
||||||
|
|
||||||
|
let mut x_re = vec![0.0_f64; n];
|
||||||
|
let mut x_im = vec![0.0_f64; n];
|
||||||
|
for k in 0..n {
|
||||||
|
let mut re = 0.0;
|
||||||
|
let mut im = 0.0;
|
||||||
|
for (idx, &h) in buf.iter().enumerate() {
|
||||||
|
let angle = -2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf;
|
||||||
|
re += h as f64 * angle.cos();
|
||||||
|
im += h as f64 * angle.sin();
|
||||||
|
}
|
||||||
|
x_re[k] = re;
|
||||||
|
x_im[k] = im;
|
||||||
|
}
|
||||||
|
|
||||||
|
for k in (n / 2 + 1)..n {
|
||||||
|
x_re[k] = 0.0;
|
||||||
|
x_im[k] = 0.0;
|
||||||
|
}
|
||||||
|
for k in 1..n.div_ceil(2) {
|
||||||
|
x_re[k] *= 2.0;
|
||||||
|
x_im[k] *= 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sq_sum = 0.0_f64;
|
||||||
|
for idx in 0..n {
|
||||||
|
let mut im = 0.0;
|
||||||
|
for k in 0..n {
|
||||||
|
let angle = 2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf;
|
||||||
|
im += x_re[k] * angle.sin() + x_im[k] * angle.cos();
|
||||||
|
}
|
||||||
|
im /= nf;
|
||||||
|
sq_sum += im * im;
|
||||||
|
}
|
||||||
|
|
||||||
|
((sq_sum / nf).sqrt() as f32 / 10.0).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the median element via a partial selection sort over a copy.
|
||||||
|
fn median_of(values: &[f32]) -> f32 {
|
||||||
|
if values.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut v = values.to_vec();
|
||||||
|
let mid = v.len() / 2;
|
||||||
|
v.select_nth_unstable_by(mid, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
v[mid]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
/* desktop */
|
||||||
|
|
||||||
|
.viz-desktop.viz-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin: 1rem 0 4rem;
|
||||||
|
width: 100vw;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
line-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-canvas-wrap canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-tracks {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
bottom: 3.5rem;
|
||||||
|
width: 13rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.4rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-tracks::-webkit-scrollbar { width: 4px; }
|
||||||
|
.viz-desktop .viz-tracks::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; }
|
||||||
|
.viz-desktop .viz-track {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(200, 200, 200, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 120ms ease;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-track:hover { color: rgba(235, 235, 235, 0.85); }
|
||||||
|
.viz-desktop .viz-track.active { color: #fff; }
|
||||||
|
.viz-desktop .viz-vol {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-vol .viz-prompt { display: none; }
|
||||||
|
.viz-desktop .viz-vol.initial {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 1.1rem 1.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-vol.initial img { width: 3rem; height: 3rem; }
|
||||||
|
.viz-desktop .viz-vol.initial .viz-prompt {
|
||||||
|
display: inline-block;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-transport {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 400ms ease;
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-transport.fade { opacity: 0; }
|
||||||
|
.viz-desktop .viz-ctrl {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
color: rgba(220, 220, 220, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 120ms ease, opacity 400ms ease;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.viz-desktop .viz-ctrl.fade { opacity: 0; }
|
||||||
|
.viz-desktop .viz-ctrl:hover { background: rgba(0, 0, 0, 0.55); }
|
||||||
|
.viz-desktop .viz-ctrl img { width: 58%; height: 58%; display: block; pointer-events: none; user-select: none; }
|
||||||
|
.viz-desktop .viz-ctrl-play img { width: 50%; height: 50%; }
|
||||||
|
|
||||||
|
/* mobile */
|
||||||
|
|
||||||
|
.viz-mobile.viz-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin: 1rem 0 2rem;
|
||||||
|
width: 100vw;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.viz-mobile .viz-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
line-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.viz-mobile .viz-canvas-wrap canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
.viz-mobile .viz-vol {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.viz-mobile .viz-vol .viz-prompt { display: none; }
|
||||||
|
.viz-mobile .viz-vol.initial {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.85rem 1.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.viz-mobile .viz-vol.initial img { width: 2.2rem; height: 2.2rem; }
|
||||||
|
.viz-mobile .viz-vol.initial .viz-prompt {
|
||||||
|
display: inline-block;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 60vw;
|
||||||
|
}
|
||||||
|
.viz-mobile .viz-transport {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 400ms ease;
|
||||||
|
}
|
||||||
|
.viz-mobile .viz-transport.fade { opacity: 0; }
|
||||||
|
.viz-mobile .viz-ctrl {
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: rgba(230, 230, 230, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 120ms ease, opacity 400ms ease;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.viz-mobile .viz-ctrl.fade { opacity: 0; }
|
||||||
|
.viz-mobile .viz-ctrl:active { background: rgba(0, 0, 0, 0.6); }
|
||||||
|
.viz-mobile .viz-ctrl img { width: 58%; height: 58%; display: block; pointer-events: none; user-select: none; }
|
||||||
|
.viz-mobile .viz-ctrl-play img { width: 50%; height: 50%; }
|
||||||
|
|
||||||
|
yrxtals-tracks {
|
||||||
|
display: block;
|
||||||
|
max-width: 92vw;
|
||||||
|
margin: 1rem auto 3rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
yrxtals-tracks .viz-track {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(220, 220, 220, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 120ms ease;
|
||||||
|
}
|
||||||
|
yrxtals-tracks .viz-track:hover { color: rgba(245, 245, 245, 0.95); }
|
||||||
|
yrxtals-tracks .viz-track.active { color: #fff; }
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
// visualizer embed for else-if.org/yr_xtals.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const HOST = 'https://files.else-if.org/f/YrXtals/';
|
||||||
|
const VIS_MODULE = HOST + 'yr_crystals_web.js';
|
||||||
|
const ALBUM_FOLDER = HOST + 'Knives_For_Cutting_Corners/';
|
||||||
|
const ICON = {
|
||||||
|
play: HOST + 'assets/Play.svg',
|
||||||
|
pause: HOST + 'assets/Pause.svg',
|
||||||
|
bskip: HOST + 'assets/BSkip.svg',
|
||||||
|
fskip: HOST + 'assets/FSkip.svg',
|
||||||
|
mute: HOST + 'assets/Mute.svg',
|
||||||
|
unmute: HOST + 'assets/Unmute.svg',
|
||||||
|
};
|
||||||
|
const ANCHOR_SELECTOR = 'yrxtals';
|
||||||
|
const TRACKS_HOST_SELECTOR = 'yrxtals-tracks';
|
||||||
|
const ASPECT_W = 16;
|
||||||
|
const ASPECT_H = 9;
|
||||||
|
const NUM_BINS = 26;
|
||||||
|
const DEFAULT_FFT = 16384;
|
||||||
|
const DEFAULT_HOP = 4096;
|
||||||
|
const TRACK_PARAMS = {
|
||||||
|
'bouncy castle': { fft: 16384, hop: 2048 },
|
||||||
|
'curled': { fft: 8192, hop: 2048 },
|
||||||
|
'eeger': { fft: 16384, hop: 2048 },
|
||||||
|
'fickle': { fft: 8192, hop: 2048 },
|
||||||
|
'fire sale': { fft: 16384, hop: 2048 },
|
||||||
|
'friik': { fft: 16384, hop: 2048 },
|
||||||
|
'gourded': { fft: 16384, hop: 2048 },
|
||||||
|
'moron': { fft: 16384, hop: 2048 },
|
||||||
|
'never give an angel a front': { fft: 8192, hop: 4096 },
|
||||||
|
"now you're speaking my language":{ fft: 16384, hop: 2048 },
|
||||||
|
'ornery': { fft: 16384, hop: 2048 },
|
||||||
|
'quicksand': { fft: 16384, hop: 2048 },
|
||||||
|
'stolen art': { fft: 8192, hop: 2048 },
|
||||||
|
'them bunch': { fft: 8192, hop: 2048 },
|
||||||
|
'twig': { fft: 16384, hop: 2048 },
|
||||||
|
'we that borrowed': { fft: 16384, hop: 2048 },
|
||||||
|
};
|
||||||
|
const LOG_MIN = Math.log10(40);
|
||||||
|
const LOG_MAX = Math.log10(11000);
|
||||||
|
const CTRL_FADE_DELAY_MS = 1500;
|
||||||
|
const CTRL_FADE_DURATION_MS = 400;
|
||||||
|
const RESTART_THRESHOLD_S = 3;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// builds an img element for one of the asset URLs.
|
||||||
|
function iconImg(src) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = src;
|
||||||
|
img.draggable = false;
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const anchor = document.querySelector(ANCHOR_SELECTOR);
|
||||||
|
if (!anchor) return;
|
||||||
|
swap(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function swap(anchor) {
|
||||||
|
const mobile = isMobileOrTablet();
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'viz-wrap ' + (mobile ? 'viz-mobile' : 'viz-desktop');
|
||||||
|
|
||||||
|
const canvasWrap = document.createElement('div');
|
||||||
|
canvasWrap.className = 'viz-canvas-wrap';
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
let externalTracksHost = document.querySelector(TRACKS_HOST_SELECTOR);
|
||||||
|
if (externalTracksHost && !mobile) {
|
||||||
|
externalTracksHost.remove();
|
||||||
|
externalTracksHost = null;
|
||||||
|
}
|
||||||
|
const tracksEl = externalTracksHost || document.createElement('div');
|
||||||
|
if (!externalTracksHost) {
|
||||||
|
tracksEl.className = 'viz-tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
const volBtn = document.createElement('button');
|
||||||
|
volBtn.className = 'viz-ctrl viz-vol initial';
|
||||||
|
volBtn.setAttribute('aria-label', 'unmute');
|
||||||
|
volBtn.appendChild(iconImg(ICON.mute));
|
||||||
|
const promptText = document.createElement('span');
|
||||||
|
promptText.className = 'viz-prompt';
|
||||||
|
promptText.textContent = 'Unmute the audio to begin the visualizer to my music!';
|
||||||
|
volBtn.appendChild(promptText);
|
||||||
|
|
||||||
|
const transportEl = document.createElement('div');
|
||||||
|
transportEl.className = 'viz-transport';
|
||||||
|
|
||||||
|
const backBtn = document.createElement('button');
|
||||||
|
backBtn.className = 'viz-ctrl';
|
||||||
|
backBtn.setAttribute('aria-label', 'previous track');
|
||||||
|
backBtn.appendChild(iconImg(ICON.bskip));
|
||||||
|
|
||||||
|
const playBtn = document.createElement('button');
|
||||||
|
playBtn.className = 'viz-ctrl viz-ctrl-play';
|
||||||
|
playBtn.setAttribute('aria-label', 'play');
|
||||||
|
playBtn.appendChild(iconImg(ICON.play));
|
||||||
|
|
||||||
|
const fwdBtn = document.createElement('button');
|
||||||
|
fwdBtn.className = 'viz-ctrl';
|
||||||
|
fwdBtn.setAttribute('aria-label', 'next track');
|
||||||
|
fwdBtn.appendChild(iconImg(ICON.fskip));
|
||||||
|
|
||||||
|
transportEl.appendChild(backBtn);
|
||||||
|
transportEl.appendChild(playBtn);
|
||||||
|
transportEl.appendChild(fwdBtn);
|
||||||
|
|
||||||
|
canvasWrap.appendChild(canvas);
|
||||||
|
canvasWrap.appendChild(volBtn);
|
||||||
|
canvasWrap.appendChild(transportEl);
|
||||||
|
wrap.appendChild(canvasWrap);
|
||||||
|
if (!externalTracksHost) {
|
||||||
|
wrap.appendChild(tracksEl);
|
||||||
|
}
|
||||||
|
anchor.replaceWith(wrap);
|
||||||
|
|
||||||
|
let vizRef = null;
|
||||||
|
const fit = () => {
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = Math.max(1, Math.floor(r.width * dpr));
|
||||||
|
const h = Math.max(1, Math.floor(r.height * dpr));
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
if (vizRef && vizRef.resize) vizRef.resize(w, h);
|
||||||
|
};
|
||||||
|
fit();
|
||||||
|
new ResizeObserver(fit).observe(canvas);
|
||||||
|
window.addEventListener('resize', fit);
|
||||||
|
window.addEventListener('orientationchange', fit);
|
||||||
|
|
||||||
|
import(VIS_MODULE)
|
||||||
|
.then(mod => mod.mount(canvas))
|
||||||
|
.then(viz => {
|
||||||
|
vizRef = viz;
|
||||||
|
fit();
|
||||||
|
bootAudio({ tracksEl, volBtn, transportEl, backBtn, playBtn, fwdBtn }, viz);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[yrxtls] mount failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootAudio(ui, viz) {
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
html = await (await fetch(ALBUM_FOLDER)).text();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[yrxtls] folder fetch failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const cards = [...doc.querySelectorAll('.folder-card-wrap[data-url$=".mp3"]')];
|
||||||
|
if (cards.length === 0) return;
|
||||||
|
|
||||||
|
const tracks = cards
|
||||||
|
.map(c => {
|
||||||
|
const url = c.dataset.url;
|
||||||
|
const stem = decodeURIComponent(url.split('/').pop().replace(/\.mp3$/i, ''));
|
||||||
|
return { url, name: stem.replace(/[_-]+/g, ' ') };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||||
|
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.crossOrigin = 'anonymous';
|
||||||
|
audio.preload = 'auto';
|
||||||
|
audio.muted = true;
|
||||||
|
|
||||||
|
// log-spaced band edges in Hz.
|
||||||
|
const binEdges = new Float32Array(NUM_BINS + 1);
|
||||||
|
for (let i = 0; i <= NUM_BINS; i++) {
|
||||||
|
const t = i / NUM_BINS;
|
||||||
|
binEdges[i] = Math.pow(10, LOG_MIN + (LOG_MAX - LOG_MIN) * t);
|
||||||
|
}
|
||||||
|
const outBins = new Float32Array(NUM_BINS);
|
||||||
|
|
||||||
|
let audioCtx = null;
|
||||||
|
let analyser = null;
|
||||||
|
let gainNode = null;
|
||||||
|
let fftBuf = null;
|
||||||
|
let isMuted = true;
|
||||||
|
let lastSampleTime = -1;
|
||||||
|
let currentIndex = -1;
|
||||||
|
let currentHop = DEFAULT_HOP;
|
||||||
|
|
||||||
|
// idempotently builds the analyser plus gain graph against the media element.
|
||||||
|
function ensureGraph() {
|
||||||
|
if (audioCtx) return;
|
||||||
|
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!Ctor) return;
|
||||||
|
audioCtx = new Ctor();
|
||||||
|
const src = audioCtx.createMediaElementSource(audio);
|
||||||
|
analyser = audioCtx.createAnalyser();
|
||||||
|
analyser.fftSize = DEFAULT_FFT;
|
||||||
|
analyser.smoothingTimeConstant = 0.2;
|
||||||
|
gainNode = audioCtx.createGain();
|
||||||
|
gainNode.gain.value = isMuted ? 0 : 1;
|
||||||
|
src.connect(analyser);
|
||||||
|
analyser.connect(gainNode);
|
||||||
|
gainNode.connect(audioCtx.destination);
|
||||||
|
audio.muted = false;
|
||||||
|
fftBuf = new Float32Array(analyser.frequencyBinCount);
|
||||||
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
requestAnimationFrame(pumpFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rAF-driven FFT pump gated to one read per currentHop interval.
|
||||||
|
function pumpFrames() {
|
||||||
|
if (!analyser) return;
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
const hopSec = currentHop / audioCtx.sampleRate;
|
||||||
|
if (lastSampleTime < 0 || now - lastSampleTime >= hopSec) {
|
||||||
|
lastSampleTime = now;
|
||||||
|
analyser.getFloatFrequencyData(fftBuf);
|
||||||
|
const binHz = audioCtx.sampleRate / analyser.fftSize;
|
||||||
|
for (let b = 0; b < NUM_BINS; b++) {
|
||||||
|
const loIdx = Math.max(0, Math.floor(binEdges[b] / binHz));
|
||||||
|
const hiIdx = Math.min(fftBuf.length - 1, Math.ceil(binEdges[b + 1] / binHz));
|
||||||
|
let peak = -200;
|
||||||
|
for (let k = loIdx; k <= hiIdx; k++) {
|
||||||
|
if (fftBuf[k] > peak) peak = fftBuf[k];
|
||||||
|
}
|
||||||
|
outBins[b] = Math.max(-80, Math.min(0, peak));
|
||||||
|
}
|
||||||
|
if (viz && viz.pushBins) viz.pushBins(outBins);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(pumpFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pills = [];
|
||||||
|
tracks.forEach((track, idx) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'viz-track';
|
||||||
|
btn.textContent = track.name;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (currentIndex === idx && !audio.paused) {
|
||||||
|
audio.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadAndPlay(idx);
|
||||||
|
});
|
||||||
|
pills.push(btn);
|
||||||
|
ui.tracksEl.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// applies the per-track FFT and hop override, falling back to defaults on no match.
|
||||||
|
function applyTrackParams(name) {
|
||||||
|
const key = (name || '').trim().toLowerCase();
|
||||||
|
const params = TRACK_PARAMS[key] || { fft: DEFAULT_FFT, hop: DEFAULT_HOP };
|
||||||
|
currentHop = params.hop;
|
||||||
|
if (analyser && analyser.fftSize !== params.fft) {
|
||||||
|
analyser.fftSize = params.fft;
|
||||||
|
fftBuf = new Float32Array(analyser.frequencyBinCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// starts playback of the indexed track with wrap-around on out-of-range index.
|
||||||
|
function loadAndPlay(idx) {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
const wrapped = ((idx % tracks.length) + tracks.length) % tracks.length;
|
||||||
|
currentIndex = wrapped;
|
||||||
|
pills.forEach((p, i) => p.classList.toggle('active', i === wrapped));
|
||||||
|
const active = pills[wrapped];
|
||||||
|
if (active && active.scrollIntoView) {
|
||||||
|
active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
audio.src = tracks[wrapped].url;
|
||||||
|
ensureGraph();
|
||||||
|
applyTrackParams(tracks[wrapped].name);
|
||||||
|
audio.play().catch(err => console.error('[yrxtls] play failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayIcon() {
|
||||||
|
const playing = !!audio.src && !audio.paused;
|
||||||
|
ui.playBtn.firstChild.src = playing ? ICON.pause : ICON.play;
|
||||||
|
ui.playBtn.setAttribute('aria-label', playing ? 'pause' : 'play');
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.backBtn.addEventListener('click', () => {
|
||||||
|
if (currentIndex < 0) { loadAndPlay(0); return; }
|
||||||
|
if (!audio.paused && audio.currentTime > RESTART_THRESHOLD_S) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
} else {
|
||||||
|
loadAndPlay(currentIndex - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.fwdBtn.addEventListener('click', () => {
|
||||||
|
loadAndPlay(currentIndex < 0 ? 0 : currentIndex + 1);
|
||||||
|
});
|
||||||
|
ui.playBtn.addEventListener('click', () => {
|
||||||
|
if (currentIndex < 0) { loadAndPlay(0); return; }
|
||||||
|
if (audio.paused) {
|
||||||
|
ensureGraph();
|
||||||
|
audio.play().catch(err => console.error('[yrxtls] play failed:', err));
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
audio.addEventListener('play', updatePlayIcon);
|
||||||
|
audio.addEventListener('pause', updatePlayIcon);
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
if (currentIndex >= 0 && currentIndex < tracks.length - 1) {
|
||||||
|
loadAndPlay(currentIndex + 1);
|
||||||
|
} else {
|
||||||
|
pills.forEach(p => p.classList.remove('active'));
|
||||||
|
currentIndex = -1;
|
||||||
|
updatePlayIcon();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let fadeTimer = null;
|
||||||
|
// reveals overlay controls and arms the idle-fade timer.
|
||||||
|
function showControls() {
|
||||||
|
ui.volBtn.classList.remove('fade');
|
||||||
|
ui.transportEl.classList.remove('fade');
|
||||||
|
if (fadeTimer) { clearTimeout(fadeTimer); fadeTimer = null; }
|
||||||
|
fadeTimer = setTimeout(() => {
|
||||||
|
ui.transportEl.classList.add('fade');
|
||||||
|
if (!isMuted) ui.volBtn.classList.add('fade');
|
||||||
|
}, CTRL_FADE_DELAY_MS);
|
||||||
|
}
|
||||||
|
function setMutedState(muted) {
|
||||||
|
isMuted = muted;
|
||||||
|
const iconEl = ui.volBtn.querySelector('img');
|
||||||
|
if (iconEl) iconEl.src = muted ? ICON.mute : ICON.unmute;
|
||||||
|
ui.volBtn.setAttribute('aria-label', muted ? 'unmute' : 'mute');
|
||||||
|
if (gainNode) gainNode.gain.value = muted ? 0 : 1;
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
// resolves the seed track index by name match, falling back to alphabetical index zero.
|
||||||
|
function seedTrackIndex() {
|
||||||
|
const target = 'it caves in';
|
||||||
|
const idx = tracks.findIndex(t => t.name.trim().toLowerCase() === target);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
ui.volBtn.addEventListener('click', () => {
|
||||||
|
const wasInitial = ui.volBtn.classList.contains('initial');
|
||||||
|
if (wasInitial) {
|
||||||
|
ui.volBtn.classList.remove('initial');
|
||||||
|
loadAndPlay(seedTrackIndex());
|
||||||
|
ensureGraph();
|
||||||
|
setMutedState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!audio.src && tracks.length > 0) {
|
||||||
|
loadAndPlay(0);
|
||||||
|
}
|
||||||
|
ensureGraph();
|
||||||
|
setMutedState(!isMuted);
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', showControls);
|
||||||
|
document.addEventListener('touchstart', showControls, { passive: true });
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', start);
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue