diff --git a/.gitignore b/.gitignore index 88bb380..f6dccfd 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,4 @@ android/app/src/main/jniLibs/ *.xcuserstate xcuserdata/ -web/ +web/dist/ diff --git a/scripts/web/build.sh b/scripts/web/build.sh new file mode 100755 index 0000000..d51f4d3 --- /dev/null +++ b/scripts/web/build.sh @@ -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 - < push_bins_db(db), + resize: (w, h) => resize(w, h), + }; + } + console.log("[yrxtls] WebGPU unavailable, mounting canvas2D fallback"); + return __mountFallback(canvas); +} + +function __isMobileOrTablet() { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent || ""; + if (/Mobi|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|webOS/i.test(ua)) return true; + // iPads since iOS 13 report as Macintosh with multi-touch present. + if (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) return true; + return false; +} + +async function __hasWebGPU() { + if (__isMobileOrTablet()) return false; + if (typeof navigator === "undefined" || !("gpu" in navigator) || !navigator.gpu) { + return false; + } + try { + const adapter = await navigator.gpu.requestAdapter(); + return !!adapter; + } catch { + return false; + } +} + +function __mountFallback(canvas) { + const ctx = canvas.getContext("2d"); + if (!ctx) return { pushBins: () => {} }; + const NUM_BINS = 26; + const FADE_BINS = 4; + const HUE_HISTORY_LEN = 40; + const HUE_PARAM = 0.9; + const bins = new Array(NUM_BINS).fill(0); + const brightMod = new Float32Array(NUM_BINS); + const alphaMod = new Float32Array(NUM_BINS); + const hueHistory = []; + let hueSumCos = 0; + let hueSumSin = 0; + let unifiedHue = 0; + let liveDb = null; + const t0 = performance.now() / 1000; + + // bins log-spaced from 40Hz to 11kHz projected onto a 20Hz-20kHz log axis, normalized to max 1.0. + const logX = new Float32Array(NUM_BINS); + { + const log40 = Math.log10(40); + const log11k = Math.log10(11000); + const log20 = Math.log10(20); + const log20k = Math.log10(20000); + const range = log20k - log20; + for (let i = 0; i < NUM_BINS; i++) { + const band = i / (NUM_BINS - 1); + const f = Math.pow(10, log40 + band * (log11k - log40)); + logX[i] = (Math.log10(f) - log20) / range; + } + const m = logX[NUM_BINS - 1]; + for (let i = 0; i < NUM_BINS; i++) logX[i] /= m; + } + + // freq_norm and amp_weight constants matching the desktop glass-color formula. + const MID_BAND = Math.floor(NUM_BINS / 2) / (NUM_BINS - 1); + const MID_FREQ = Math.pow(10, Math.log10(40) + MID_BAND * (Math.log10(11000) - Math.log10(40))); + const FREQ_NORM = (Math.log10(MID_FREQ) - Math.log10(20)) / (Math.log10(20000) - Math.log10(20)); + const AMP_WEIGHT = Math.max(0.5, Math.min(6, Math.pow(1 / (FREQ_NORM + 1e-4), 5) * 2)); + + // per-bin hue with the mirror-inverted mapping the desktop ingest applies in default config. + const binHues = new Float32Array(NUM_BINS); + for (let i = 0; i < NUM_BINS; i++) { + binHues[i] = 1 - i / (NUM_BINS - 1); + } + + // drives unifiedHue from amp-weighted freq norm through a circular running mean. + function updateUnifiedHue() { + let sum = 0; + for (let i = 0; i < NUM_BINS; i++) sum += bins[i]; + const ampNorm = Math.max(0, Math.min(1, sum / NUM_BINS)); + + let hue = (FREQ_NORM + ampNorm * AMP_WEIGHT * HUE_PARAM) % 1; + if (hue < 0) hue += 1; + hue = 1 - hue; + if (hue < 0) hue += 1; + + const angle = hue * Math.PI * 2; + const c = Math.cos(angle); + const s = Math.sin(angle); + hueHistory.push([c, s]); + hueSumCos += c; + hueSumSin += s; + if (hueHistory.length > HUE_HISTORY_LEN) { + const old = hueHistory.shift(); + hueSumCos -= old[0]; + hueSumSin -= old[1]; + } + if (Math.abs(hueSumCos) + Math.abs(hueSumSin) > 0.01) { + const smoothed = Math.atan2(hueSumSin, hueSumCos); + unifiedHue = ((smoothed / (Math.PI * 2)) + 1) % 1; + } + } + + // stamps the three-step bright/alpha pattern outward from a peak bin with distance-decayed intensity. + function applyPattern(centre, dist, isBrightSide, direction, peakIntensity, decayBase) { + const target = direction === -1 ? centre - dist : centre + dist - 1; + if (target < 0 || target >= NUM_BINS) return; + const cycle = Math.floor((dist - 1) / 3); + const step = (dist - 1) % 3; + const decay = Math.pow(decayBase, cycle); + const intensity = peakIntensity * decay; + if (intensity < 0.01) return; + const ty = isBrightSide ? (step + 2) % 3 : step; + if (ty === 0) { + brightMod[target] += 0.8 * intensity; + alphaMod[target] -= 0.8 * intensity; + } else if (ty === 1) { + brightMod[target] -= 0.8 * intensity; + alphaMod[target] += 0.2 * intensity; + } else { + brightMod[target] += 0.8 * intensity; + alphaMod[target] += 0.2 * intensity; + } + } + + // recomputes per-bin bright/alpha modulations from local-maxima peaks. + function updatePeakMods() { + for (let i = 0; i < NUM_BINS; i++) { + brightMod[i] = 0; + alphaMod[i] = 0; + } + for (let i = 1; i < NUM_BINS - 1; i++) { + const curr = bins[i]; + const prev = bins[i - 1]; + const next = bins[i + 1]; + if (curr > prev && curr > next) { + const leftDominant = prev > next; + const sharpness = Math.min(curr - prev, curr - next); + const peakIntensity = Math.max(0, Math.min(1, Math.pow(Math.max(0, sharpness) * 10, 0.3))); + const decayBase = 0.65 - Math.max(0, Math.min(0.35, sharpness * 3)); + for (let d = 1; d <= 12; d++) { + applyPattern(i, d, leftDominant, -1, peakIntensity, decayBase); + applyPattern(i, d, !leftDominant, 1, peakIntensity, decayBase); + } + } + } + } + + // quadratic fade ramp over the last FADE_BINS positions. + function segFade(idx, count) { + const fromEnd = count - 1 - idx; + if (fromEnd < FADE_BINS) { + const f = (fromEnd + 1) / (FADE_BINS + 1); + return f * f; + } + return 1; + } + + // log-x trapezoid fills under the glass hue with per-bin rainbow spike accents. + function drawSpectrum(baseW, baseH) { + const denom = NUM_BINS - 1; + const fillHue = unifiedHue * 360; + + for (let i = 0; i < denom; i++) { + const x1 = logX[i] * baseW; + const x2 = logX[i + 1] * baseW; + const y1 = baseH - bins[i] * baseH; + const y2 = baseH - bins[i + 1] * baseH; + const a = bins[i]; + const bm = brightMod[i]; + const bMult = bm >= 0 ? 1 + bm : 1 / (1 - bm * 2); + const brightness = Math.max(0, Math.min(1, Math.sqrt(a) * bMult)); + const lum = brightness * 50; + const am = alphaMod[i]; + const aMult = Math.max(0.1, am >= 0 ? 1 + am * 0.5 : 1 + am); + const alphaBase = Math.max(0, Math.min(1, (0.4 + (a - 0.5)) * aMult)); + const alpha = alphaBase * segFade(i, denom); + ctx.fillStyle = "hsla(" + fillHue + ", 100%, " + lum + "%, " + alpha + ")"; + ctx.beginPath(); + ctx.moveTo(x1, baseH); + ctx.lineTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.lineTo(x2, baseH); + ctx.closePath(); + ctx.fill(); + } + + const lineW = Math.max(1, baseW * 0.004); + for (let i = 0; i < NUM_BINS; i++) { + const x = logX[i] * baseW; + const y = baseH - bins[i] * baseH; + const a = bins[i]; + const bm = brightMod[i]; + const bMult = bm >= 0 ? 1 + bm : 1 / (1 - bm * 2); + const brightness = Math.max(0, Math.min(1, Math.sqrt(a) * bMult)); + const lineHue = binHues[i] * 360; + const lum = brightness * 50; + const am = alphaMod[i]; + const aMult = Math.max(0.1, am >= 0 ? 1 + am * 0.5 : 1 + am); + const alphaBase = Math.max(0, Math.min(0.9, (0.4 + (a - 0.5)) * aMult)); + const alpha = alphaBase * segFade(i, NUM_BINS); + ctx.strokeStyle = "hsla(" + lineHue + ", 90%, " + lum + "%, " + alpha + ")"; + ctx.lineWidth = lineW; + ctx.beginPath(); + ctx.moveTo(x, baseH); + ctx.lineTo(x, y); + ctx.stroke(); + } + } + + // reflects the base-rect spectrum into one canvas quadrant. + function drawQuadrant(baseW, baseH, flipX, flipY, w, h) { + ctx.save(); + ctx.translate(flipX ? w : 0, flipY ? h : 0); + ctx.scale(flipX ? -1 : 1, flipY ? -1 : 1); + drawSpectrum(baseW, baseH); + ctx.restore(); + } + + function frame() { + const t = performance.now() / 1000 - t0; + const w = canvas.width; + const h = canvas.height; + if (w === 0 || h === 0) { + requestAnimationFrame(frame); + return; + } + if (liveDb) { + for (let i = 0; i < NUM_BINS; i++) { + const db = i < liveDb.length ? liveDb[i] : -80; + const norm = Math.max(0, Math.min(1, (db + 80) / 80)); + bins[i] = bins[i] * 0.55 + norm * 0.45; + } + } else { + // log-frequency sweep with a beat envelope. + for (let i = 0; i < NUM_BINS; i++) { + const band = i / (NUM_BINS - 1); + const sweep = Math.sin(t * 0.4) * 0.5 + 0.5; + const bump = Math.exp(-((band - sweep) ** 2) * 20); + const beat = (Math.sin(t * 2) * 0.5 + 0.5) * 0.4 + 0.6; + const target = bump * beat; + bins[i] = bins[i] * 0.8 + target * 0.2; + } + } + updateUnifiedHue(); + updatePeakMods(); + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, w, h); + // (N-1)/(2(N-1)-1) yields a one-bin horizontal mirror overlap for N=26 bins. + const baseW = w * (NUM_BINS - 1) / (2 * (NUM_BINS - 1) - 1); + const baseH = h * 0.5; + drawQuadrant(baseW, baseH, false, false, w, h); + drawQuadrant(baseW, baseH, true, false, w, h); + drawQuadrant(baseW, baseH, false, true, w, h); + drawQuadrant(baseW, baseH, true, true, w, h); + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + return { + pushBins: (db) => { liveDb = db; }, + resize: () => {}, + }; +} +''' + +(dist / "yr_crystals_web.js").write_text(shim + wrapper) +size = (dist / "yr_crystals_web.js").stat().st_size +print(f" wrote {dist / 'yr_crystals_web.js'} ({size / 1024:.0f} KB)") + +# minimal local-test page that imports the same JS by sibling path. +test_html = '''YrXtls local test + + +''' +(dist / "index.html").write_text(test_html) +print(f" wrote {dist / 'index.html'} (local test page)") +PY + +cp -R "$ROOT/assets" "$DIST/assets" +echo " copied $ROOT/assets -> $DIST/assets" + +cp "$ROOT/web/yr_crystals_embed.js" "$DIST/yr_crystals_embed.js" +echo " copied $ROOT/web/yr_crystals_embed.js -> $DIST/yr_crystals_embed.js" + +cp "$ROOT/web/yr_crystals_embed.css" "$DIST/yr_crystals_embed.css" +echo " copied $ROOT/web/yr_crystals_embed.css -> $DIST/yr_crystals_embed.css" + +rm -rf "$TMP" + +echo +echo "deploy: upload the contents of web/dist/ to your file host (yr_crystals_web.js, yr_crystals_embed.js, yr_crystals_embed.css, assets/)." +echo "test locally: (cd web/dist && python3 -m http.server 8080) then open http://localhost:8080/" diff --git a/src/processor.rs b/src/processor.rs index c41706a..c9013ed 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -131,7 +131,7 @@ impl Processor { 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) { self.sample_freqs.clear(); self.freqs_const.clear(); diff --git a/web/Cargo.toml b/web/Cargo.toml new file mode 100644 index 0000000..8e62bfd --- /dev/null +++ b/web/Cargo.toml @@ -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" diff --git a/web/build.rs b/web/build.rs new file mode 100644 index 0000000..7432f49 --- /dev/null +++ b/web/build.rs @@ -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()); +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ea12128 --- /dev/null +++ b/web/index.html @@ -0,0 +1,132 @@ + + + + + + YrXtls + + + + +
booting...
+ + + diff --git a/web/shaders/visualizer.wgsl b/web/shaders/visualizer.wgsl new file mode 100644 index 0000000..539faf8 --- /dev/null +++ b/web/shaders/visualizer.wgsl @@ -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, // full canvas in pixels + base: vec2, // 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 globals: Globals; +@group(0) @binding(1) var bins: array; + +struct VertexOut { + @builtin(position) clip_position: vec4, + @location(0) color: vec4, +}; + +fn flag(bit: u32) -> bool { + return (globals.flags & bit) != 0u; +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3 { + 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(v, t, p); } + if (ii == 1) { return vec3(q, v, p); } + if (ii == 2) { return vec3(p, v, t); } + if (ii == 3) { return vec3(p, q, v); } + if (ii == 4) { return vec3(t, p, v); } + return vec3(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 { + 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 { + 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, ch: u32) -> vec3 { + if (ch == 1u && flag(8u)) { + let off = 40.0 / 255.0; + return vec3( + 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) -> vec4 { + 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(nx, ny, 0.0, 1.0); +} + +fn mirror_xform(iid: u32, p: vec2) -> vec2 { + 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(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; + switch corner { + case 0u: { p = vec2(x1, anchor_y); } + case 1u: { p = vec2(x1, y1); } + case 2u: { p = vec2(x2, y2); } + case 3u: { p = vec2(x1, anchor_y); } + case 4u: { p = vec2(x2, y2); } + default: { p = vec2(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(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; + if (endpoint == 0u) { + p = vec2(x, anchor_y); + } else { + p = vec2(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(rgb, a); + return out; +} + +// cepstrum line strip in pixel space. +struct CepIn { + @location(0) position: vec2, + @location(1) color: vec4, +}; + +@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 { + return in.color; +} diff --git a/web/src/lib.rs b/web/src/lib.rs new file mode 100644 index 0000000..17dd173 --- /dev/null +++ b/web/src/lib.rs @@ -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>>> = const { RefCell::new(None) }; + /// live per-bin dB values from JS audio analysis. + static LIVE_BINS_DB: RefCell>> = 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, + palette: Option>>, + 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> = 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 = 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, 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::()?; + 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 { + 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>>> = 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)); + + 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) { + if let Some(window) = web_sys::window() { + let _ = window.request_animation_frame(cb.as_ref().unchecked_ref()); + } +} diff --git a/web/src/palette.rs b/web/src/palette.rs new file mode 100644 index 0000000..25129b8 --- /dev/null +++ b/web/src/palette.rs @@ -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) +} diff --git a/web/src/server.rs b/web/src/server.rs new file mode 100644 index 0000000..a86bbec --- /dev/null +++ b/web/src/server.rs @@ -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 = 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)"); +} diff --git a/web/src/visualizer/build.rs b/web/src/visualizer/build.rs new file mode 100644 index 0000000..11049e9 --- /dev/null +++ b/web/src/visualizer/build.rs @@ -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, 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), + } +} diff --git a/web/src/visualizer/mod.rs b/web/src/visualizer/mod.rs new file mode 100644 index 0000000..497b81c --- /dev/null +++ b/web/src/visualizer/mod.rs @@ -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, + pub db: Vec, + pub primary_db: Vec, + pub cepstrum: Vec, +} + +/// 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, + } + } +} diff --git a/web/src/visualizer/pipeline.rs b/web/src/visualizer/pipeline.rs new file mode 100644 index 0000000..1f6aacb --- /dev/null +++ b/web/src/visualizer/pipeline.rs @@ -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, + pub scratch_cep: Vec, +} + +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::() 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::() 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::() 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::() 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::() 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::() 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, + }, + ], + } +} diff --git a/web/src/visualizer/state.rs b/web/src/visualizer/state.rs new file mode 100644 index 0000000..f722377 --- /dev/null +++ b/web/src/visualizer/state.rs @@ -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, +} + +/// row of bins for one audio channel. +#[derive(Debug, Default, Clone)] +pub struct ChannelState { + pub bins: Vec, +} + +/// cpu-side smoothing state for the visualizer. +#[derive(Debug, Default)] +pub struct VisState { + pub channels: Vec, + 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, + + 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) { + 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::() / 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 = 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 { + let buf: Vec = 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] +} diff --git a/web/yr_crystals_embed.css b/web/yr_crystals_embed.css new file mode 100644 index 0000000..4beb90c --- /dev/null +++ b/web/yr_crystals_embed.css @@ -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; } diff --git a/web/yr_crystals_embed.js b/web/yr_crystals_embed.js new file mode 100644 index 0000000..22e6223 --- /dev/null +++ b/web/yr_crystals_embed.js @@ -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(); + } +})();