diff --git a/src/audio.rs b/src/audio.rs index 273586d..27272ff 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -209,7 +209,10 @@ pub fn print_devices() -> anyhow::Result<()> { let host = cpal::default_host(); println!("Input devices:"); for (i, d) in host.input_devices()?.enumerate() { - println!(" [{i}] {}", d.name().unwrap_or_else(|_| "".into())); + println!( + " [{i}] {}", + d.name().unwrap_or_else(|_| "".into()) + ); } println!( "Tips: pass an index, or `monitor`/`loopback`, or a file path.\n \ @@ -311,7 +314,11 @@ pub fn start(src: Source) -> anyhow::Result { move |data: &[f32], _| { for f in data.chunks(channels) { let mid: f32 = f.iter().sum::() / f.len().max(1) as f32; - let side = if f.len() >= 2 { (f[0] - f[1]) * 0.5 } else { 0.0 }; + let side = if f.len() >= 2 { + (f[0] - f[1]) * 0.5 + } else { + 0.0 + }; push_ms(mid, side); } }, @@ -410,9 +417,7 @@ fn spawn_file_source( // back to the device's native rate and linear-resample. let supports_file_sr = dev .supported_output_configs() - .map(|mut it| { - it.any(|c| c.min_sample_rate() <= file_sr && file_sr <= c.max_sample_rate()) - }) + .map(|mut it| it.any(|c| c.min_sample_rate() <= file_sr && file_sr <= c.max_sample_rate())) .unwrap_or(false); let (scfg, out_sr) = if supports_file_sr { ( @@ -466,7 +471,7 @@ fn spawn_file_source( loop { let packet = match reader.next_packet() { Ok(Some(p)) => p, - Ok(None) => break, // EOF -> stop feeding; output silences + Ok(None) => break, // EOF -> stop feeding; output silences Err(_) => break, }; if packet.track_id != track_id { @@ -481,7 +486,11 @@ fn spawn_file_source( for frame in ilv.chunks(ch) { let mid = frame.iter().sum::() / ch as f32; - let side = if ch >= 2 { (frame[0] - frame[1]) * 0.5 } else { 0.0 }; + let side = if ch >= 2 { + (frame[0] - frame[1]) * 0.5 + } else { + 0.0 + }; // Emit `resample` output frames per input frame (linear). frac += resample; while frac >= 1.0 { @@ -536,9 +545,9 @@ pub struct Analyzer { agc_loud: f32, agc_broad: f32, agc_csd: f32, - pop: [f32; 3], // low/mid/high onset envelopes - broad_pop: f32, // broadband onset envelope - csd_pop: f32, // complex-domain onset envelope + pop: [f32; 3], // low/mid/high onset envelopes + broad_pop: f32, // broadband onset envelope + csd_pop: f32, // complex-domain onset envelope spec_edges: [(usize, usize); SPEC_N], // Predictive-IOI beat model (robust for breakcore's unstable tempo: it // tracks the running inter-onset interval, not a brittle global BPM). @@ -651,10 +660,8 @@ impl Analyzer { let mut dct = vec![0.0f32; MFCC_N * MEL_N]; for k in 1..=MFCC_N { for j in 0..MEL_N { - dct[(k - 1) * MEL_N + j] = (std::f32::consts::PI * k as f32 - * (j as f32 + 0.5) - / MEL_N as f32) - .cos(); + dct[(k - 1) * MEL_N + j] = + (std::f32::consts::PI * k as f32 * (j as f32 + 0.5) / MEL_N as f32).cos(); } } @@ -825,7 +832,11 @@ impl Analyzer { let nbin = (half - 1).max(1) as f32; let gm = (log_sum / nbin).exp(); let am = lin_sum / nbin; - let flatness = if am > 1e-9 { (gm / am).clamp(0.0, 1.0) } else { 0.0 }; + let flatness = if am > 1e-9 { + (gm / am).clamp(0.0, 1.0) + } else { + 0.0 + }; // MFCC: mel-filterbank energies (magnitude) -> log -> DCT-II. c0 // (overall energy) is dropped; c1.. = pitch-independent timbre. @@ -874,7 +885,11 @@ impl Analyzer { for i in 0..SPEC_N { spec[i] = norm(spec_raw[i], &mut self.agc_spec[i]); } - let centroid_hz = if cen_den > 1e-6 { cen_num / cen_den } else { 0.0 }; + let centroid_hz = if cen_den > 1e-6 { + cen_num / cen_den + } else { + 0.0 + }; let centroid = norm(centroid_hz, &mut self.agc_centroid); let loud = norm(loud_sum / half as f32, &mut self.agc_loud); let broad = norm(broad_flux / half as f32, &mut self.agc_broad); @@ -941,7 +956,11 @@ impl Analyzer { if (0.18..1.20).contains(&obs) { let ratio = obs / self.beat_ioi.max(1e-3); // In-range: trust it. Way off (real tempo change): adopt slowly. - let k = if (0.55..1.80).contains(&ratio) { 0.15 } else { 0.05 }; + let k = if (0.55..1.80).contains(&ratio) { + 0.15 + } else { + 0.05 + }; self.beat_ioi = (self.beat_ioi + (obs - self.beat_ioi) * k).clamp(0.18, 1.0); } self.beat_clock = 0.0; @@ -1028,8 +1047,8 @@ impl Analyzer { if best > ACF_SNAP && !(0.75..1.34).contains(&ratio) { self.beat_ioi = ioi; } else { - self.beat_ioi = (self.beat_ioi + (ioi - self.beat_ioi) * (best * 0.20)) - .clamp(0.18, 1.0); + self.beat_ioi = + (self.beat_ioi + (ioi - self.beat_ioi) * (best * 0.20)).clamp(0.18, 1.0); } } } @@ -1092,7 +1111,11 @@ pub fn analyze_file(path: &Path) -> anyhow::Result { decoded.copy_to_vec_interleaved::(&mut ilv); for frame in ilv.chunks(ch) { let mid = frame.iter().sum::() / ch as f32; - let side = if ch >= 2 { (frame[0] - frame[1]) * 0.5 } else { 0.0 }; + let side = if ch >= 2 { + (frame[0] - frame[1]) * 0.5 + } else { + 0.0 + }; samples += 1; if let Some(b) = an.push(mid, side) { frames.push(b); diff --git a/src/bin/sigil.rs b/src/bin/sigil.rs index 1abf5ee..036f3bb 100644 --- a/src/bin/sigil.rs +++ b/src/bin/sigil.rs @@ -38,6 +38,7 @@ use audio_visualizer::viz::core::{RenderContext, Visualizer}; use audio_visualizer::viz::fingerprint::{self, Accum as FpAccum}; use audio_visualizer::viz::monolith::Monolith; use audio_visualizer::viz::palette::Palette; +use audio_visualizer::viz::underground::Underground; use clap::{Parser, ValueEnum}; use nannou::app::LoopMode; use nannou::prelude::*; @@ -113,6 +114,7 @@ impl Preset { enum VisMode { Breakcore, Monolith, + Underground, } impl VisMode { @@ -120,12 +122,14 @@ impl VisMode { match self { VisMode::Breakcore => "breakcore", VisMode::Monolith => "monolith", + VisMode::Underground => "underground", } } fn parse(s: &str) -> Option { Some(match s { "breakcore" => VisMode::Breakcore, "monolith" => VisMode::Monolith, + "underground" => VisMode::Underground, _ => return None, }) } @@ -133,7 +137,8 @@ impl VisMode { fn next(self) -> Self { match self { VisMode::Breakcore => VisMode::Monolith, - VisMode::Monolith => VisMode::Breakcore, + VisMode::Monolith => VisMode::Underground, + VisMode::Underground => VisMode::Breakcore, } } } @@ -710,7 +715,11 @@ fn model(app: &App) -> Model { if scaled { cmd.args([ "-vf", - &format!("scale={w}:{h}:flags=lanczos", w = g.render_w, h = g.render_h), + &format!( + "scale={w}:{h}:flags=lanczos", + w = g.render_w, + h = g.render_h + ), ]); } cmd.args([ @@ -821,6 +830,7 @@ fn build_visualizer( match mode { VisMode::Breakcore => Box::new(Breakcore::new(seed, device, rw, rh)), VisMode::Monolith => Box::new(Monolith::new(seed, device, rw, rh)), + VisMode::Underground => Box::new(Underground::new(device, rw, rh, seed)), } } @@ -829,8 +839,7 @@ fn build_visualizer( /// the deterministic file-derived seed so the same file looks different per /// invocation but the chosen *form* still tracks the song fingerprint. fn wallclock_seed(app: &App) -> u64 { - (app.duration.since_start.as_nanos() as u64) - .wrapping_mul(0x9E37_79B9_7F4A_7C15) + (app.duration.since_start.as_nanos() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xD1B5_4A32_D192_ED03 } @@ -945,18 +954,19 @@ fn update(app: &App, m: &mut Model, upd: Update) { m.visual.install_fingerprint(fp); println!( "fingerprint: centroid={:.2} chroma={} tonal={:.2} dyn={:.2} bpm_cls={:.2}", - fp.centroid_mean, - fp.chroma_dom, - fp.tonality, - fp.dyn_range, - fp.tempo_class, + fp.centroid_mean, fp.chroma_dom, fp.tonality, fp.dyn_range, fp.tempo_class, ); } } // Audio-driven motion. The visual's own update grows/morphs/restructures. m.visual.update(&b, dt); - m.ca_env += (b.flux - m.ca_env) * if b.flux > m.ca_env { 0.55 } else { 0.10 }; + let ca_a = if b.flux > m.ca_env { + 1.0 - (-dt / 0.02).exp() + } else { + 1.0 - (-dt / 0.16).exp() + }; + m.ca_env += (b.flux - m.ca_env) * ca_a; let pal = Palette::from_audio(&b); let scale = 1.0 + (b.low * m.g.low).min(0.9) + b.low_on * 0.4; @@ -1024,7 +1034,9 @@ fn view(app: &App, m: &Model, frame: Frame) { if m.hud { let g = m.g; let extra = match &m.mode { - Mode::Render { frame: f, total, .. } => format!("RENDER {}/{}", f, total), + Mode::Render { + frame: f, total, .. + } => format!("RENDER {}/{}", f, total), Mode::Live(_) => format!("fps {:.0}", app.fps()), }; let fp_state = if m.visual.fingerprint_ready() { diff --git a/src/viz/breakcore.rs b/src/viz/breakcore.rs index d1125b4..40cd6b3 100644 --- a/src/viz/breakcore.rs +++ b/src/viz/breakcore.rs @@ -110,17 +110,17 @@ enum Kind { /// Audio still rides on top of these via the springs (premise §3). #[derive(Clone, Copy)] struct Regime { - scale: f32, // backbone normalize-scale target - melt: f32, // smooth-min fuse weight (0..1, scales melt_k) - glow: f32, // filament brightness base - speed: f32, // rotation + attractor integration multiplier - tube: f32, // base capsule radius - rib: f32, // spectral-shell presence 0..1 - deb: f32, // debris presence 0..1 - heat: f32, // colour-warmth / energy push base 0..1 - warpd: f32, // spine spectral-displacement amount - ca: f32, // chromatic-aberration multiplier - fade: f32, // feedback-fade multiplier (smaller ⇒ longer trail) + scale: f32, // backbone normalize-scale target + melt: f32, // smooth-min fuse weight (0..1, scales melt_k) + glow: f32, // filament brightness base + speed: f32, // rotation + attractor integration multiplier + tube: f32, // base capsule radius + rib: f32, // spectral-shell presence 0..1 + deb: f32, // debris presence 0..1 + heat: f32, // colour-warmth / energy push base 0..1 + warpd: f32, // spine spectral-displacement amount + ca: f32, // chromatic-aberration multiplier + fade: f32, // feedback-fade multiplier (smaller ⇒ longer trail) prefer_knot: bool, // bias the next restructure's backbone kind } @@ -388,23 +388,19 @@ impl Breakcore { // §3 springs — regime sets the base, audio + tension ride on top. // Build-up contracts the backbone toward a dense core; release kicks // it back out (spring overshoot makes the drop *punch*). - let scale_t = rg.scale * (1.0 - 0.42 * tn) + 0.30 * b.low + 0.25 * b.low_on - + 0.85 * rel; + let scale_t = rg.scale * (1.0 - 0.42 * tn) + 0.30 * b.low + 0.25 * b.low_on + 0.85 * rel; self.sp_scale.step(scale_t, 14.0, dt); self.sp_tube .step(rg.tube + 0.05 * b.mid + 0.02 * b.mid_on, 11.0, dt); self.sp_dist.step(3.2 - 0.85 * b.low, 6.0, dt); - self.sp_glow.step( - rg.glow + 0.45 * b.loud + 0.4 * b.flux + 0.35 * tn, - 9.0, - dt, - ); + self.sp_glow + .step(rg.glow + 0.45 * b.loud + 0.4 * b.flux + 0.35 * tn, 9.0, dt); // Music-locked rotation. No constant baseline — true silence leaves // the field still; every term is audio · regime · tension. A snare/hat // onset adds a quick rotational *jolt* (decays, so it reads as a kick). let sp = rg.speed * (1.0 + 0.7 * tn); - self.jolt = (self.jolt * 0.88).max(0.6 * b.high_on + 0.4 * b.mid_on); + self.jolt = (self.jolt * (-7.8 * dt).exp()).max(0.6 * b.high_on + 0.4 * b.mid_on); self.yaw += (0.7 * b.mid + 0.9 * self.jolt) * sp * dt; self.pitch += 0.4 * b.low * sp * dt; self.roll += (0.35 * b.high + 1.4 * self.jolt) * sp * dt; @@ -414,8 +410,11 @@ impl Breakcore { // widens through a build then snaps back on the drop. self.sp_dolly .step(0.55 * b.low_on + 0.45 * b.beat, 20.0, dt); - self.sp_focal - .step((1.70 + 0.55 * tn - 0.15 * b.loud).clamp(1.25, 2.40), 6.0, dt); + self.sp_focal.step( + (1.70 + 0.55 * tn - 0.15 * b.loud).clamp(1.25, 2.40), + 6.0, + dt, + ); // Broadband-onset radial shock: the whole sigil pulses out per big // hit, distinct from the section restructure. Decays in ~0.4 s. @@ -550,8 +549,7 @@ impl Breakcore { let kw = (-(dw * dw) * 60.0).exp() * beat_amp; let disp = warpd * (0.05 + 0.55 * band + 0.4 * self.b.high_on) + 0.05 * kw; let p = (base[i] + nrm * disp) * scale; - let r = (self.sp_tube.x * (0.4 + 0.7 * band) * (1.0 + 0.8 * kw)) - .clamp(0.0015, 0.0075); + let r = (self.sp_tube.x * (0.4 + 0.7 * band) * (1.0 + 0.8 * kw)).clamp(0.0015, 0.0075); out[i] = [p.x, p.y, p.z, r]; } } @@ -626,8 +624,7 @@ impl Breakcore { } let ang = pc as f32 / CHROMA_N as f32 * TAU; // A separate tilted plane from the ribs so spokes read distinctly. - let dir = vec3(ang.cos(), 0.35 * (ang * 2.0).sin(), ang.sin()) - .normalize_or_zero(); + let dir = vec3(ang.cos(), 0.35 * (ang * 2.0).sin(), ang.sin()).normalize_or_zero(); let inner = dir * (0.16 * scale); let outer = dir * ((0.16 + 0.62 * cv) * scale); let r = (self.sp_tube.x * (0.35 + 1.1 * cv)).clamp(0.0015, 0.0075); @@ -684,8 +681,7 @@ impl Breakcore { 0.13 + 0.06 * lo, self.hue_b, ); - let sat = (0.17 + 0.12 * (lo * 0.5 + self.b.mid * 0.4)) - * (1.0 - 0.25 * self.b.flatness); + let sat = (0.17 + 0.12 * (lo * 0.5 + self.b.mid * 0.4)) * (1.0 - 0.25 * self.b.flatness); let acc = oklch((0.62 + 0.28 * lo).min(0.97), sat, self.hue_a); let mut u = [0.0f32; UBO_LEN]; @@ -727,7 +723,11 @@ impl Breakcore { u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, march_cap.min(96) as f32); // Tension fuses the folds (higher melt_k) so a build melts to a core. u[21] = (0.004 + 0.008 * self.b.loud + 0.010 * tn * rg.melt).clamp(0.003, 0.018); - u[22] = if feedback && self.gpu.primed() { 1.0 } else { 0.0 }; + u[22] = if feedback && self.gpu.primed() { + 1.0 + } else { + 0.0 + }; // bounding-sphere radius: covers spine (0.92) + ribs/spokes/debris // (≤~1.05·scale) + tube, and grows with the shock so a pulse never // clips. Parked capsules sit far outside and contribute zero glow. diff --git a/src/viz/core.rs b/src/viz/core.rs index d6dffed..606d63b 100644 --- a/src/viz/core.rs +++ b/src/viz/core.rs @@ -125,7 +125,16 @@ impl Visualizer for Breakcore { c: &RenderContext, ) -> Option<&wgpu::Texture> { Some(Breakcore::render( - self, device, queue, c.pal, c.scale, c.warp, c.feedback, c.fade, c.ca_px, c.drive, + self, + device, + queue, + c.pal, + c.scale, + c.warp, + c.feedback, + c.fade, + c.ca_px, + c.drive, c.march_cap, )) } @@ -167,7 +176,16 @@ impl Visualizer for Monolith { c: &RenderContext, ) -> Option<&wgpu::Texture> { Some(Monolith::render( - self, device, queue, c.pal, c.scale, c.warp, c.feedback, c.fade, c.ca_px, c.drive, + self, + device, + queue, + c.pal, + c.scale, + c.warp, + c.feedback, + c.fade, + c.ca_px, + c.drive, c.march_cap, )) } diff --git a/src/viz/curve.rs b/src/viz/curve.rs index 4ecf5ac..076f3a7 100644 --- a/src/viz/curve.rs +++ b/src/viz/curve.rs @@ -83,8 +83,7 @@ fn hash2(mut x: u32) -> f32 { } fn grad(ix: i32, iy: i32, seed: u32) -> Vec2 { - let h = (ix as u32) - .wrapping_mul(0x9E37_79B1) + let h = (ix as u32).wrapping_mul(0x9E37_79B1) ^ (iy as u32).wrapping_mul(0x85EB_CA77) ^ seed.wrapping_mul(0xC2B2_AE3D); let a = hash2(h) * std::f32::consts::TAU; diff --git a/src/viz/geometry.rs b/src/viz/geometry.rs index f196b3a..cce8725 100644 --- a/src/viz/geometry.rs +++ b/src/viz/geometry.rs @@ -44,9 +44,7 @@ impl Attr { p.x * (rho - p.z) - p.y, p.x * p.y - beta * p.z, ), - Attr::Rossler { a, b, c } => { - vec3(-p.y - p.z, p.x + a * p.y, b + p.z * (p.x - c)) - } + Attr::Rossler { a, b, c } => vec3(-p.y - p.z, p.x + a * p.y, b + p.z * (p.x - c)), } } @@ -172,7 +170,11 @@ impl Figure { let t = tau * p[3].max(1.0) * u; let (pn, qn) = (p[0], p[1]); let r = 1.0 + p[2] * (qn * t).cos(); - vec3(r * (pn * t).cos(), r * (pn * t).sin(), p[2] * (qn * t).sin()) * 0.85 + vec3( + r * (pn * t).cos(), + r * (pn * t).sin(), + p[2] * (qn * t).sin(), + ) * 0.85 } 1 => { let sf = |ang: f32, m: f32, n1: f32, n2: f32, n3: f32| -> f32 { diff --git a/src/viz/mod.rs b/src/viz/mod.rs index 0921323..0dba57f 100644 --- a/src/viz/mod.rs +++ b/src/viz/mod.rs @@ -17,3 +17,4 @@ pub mod palette; pub mod post; pub mod shader; pub mod structure; +pub mod underground; diff --git a/src/viz/monolith.rs b/src/viz/monolith.rs index 5fdca8c..5c290b2 100644 --- a/src/viz/monolith.rs +++ b/src/viz/monolith.rs @@ -38,6 +38,7 @@ use crate::viz::math::{Spring, angle_to}; use crate::viz::palette::{Palette, oklch}; use crate::viz::post::read_texture_rgba; use crate::viz::shader::ShaderPipeline; +use crate::viz::structure::{Arch, Structure}; use nannou::wgpu; /// UBO length in f32: **10** std140 rows (40). Header layout is duplicated @@ -98,6 +99,8 @@ pub struct Monolith { fp_committed: bool, accent_class: Accent, + structure: Structure, + // springs sp_scale: Spring, // sub-bass breath (bulb world scale) sp_glow: Spring, @@ -152,6 +155,7 @@ impl Monolith { fp: Fingerprint::default(), fp_committed: false, accent_class: Accent::Cyan, + structure: Structure::new(), sp_scale: Spring { x: 1.0, v: 0.0 }, sp_glow: Spring { x: 0.55, v: 0.0 }, sp_power: Spring { x: 8.0, v: 0.0 }, @@ -217,6 +221,10 @@ impl Monolith { self.frame = self.frame.wrapping_add(1); self.b = *b; + self.structure.update(b, dt); + let arch = self.structure.arch(); + let release = self.structure.release(); + // Dynamic-range scaled motion (premise: quiet songs move slowly). let dyn_m = (0.40 + 0.85 * self.fp.dyn_range).clamp(0.40, 1.20); @@ -230,15 +238,17 @@ impl Monolith { let flux_q = b.flux * b.flux; // --- sub-bass breath: sp_scale grows on low²; the kick still reads, - // a mid-range hum doesn't. Range now ≈ 1.0..1.20 instead of ≈1.0..1.55. - let scale_t = 1.0 + 0.20 * low_q + 0.12 * b.low_on; - self.sp_scale.step(scale_t, 4.0 * dyn_m, dt); - let dist_t = 2.8 + 0.25 * low_q + 0.10 * b.low_on; + // a mid-range hum doesn't. + let scale_t = 1.0 + 0.40 * low_q + 0.22 * b.low_on; + self.sp_scale.step(scale_t, 5.0 * dyn_m, dt); + + // --- distance: kick pulls back into void; release kick-back shove. + let dist_t = 2.8 + 0.35 * low_q + 0.15 * b.low_on; self.sp_dist.step(dist_t, 4.0 * dyn_m, dt); + self.sp_dist.x += 1.2 * release * release; // Immediate shove on drop // --- bulb power: small drift around 8 — mid² nudges it gently and - // the fingerprint's tonality biases the resting point (tonal music - // keeps a cleaner low-power bulb, noisy/atonal goes higher). + // the fingerprint's tonality biases the resting point. let power_t = 7.8 + 0.5 * self.fp.tonality + 0.2 * (mid_q - 0.25); self.sp_power.step(power_t.clamp(6.5, 9.5), 2.0, dt); @@ -250,31 +260,32 @@ impl Monolith { dt, ); - // --- glitch envelope: smoothed hi-band onset / flux with a deadband - // (subtract `min_trigger` so a steady noise floor produces nothing). - // Slow attack so a single hi-hat tap doesn't ping the grid; slow - // decay so a snare roll holds the effect through the burst. + // --- glitch envelope: smoothed hi-band onset / flux with a deadband. let raw = 0.7 * b.high_on * b.high_on + 0.35 * flux_q; - let glitch_target = (raw - 0.10).max(0.0).min(1.2); - let a_g = if glitch_target > self.glitch_env { - 0.18 + let glitch_target = (raw - 0.10).clamp(0.0, 1.2); + let g_tau = if glitch_target > self.glitch_env { + 0.08 } else { - 0.04 + 0.40 }; + let a_g = 1.0 - (-dt / g_tau).exp(); self.glitch_env += (glitch_target - self.glitch_env) * a_g; // --- stutter FSM (the "drops to 12 fps" simulation). Triggered only - // by real snare-flux bursts — both bands strong, or one very strong. - // Gate is wide (0.45 s) so a busy fill can't latch repeatedly; the - // hold itself is short (~80 ms) so the freeze reads as a tic, not a - // dropped section. + // by real snare-flux bursts. self.stutter_gate = (self.stutter_gate - dt).max(0.0); self.stutter_w = (self.stutter_w - dt / 0.08).max(0.0); - let snare_burst = - (b.high_on > 0.72 && b.flux > 0.65) || b.flux > 0.90 || b.high_on > 0.85; + let snare_burst = (b.high_on > 0.75 && b.flux > 0.70) || b.flux > 0.94 || b.high_on > 0.88; + + // Gate stutter more aggressively during drops/builds so it doesn't "stuck" the energy. + let stutter_cooldown = match arch { + Arch::Drop | Arch::Build => 0.65, + _ => 0.45, + }; + if snare_burst && self.stutter_gate <= 0.0 { self.stutter_w = 1.0; - self.stutter_gate = 0.45; + self.stutter_gate = stutter_cooldown; self.held_yaw = self.yaw; self.held_pitch = self.pitch; self.held_roll = self.roll; @@ -283,24 +294,24 @@ impl Monolith { self.held_glow = self.sp_glow.x; } - // --- camera (BPM-paced + small audio jitter). No constant baseline - // beyond tempo — true silence keeps it nearly still. All terms - // gated quadratically so a quiet passage holds a steady frame. - let base_rate = 0.05 + 0.10 * self.fp.tempo_class; - let rate = base_rate * dyn_m; - self.yaw += rate * dt + 0.10 * b.beat * b.beat * dt; - self.pitch += (0.06 * low_q - 0.02) * rate * dt; - self.roll += 0.02 * high_q * dt; + // --- camera: Section-aware rotation rate. + let energy_mult = match arch { + Arch::Drop => 2.4, + Arch::Build => 1.4 + self.structure.tension(), + Arch::Ambient | Arch::Breakdown => 0.45, + _ => 1.0, + }; - // --- swirl: mid² accumulates the feedback rotation. Sustained pad - // = a slow drift; sparse mids = nearly stationary trail. + let base_rate = 0.05 + 0.10 * self.fp.tempo_class; + let rate = base_rate * dyn_m * energy_mult; + self.yaw += rate * dt + 0.15 * b.beat * b.beat * dt; + self.pitch += (0.12 * low_q - 0.04) * rate * dt; + self.roll += 0.04 * high_q * dt; + + // --- swirl: mid² accumulates the feedback rotation. self.swirl_phase += (0.15 + 0.6 * mid_q) * 0.30 * dt; - // --- colour inertia. Base hue drifts blue↔purple with centroid; the - // two accents ease toward the neon class + its partner so contrast - // stays alive. Each accent picks up a small audio nudge (mid drifts - // the primary, high drifts the secondary) so the hues breathe with - // the music instead of being statically committed. + // --- colour inertia. let base_t = (5.0 - b.centroid * 1.4).rem_euclid(TAU); let acc_t = self.accent_class.hue() + (self.fp.chroma_dom as f32) / 12.0 * 0.3 @@ -312,10 +323,7 @@ impl Monolith { self.hue_a2 = angle_to(self.hue_a2, acc2_t, ha); } - /// Render this frame into the target and return it. Bin tunables match - /// the other modes' contract so cfg keys stay shared (`fade`/`ca_px`/ - /// `drive`/`march_cap`); `warp` is unused — monolith has its own - /// audio-driven swirl so the bin's noise-warp slot is a no-op here. + /// Render this frame into the target and return it. #[allow(clippy::too_many_arguments)] pub fn render( &mut self, @@ -332,10 +340,9 @@ impl Monolith { ) -> &wgpu::Texture { let dr = drive.clamp(0.0, 3.0); let dyn_m = (0.40 + 0.85 * self.fp.dyn_range).clamp(0.40, 1.20); + let arch = self.structure.arch(); - // Held-vs-live blending: while stutter is high, upload the held - // values so the picture freezes; the shader also pins the feedback - // floor near 1.0 from `stutter_w` so the trail survives unchanged. + // Held-vs-live blending let s = self.stutter_w.clamp(0.0, 1.0); let yaw = self.yaw * (1.0 - s) + self.held_yaw * s; let pitch = self.pitch * (1.0 - s) + self.held_pitch * s; @@ -344,10 +351,7 @@ impl Monolith { let scl = self.sp_scale.x * (1.0 - s) + self.held_scale * s; let glow = self.sp_glow.x * (1.0 - s) + self.held_glow * s; - // Palette — base lands deep dark silver/blue; two accents on - // contrasting neon classes so a frame never reads as one hue. - // Lightness rides loud² so quiet stays dark; saturation rides - // tonality. Capped low so neither accent can wash the frame. + // Palette let lo = self.b.loud; let base = oklch( (0.14 + 0.14 * lo * lo).min(0.42), @@ -356,8 +360,6 @@ impl Monolith { ); let acc_sat = (0.18 + 0.05 * lo) * (0.65 + 0.40 * self.fp.tonality); let acc = oklch((0.68 + 0.20 * lo).min(0.92), acc_sat, self.hue_a); - // Secondary accent slightly less saturated so the body's primary tint - // still reads — the partner is a *contrast*, not a competing colour. let acc2 = oklch( (0.66 + 0.20 * lo).min(0.90), (acc_sat * 0.85).min(0.30), @@ -366,29 +368,22 @@ impl Monolith { let mut u = [0.0f32; UBO_LEN]; - // row0 cam — held during stutter (see above) + // row0 cam u[0] = yaw; u[1] = pitch; u[2] = roll; u[3] = dist.clamp(1.8, 4.5); // row1 p0 = scale, glow_gain, ca_px, edge_softness - // `scale` (caller's expressive multiplier) rides on top of breath. - u[4] = (scale * scl).clamp(0.4, 1.8); + u[4] = (scale * scl).clamp(0.4, 2.2); u[5] = glow.clamp(0.18, 0.85); - // CA: small base, stutter lifts it modestly (the smear during freeze - // reads as signal corruption — but not screen-wide prism shimmer). u[6] = ca_px * (0.6 + 0.4 * dr) * (1.0 + 0.6 * s); - // Tonal music keeps a crisp particle edge; noisy/atonal softens. - u[7] = (0.50 + 0.45 * (1.0 - self.fp.tonality) + 0.15 * self.b.flatness) - .clamp(0.30, 1.10); + u[7] = (0.50 + 0.45 * (1.0 - self.fp.tonality) + 0.15 * self.b.flatness).clamp(0.30, 1.10); // row2 col0 = base.rgb, fade u[8] = base[0]; u[9] = base[1]; u[10] = base[2]; - // Wider fade (longer trail) when dyn_motion is low (calm tracks leave - // more wake). Drive doesn't pull it shorter — the stutter does. u[11] = (fade * (1.0 - 0.25 * (1.0 - dyn_m))).clamp(0.02, 1.0); // row3 col1 = accent.rgb, flash @@ -403,17 +398,23 @@ impl Monolith { u[18] = (self.frame & 0xffff) as f32; u[19] = self.t; - // row5 p2 = march_steps, power_n, feedback_on, world_r (bounding) - // Mandelbulb takes ~8 iters/step — heavier than the capsule field, so - // hold the request slightly lower than breakcore's. + // row5 p2 = march_steps, power_n, feedback_on, world_r u[20] = (20.0 + 7.0 * dr).clamp(16.0, march_cap.min(96) as f32); u[21] = self.sp_power.x.clamp(6.5, 9.5); - u[22] = if feedback && self.gpu.primed() { 1.0 } else { 0.0 }; - // Bulb fits in r ≈ 1.25; pad for breath + sub-bass extension. - u[23] = (1.30 * scl + 0.20).clamp(1.0, 3.0); + u[22] = if feedback && self.gpu.primed() { + 1.0 + } else { + 0.0 + }; + u[23] = (1.30 * scl + 0.20).clamp(1.0, 3.5); // row6 p3 = grain, glitch_a, fog, beat - u[24] = (0.006 + 0.014 * self.b.flux * dr).clamp(0.0, 0.022); + // Gated grain: ambient/breakdown reduces grain base to near zero. + let g_base = match arch { + Arch::Ambient | Arch::Breakdown => 0.001, + _ => 0.005, + }; + u[24] = (g_base + 0.012 * self.b.flux * dr).clamp(0.0, 0.022); u[25] = (self.glitch_env * dr).clamp(0.0, 1.2); u[26] = (0.30 + 0.35 * self.b.loud).clamp(0.20, 0.75); u[27] = self.b.beat; @@ -430,11 +431,11 @@ impl Monolith { u[34] = self.rw as f32 / self.rh.max(1) as f32; u[35] = self.fp.tonality.clamp(0.0, 1.0); - // row9 col2 = secondary accent.rgb, _ — paints surface contrast + // row9 col2 = secondary accent.rgb, release u[36] = acc2[0]; u[37] = acc2[1]; u[38] = acc2[2]; - u[39] = 0.0; + u[39] = self.structure.release(); self.gpu.render(device, queue, &u) } diff --git a/src/viz/monolith.wgsl b/src/viz/monolith.wgsl index 7b8e99f..a38cd17 100644 --- a/src/viz/monolith.wgsl +++ b/src/viz/monolith.wgsl @@ -35,7 +35,7 @@ struct U { p3: vec4, // grain, glitch_a, fog, beat p4: vec4, // loud, low, mid, high p5: vec4, // stutter_w, swirl, aspect, tonality - col2: vec4, // accent2.rgb (secondary neon), _ + col2: vec4, // accent2.rgb (secondary neon), release }; @group(0) @binding(0) var u: U; @@ -141,8 +141,10 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let swirl = u.p5.y; // accumulated swirl angle (rad) let aspect = u.p5.z; let tonal = u.p5.w; + let release = u.col2.w; - // --- hi-band glitch grid: coarse-cell UV displacement of the FEEDBACK + // --- hi-band glitch grid + // : coarse-cell UV displacement of the FEEDBACK // sample only — the bulb itself stays geometrically stable so a busy hat // pattern can't tear the whole picture. Deadband below 0.35 so quiet // music produces no glitch at all; only a small fraction of cells shove @@ -166,12 +168,11 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let uv = in.uv; let ndc = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0); let dist = u.cam.w; - let focal = 1.6; + let focal = 1.6 + 0.4 * release * release; // FOV warp on drop let ro = vec3(0.0, 0.0, -dist); let rd = normalize(vec3(ndc.x * aspect, ndc.y, focal)); - // Ray vs bounding sphere — the background-pixel early-out that keeps the - // raymarch from melting the GPU. `rb` is set to (scale + breath + pad). + // Ray vs bounding sphere let b = dot(ro, rd); let c = dot(ro, ro) - rb * rb; let disc = b * b - c; @@ -234,12 +235,11 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // zero, only strong snares light the edge. body = body + accent2 * highf * highf * fres * fres * 0.40; } - // Particle-dither: per-pixel hash threshold against intensity. - // Each pixel is a "particle" that either shows or doesn't — - // identifies the form as point-cloud rather than solid surface. + // Particle-dither: less hectic in quiet parts + let dither_thresh = clamp(inten * 2.2 - 0.08, 0.0, 1.0); let pcell = hash21(uv * vec2(res_w, res_h) * 0.85 + vec2(frame * 0.013, frame * 0.029)); - let pkeep = step(pcell, clamp(inten * 1.65 + 0.12, 0.0, 1.0)); + let pkeep = step(pcell, dither_thresh); col = body * inten * (0.50 + 0.55 * pkeep); // Core punch — uses the per-pixel tint so the bulb's deep core // glows different shades in different regions, not one hot dot. diff --git a/src/viz/shader.rs b/src/viz/shader.rs index 025958a..c5f8e76 100644 --- a/src/viz/shader.rs +++ b/src/viz/shader.rs @@ -37,9 +37,7 @@ use nannou::wgpu; /// exactly this invariant for its one UBO, so this needs no `bytemuck` dep — /// but `U` MUST stay padding-free and match the shader's UBO layout. fn as_bytes(u: &U) -> &[u8] { - unsafe { - std::slice::from_raw_parts((u as *const U).cast::(), std::mem::size_of::()) - } + unsafe { std::slice::from_raw_parts((u as *const U).cast::(), std::mem::size_of::()) } } /// Fullscreen fragment pipeline with ping-pong feedback. `U` is the uniform diff --git a/src/viz/structure.rs b/src/viz/structure.rs index c147745..16ed4d3 100644 --- a/src/viz/structure.rs +++ b/src/viz/structure.rs @@ -93,8 +93,7 @@ impl Structure { let a_bass = 1.0 - (-dt / 0.8).exp(); let a_long = 1.0 - (-dt / 8.0).exp(); - let busy_in = - (b.flux + 0.5 * (b.low_on + b.mid_on + b.high_on)).min(1.5); + let busy_in = (b.flux + 0.5 * (b.low_on + b.mid_on + b.high_on)).min(1.5); self.e += (b.loud - self.e) * a_fast; self.br += (b.centroid - self.br) * a_fast; self.ba += (b.low - self.ba) * a_bass; diff --git a/src/viz/underground.rs b/src/viz/underground.rs new file mode 100644 index 0000000..7ce4993 --- /dev/null +++ b/src/viz/underground.rs @@ -0,0 +1,159 @@ +//! underground — geometric techno/dnb visuals. grid, boxes, strobe. +//! +//! §1 geometry: 8x8 grid of boxes. each box size/rotation tracks band energy. +//! §2 audio: kicks drive global scale + strobe. high-flux drive chromatic aberration. +//! §3 render: raymarcher with box SDF. volumetric glow + post-fx. + +use crate::audio::Bands; +use crate::viz::core::{RenderContext, Visualizer}; +use crate::viz::math::Spring; +use crate::viz::post::read_texture_rgba; +use crate::viz::shader::ShaderPipeline; +use nannou::wgpu; + +const N: usize = 64; // 8x8 grid + +#[repr(C)] +#[derive(Clone, Copy)] +struct UndergroundUbo { + // p0..p10: 11 rows of 4 floats (std140) + header: [f32; 44], + // NP * vec4 + points: [[f32; 4]; N], +} + +pub struct Underground { + seed: u64, + pipe: ShaderPipeline, + boxes: [BoxState; N], + // Global reactives + strobe: Spring, + scale: Spring, + flux: Spring, + time: f32, + w: u32, + h: u32, +} + +struct BoxState { + size: Spring, + rot_v: f32, + rot: f32, +} + +impl Underground { + pub fn new(device: &wgpu::Device, w: u32, h: u32, seed: u64) -> Self { + let wgsl = include_str!("underground.wgsl"); + let pipe = ShaderPipeline::new(device, wgsl, w, h, wgpu::TextureFormat::Rgba8UnormSrgb); + + let mut boxes = Vec::with_capacity(N); + for _ in 0..N { + boxes.push(BoxState { + size: Spring { x: 0.1, v: 0.0 }, + rot_v: 0.0, + rot: 0.0, + }); + } + + Self { + seed, + pipe, + boxes: boxes.try_into().unwrap_or_else(|_| panic!("N mismatch")), + strobe: Spring { x: 0.0, v: 0.0 }, + scale: Spring { x: 1.0, v: 0.0 }, + flux: Spring { x: 0.0, v: 0.0 }, + time: 0.0, + w, + h, + } + } +} + +impl Visualizer for Underground { + fn name(&self) -> &'static str { + "underground" + } + fn seed(&self) -> u64 { + self.seed + } + fn reseed(&mut self, seed: u64) { + self.seed = seed; + } + + fn update(&mut self, b: &Bands, dt: f32) { + self.time += dt; + + // Global reaction + self.scale.step(1.0 + b.low * 0.5, 15.0, dt); + self.strobe + .step(if b.low_on > 0.8 { 1.0 } else { 0.0 }, 30.0, dt); + self.flux.step(b.flux, 10.0, dt); + + // Per-box update + for i in 0..N { + let energy = b.spec[i % b.spec.len()]; + self.boxes[i].size.step(0.1 + energy * 0.8, 20.0, dt); + self.boxes[i].rot_v += energy * dt * 20.0; + self.boxes[i].rot_v *= 0.95; // friction + self.boxes[i].rot += self.boxes[i].rot_v * dt; + } + } + + fn element_count(&self) -> usize { + N + } + fn is_gpu(&self) -> bool { + true + } + + fn render_gpu( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + ctx: &RenderContext, + ) -> Option<&wgpu::Texture> { + let mut points = [[0.0; 4]; N]; + for i in 0..N { + let x = (i % 8) as f32 - 3.5; + let y = (i / 8) as f32 - 3.5; + points[i] = [x, y, self.boxes[i].size.x, self.boxes[i].rot]; + } + + let mut header = [0.0; 44]; + header[0] = self.time; + header[1] = self.scale.x; + header[2] = self.strobe.x; + header[3] = self.flux.x; + header[4] = ctx.scale; + header[5] = ctx.warp; + header[6] = if ctx.feedback { 1.0 } else { 0.0 }; + header[7] = ctx.fade; + header[8] = ctx.ca_px; + header[9] = ctx.drive; + header[10] = ctx.march_cap as f32; + header[40] = self.w as f32; + header[41] = self.h as f32; + header[42] = (self.w as f32) / (self.h as f32); + + let ubo = UndergroundUbo { header, points }; + Some(self.pipe.render(device, queue, &ubo)) + } + + fn current_tex(&self) -> Option<&wgpu::Texture> { + Some(self.pipe.current()) + } + + fn capture_raw( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> Option>> { + Some(read_texture_rgba( + device, + queue, + self.pipe.current(), + self.w, + self.h, + )) + } +} diff --git a/src/viz/underground.wgsl b/src/viz/underground.wgsl new file mode 100644 index 0000000..9ae145b --- /dev/null +++ b/src/viz/underground.wgsl @@ -0,0 +1,95 @@ +struct Ubo { + header: array, 11>, + points: array, 64>, +} + +@group(0) @binding(0) var ubo: Ubo; +@group(0) @binding(1) var prev_tex: texture_2d; +@group(0) @binding(2) var smp: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput { + var out: VertexOutput; + let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u)); + out.uv = uv; + out.position = vec4(uv * 2.0 - 1.0, 0.0, 1.0); + return out; +} + +fn sdBox(p: vec3, b: vec3) -> f32 { + let q = abs(p) - b; + return length(max(q, vec3(0.0))) + min(max(q.x, max(q.y, q.z)), 0.0); +} + +fn rotate(p: vec2, a: f32) -> vec2 { + let s = sin(a); + let c = cos(a); + return vec2(p.x * c - p.y * s, p.x * s + p.y * c); +} + +fn map(p: vec3) -> f32 { + var d = 1e10; + let grid_size = 1.0; + + for (var i = 0; i < 64; i = i + 1) { + let pt = ubo.points[i]; + let pos = vec3(pt.x, pt.y, 0.0); + var q = p - pos; + + q.x = rotate(q.xz, pt.w).x; // basic rotate + + let size = pt.z * 0.4; + d = min(d, sdBox(q, vec3(size))); + } + + return d; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let time = ubo.header[0].x; + let scale = ubo.header[0].y; + let strobe = ubo.header[0].z; + let flux = ubo.header[0].w; + + let res = vec2(ubo.header[10].x, ubo.header[10].y); + let aspect = ubo.header[10].z; + + var uv = (in.position.xy / res) * 2.0 - 1.0; + uv.x *= aspect; + + let ro = vec3(0.0, 0.0, -8.0 / scale); + let rd = normalize(vec3(uv, 2.0)); + + var t = 0.0; + var glow = 0.0; + let march_cap = i32(ubo.header[2].z); + + for (var i = 0; i < 96; i = i + 1) { + if (i >= march_cap) { break; } + let p = ro + rd * t; + let d = map(p); + + glow += exp(-d * 4.0) * (0.01 + flux * 0.05); + + if (d < 0.001 || t > 20.0) { break; } + t += d * 0.8; + } + + var col = vec3(glow); + col *= vec3(0.2, 0.5, 1.0); // Cyan-ish + + if (strobe > 0.5) { + col += 0.2; + } + + // Scanlines + col *= 0.8 + 0.2 * sin(in.position.y * 1.5 + time * 10.0); + + return vec4(col, 1.0); +}