diff --git a/.gitignore b/.gitignore index 70cf4f9..027c6d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target/ *.mp4 *.flac +*.png diff --git a/src/audio.rs b/src/audio.rs index 2f073e6..f2c2df5 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -56,6 +56,16 @@ const AGC_DECAY: f32 = 0.9992; const AGC_FLOOR: f32 = 1e-4; // Onset: instantaneous attack on rising spectral flux, fast release -> a hit. const ONSET_RELEASE: f32 = 0.78; +// Autocorrelation tempo: window of broadband-flux history, beat-period search +// range, and confidence gates. Period search 0.34..0.95 s ~= 63..176 BPM; +// detected BPM is octave-folded into [BPM_FOLD_LO, BPM_FOLD_HI] (one octave). +const ACF_WIN_SECS: f32 = 3.0; +const ACF_PERIOD_LO: f32 = 0.34; // s/beat (fast end of the lag search) +const ACF_PERIOD_HI: f32 = 0.95; // s/beat (slow end of the lag search) +const ACF_CONF_MIN: f32 = 0.15; // below this the ACF peak is noise -> ignore +const ACF_SNAP: f32 = 0.30; // strong + wrong-octave IOI -> snap, don't glide +const BPM_FOLD_LO: f32 = 88.0; +const BPM_FOLD_HI: f32 = 176.0; /// Per-band level (AGC-normalised, smoothed) + onset spike + rich descriptors. /// All scalar fields are 0..~1. @@ -73,6 +83,13 @@ pub struct Bands { pub centroid: f32, /// Broadband half-wave spectral flux onset -> global transient flashes. pub flux: f32, + /// Rectified complex-domain onset (phase-deviation, AGC'd + spiked like + /// `flux`). Magnitude flux misses *soft* attacks (a pad swell, a new synth + /// note at steady level); a fresh note still disrupts per-bin phase + /// predictability, so this fires on tonal/harmonic onsets `flux` can't see. + /// Zero-latency chord-attack drive for chroma / harmonic-spoke visuals and + /// a sharper feed for breakcore's `Structure::novelty`. + pub csd: f32, /// Overall loudness (mean magnitude), AGC-normalised -> lightness/scale. pub loud: f32, /// Short decaying pulse on a predicted beat (like an onset, but tempo-gated). @@ -80,6 +97,11 @@ pub struct Bands { /// Sawtooth 0..1: fraction of the predicted beat interval elapsed. Lets /// the visualiser *anticipate* the next hit instead of reacting late. pub beat_phase: f32, + /// Autocorrelation-estimated dominant tempo (BPM), octave-folded into a + /// stable range; 0 until the ACF is confident. The predictive-IOI model + /// is *anchored* to this, so `beat_phase` no longer drifts an octave on + /// syncopated breakcore fills — sync pulses/dolly-punches to this grid. + pub bpm: f32, /// Spectral flatness 0 (tonal/pad) .. 1 (noisy/break) -> smooth vs jagged. pub flatness: f32, /// Relative pitch-class energy (max-normalised) -> harmonic accent hues. @@ -101,9 +123,11 @@ impl Default for Bands { spec: [0.0; SPEC_N], centroid: 0.0, flux: 0.0, + csd: 0.0, loud: 0.0, beat: 0.0, beat_phase: 0.0, + bpm: 0.0, flatness: 0.0, chroma: [0.0; CHROMA_N], wave: [0.0; WAVE_N], @@ -464,6 +488,11 @@ pub struct Analyzer { since_hop: usize, spectrum: Vec>, prev_mag: Vec, + // Per-bin phase at t-1 / t-2 for the complex-domain (CSD) onset: a steady + // tone advances phase linearly, so `2*ph[-1] - ph[-2]` predicts the next + // bin; the Euclidean miss from that prediction is the onset energy. + prev_phase: Vec, + prev_phase2: Vec, bin_hz: f32, env: Bands, // AGC ceilings: 3 level, 3 flux, SPEC_N spectrum, centroid, loud, broad flux. @@ -473,8 +502,10 @@ pub struct Analyzer { agc_centroid: f32, 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 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). @@ -483,6 +514,15 @@ pub struct Analyzer { beat_clock: f32, // seconds since the last accepted beat prev_broad: f32, // for the broadband-onset rising edge beat_pop: f32, // decaying beat pulse + // Autocorrelation tempo tracker: ring of recent broadband-flux samples, + // a reused time-ordered scratch, the lag search bounds (frames), and the + // smoothed octave-folded BPM that anchors `beat_ioi`. + flux_hist: Vec, + flux_head: usize, + acf_buf: Vec, + acf_lag_min: usize, + acf_lag_max: usize, + bpm: f32, } fn norm(v: f32, c: &mut f32) -> f32 { @@ -518,6 +558,13 @@ impl Analyzer { *e = (a, b); } + // ACF tempo sizing (frames). hop_dt = seconds advanced per STFT hop; + // window must comfortably exceed the slowest searched period. + let hop_dt = HOP as f32 / sample_rate.max(1.0); + let acf_lag_min = (ACF_PERIOD_LO / hop_dt).round().max(1.0) as usize; + let acf_lag_max = (ACF_PERIOD_HI / hop_dt).round() as usize; + let acf_n = ((ACF_WIN_SECS / hop_dt).round() as usize).max(acf_lag_max + 2); + Analyzer { hann, fft, @@ -526,6 +573,8 @@ impl Analyzer { since_hop: 0, spectrum: vec![Complex::new(0.0, 0.0); FFT_SIZE], prev_mag: vec![0.0; half], + prev_phase: vec![0.0; half], + prev_phase2: vec![0.0; half], bin_hz, env: Bands::default(), agc_lvl: [AGC_FLOOR; 3], @@ -534,14 +583,22 @@ impl Analyzer { agc_centroid: AGC_FLOOR, agc_loud: AGC_FLOOR, agc_broad: AGC_FLOOR, + agc_csd: AGC_FLOOR, pop: [0.0; 3], broad_pop: 0.0, + csd_pop: 0.0, spec_edges, - hop_dt: HOP as f32 / sample_rate.max(1.0), + hop_dt, beat_ioi: 0.5, // ~120 BPM until the track tells us otherwise beat_clock: 0.0, prev_broad: 0.0, beat_pop: 0.0, + flux_hist: vec![0.0; acf_n], + flux_head: 0, + acf_buf: vec![0.0; acf_n], + acf_lag_min, + acf_lag_max, + bpm: 0.0, } } @@ -572,19 +629,33 @@ impl Analyzer { (a, b.max(a + 1)) }; - // Magnitudes (cache once), running flux + centroid + loudness. + // Magnitudes (cache once), running flux + centroid + loudness, plus + // the rectified complex-domain onset: predict each bin assuming a + // steady tone (constant magnitude, phase advancing at last frame's + // rate) and sum the Euclidean miss where energy *rose* (onset, not + // offset). Phase shifts t-1 -> t-2 in the same pass (no extra Vec). let mut mags = vec![0.0f32; half]; let mut broad_flux = 0.0f32; + let mut csd_raw = 0.0f32; let mut cen_num = 0.0f32; let mut cen_den = 0.0f32; let mut loud_sum = 0.0f32; for k in 0..half { - let m = self.spectrum[k].norm(); + let c = self.spectrum[k]; + let m = c.norm(); mags[k] = m; let d = m - self.prev_mag[k]; if d > 0.0 { broad_flux += d; + let pred_ph = 2.0 * self.prev_phase[k] - self.prev_phase2[k]; + let pred = Complex::new( + self.prev_mag[k] * pred_ph.cos(), + self.prev_mag[k] * pred_ph.sin(), + ); + csd_raw += (c - pred).norm(); } + self.prev_phase2[k] = self.prev_phase[k]; + self.prev_phase[k] = c.arg(); let f = k as f32 * bin_hz; cen_num += f * m; cen_den += m; @@ -673,6 +744,7 @@ impl Analyzer { 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); + let csd = norm(csd_raw / half as f32, &mut self.agc_csd); // Smoothed levels. follow(&mut self.env.low, l[0]); @@ -698,12 +770,23 @@ impl Analyzer { } else { self.broad_pop * ONSET_RELEASE }; + self.csd_pop = if csd > self.csd_pop { + csd + } else { + self.csd_pop * ONSET_RELEASE + }; self.env.low_on = self.pop[0]; self.env.mid_on = self.pop[1]; self.env.high_on = self.pop[2]; self.env.flux = self.broad_pop; + self.env.csd = self.csd_pop; follow(&mut self.env.flatness, flatness); + // Autocorrelation tempo: anchor the predictive IOI to the dominant + // period in ~3 s of broadband-flux history *before* the beat block + // runs, so a syncopated fill can't drag `beat_phase` off the grid. + self.update_tempo(broad); + // Predictive beat model. A rising broadband-onset edge past a floor // (with a refractory gap) is a candidate beat; the inter-onset // interval is smoothed only when it stays a plausible multiple of the @@ -728,6 +811,7 @@ impl Analyzer { } self.env.beat = self.beat_pop; self.env.beat_phase = (self.beat_clock / self.beat_ioi.max(1e-3)).clamp(0.0, 1.0); + self.env.bpm = self.bpm; // Raw waveform tap: decimate the un-windowed sample window so the scope // mode has a real time-domain trace. Same numbers live + offline. @@ -738,6 +822,76 @@ impl Analyzer { self.env } + + /// Push one broadband-flux sample into the rolling history and re-estimate + /// the dominant tempo by autocorrelation. The ACF peak in the searched lag + /// window is the beat period; its height is the confidence. Octave-fold + /// into a stable BPM range, smooth it, and anchor `beat_ioi`: glide toward + /// it normally, but *snap* when the predictive model has locked a wrong + /// octave (so `beat_phase` recovers fast instead of fighting the ACF). + fn update_tempo(&mut self, x: f32) { + let n = self.flux_hist.len(); + self.flux_hist[self.flux_head] = x; + self.flux_head = (self.flux_head + 1) % n; + + // Oldest -> newest, DC-removed (a steady flux floor must not dominate). + for i in 0..n { + self.acf_buf[i] = self.flux_hist[(self.flux_head + i) % n]; + } + let mean = self.acf_buf.iter().sum::() / n as f32; + for v in &mut self.acf_buf { + *v -= mean; + } + let var = self.acf_buf.iter().map(|v| v * v).sum::() / n as f32; + if var < 1e-9 { + return; // silence / no flux structure -> keep last estimate + } + + // Unbiased, variance-normalised ACF so peaks are comparable across + // lags (raw ACF decays with lag and would bias fast). + let lag_hi = self.acf_lag_max.min(n - 1); + let mut best = 0.0f32; + let mut best_lag = 0usize; + for lag in self.acf_lag_min..=lag_hi { + let mut s = 0.0f32; + for i in lag..n { + s += self.acf_buf[i] * self.acf_buf[i - lag]; + } + let r = s / ((n - lag) as f32 * var); + if r > best { + best = r; + best_lag = lag; + } + } + if best_lag == 0 || best < ACF_CONF_MIN { + return; + } + + // Beat period -> BPM, octave-folded into one stable range. + let mut bpm = 60.0 / (best_lag as f32 * self.hop_dt).max(1e-3); + while bpm > BPM_FOLD_HI { + bpm *= 0.5; + } + while bpm < BPM_FOLD_LO { + bpm *= 2.0; + } + self.bpm = if self.bpm <= 0.0 { + bpm + } else { + self.bpm + (bpm - self.bpm) * 0.10 + }; + + // Anchor the predictive IOI. Snap on a confident wrong-octave lock, + // otherwise glide proportionally to confidence. + let ioi = (60.0 / self.bpm.max(1e-3)).clamp(0.18, 1.0); + let ratio = self.beat_ioi / ioi.max(1e-3); + 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); + } + } } fn analysis_loop( diff --git a/src/bin/sigil.rs b/src/bin/sigil.rs index 4b05742..63f1938 100644 --- a/src/bin/sigil.rs +++ b/src/bin/sigil.rs @@ -327,14 +327,26 @@ fn main() { match audio::analyze_file(&f) { Ok(tl) => { let mut peak = Bands::default(); + let mut bpms: Vec = Vec::new(); for b in &tl.frames { peak.low = peak.low.max(b.low); peak.loud = peak.loud.max(b.loud); peak.flux = peak.flux.max(b.flux); + peak.csd = peak.csd.max(b.csd); peak.centroid = peak.centroid.max(b.centroid); + if b.bpm > 0.0 { + bpms.push(b.bpm); + } } + // Median BPM = the track's anchored tempo (ACF-stabilised). + let med_bpm = if bpms.is_empty() { + 0.0 + } else { + bpms.sort_by(|a, b| a.total_cmp(b)); + bpms[bpms.len() / 2] + }; println!( - "ok: {} frames, {:.2}s, {} Hz, {:.1} fps\n peak low {:.2} loud {:.2} flux {:.2} centroid {:.2}", + "ok: {} frames, {:.2}s, {} Hz, {:.1} fps\n peak low {:.2} loud {:.2} flux {:.2} csd {:.2} centroid {:.2}\n tempo {:.1} BPM (median of {} locked frames)", tl.frames.len(), tl.duration(), tl.sample_rate as u32, @@ -342,7 +354,10 @@ fn main() { peak.low, peak.loud, peak.flux, + peak.csd, peak.centroid, + med_bpm, + bpms.len(), ); } Err(e) => die(format!("analyze: {e}")), diff --git a/src/viz/breakcore.rs b/src/viz/breakcore.rs index 7ece597..0cb4915 100644 --- a/src/viz/breakcore.rs +++ b/src/viz/breakcore.rs @@ -246,7 +246,7 @@ impl Arch { match self { // Wide, slow, thin, dim. Knot (ordered) backbone, shell barely on. Arch::Ambient => Regime { - scale: 0.95, + scale: 1.00, melt: 0.3, glow: 0.40, speed: 0.55, @@ -262,7 +262,7 @@ impl Arch { // Contracting toward a dense core; everything ramps with tension // (see Breakcore::update — these are the *base* before tension). Arch::Build => Regime { - scale: 0.78, + scale: 0.84, melt: 0.7, glow: 0.60, speed: 0.9, @@ -292,7 +292,7 @@ impl Arch { }, // Hollowed out after a drop: medium, soft, cool, slow. Arch::Breakdown => Regime { - scale: 0.85, + scale: 0.90, melt: 0.45, glow: 0.50, speed: 0.7, @@ -307,7 +307,7 @@ impl Arch { }, // Balanced sustained default. Arch::Groove => Regime { - scale: 0.82, + scale: 0.88, melt: 0.5, glow: 0.65, speed: 1.0, @@ -636,7 +636,7 @@ impl Breakcore { 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.4 - 0.9 * b.low, 6.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, @@ -729,7 +729,10 @@ impl Breakcore { /// shock, so every structure pulses out together on a big hit and the /// bounding sphere (which uses this too) never clips them. fn world_scale(&self) -> f32 { - self.sp_scale.x.clamp(0.4, 2.4) * (1.0 + 0.12 * self.shock) + // Hard upper bound: this also feeds the bounding-sphere radius, and a + // sphere that fills the viewport defeats the background early-out that + // protects the GPU. A release/loud spike must not balloon it. + self.sp_scale.x.clamp(0.4, 1.8) * (1.0 + 0.12 * self.shock) } /// Sample the backbone into the spine slots, blending from→to with the @@ -927,11 +930,11 @@ impl Breakcore { let lo = self.b.loud; let base = oklch( (0.55 + 0.30 * lo + 0.12 * tn).min(0.95), - 0.10 + 0.06 * lo, + 0.13 + 0.06 * lo, self.hue_b, ); - let sat = (0.13 + 0.10 * (lo * 0.5 + self.b.mid * 0.4)) - * (1.0 - 0.30 * 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]; @@ -946,7 +949,7 @@ impl Breakcore { // row1 scale,tube,glow,ca u[4] = scale; u[5] = self.sp_tube.x; - u[6] = self.sp_glow.x.clamp(0.40, 1.2); + u[6] = self.sp_glow.x.clamp(0.40, 1.0); // build-up creeps the aberration; release spikes it (drive scales // the swing, not the user's base `ca`). u[7] = ca_px * rg.ca * (1.0 + (0.7 * tn + 2.0 * rel) * dr); @@ -1001,7 +1004,7 @@ impl Breakcore { // row9 swirl_zoom, swirl_rot, bg_glow, beat u[36] = (0.004 + (0.018 * self.b.loud + 0.020 * self.shock) * dr).clamp(0.0, 0.05); u[37] = ((0.006 * self.b.mid + 0.020 * tn) * dr).clamp(0.0, 0.05); - u[38] = (0.15 + 0.70 * self.b.loud).clamp(0.0, 1.0); + u[38] = (0.05 + 0.40 * self.b.loud).clamp(0.0, 1.0); u[39] = self.b.beat; // points (after 10 std140 rows = 40 f32) for (i, p) in pts.iter().enumerate() { diff --git a/src/viz/breakcore.wgsl b/src/viz/breakcore.wgsl index 8dc475e..be461b8 100644 --- a/src/viz/breakcore.wgsl +++ b/src/viz/breakcore.wgsl @@ -211,7 +211,9 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // binary hit→white. Bright within ~0.012, gone by ~0.03, so a dense // tangle reads as separated glowing wires, not a filled slab. let dl = max(dmin, 0.0); - inten = exp(-dl * dl * 6000.0) + 0.10 * exp(-dl * 30.0); + // Tighter halo (0.06·e^-40d, was 0.10·e^-30d) so a dense tangle reads + // as separated glowing wires, not one glow mass. + inten = exp(-dl * dl * 6000.0) + 0.06 * exp(-dl * 40.0); let tt = select(t, hit_t, hit_t > 0.0); depth = clamp((tt + b) / max(2.0 * sq, 1e-3), 0.0, 1.0); // Reactive volumetric fog: near strands brighter than far. @@ -222,29 +224,44 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // Shade point (local/rotated space, same as map()). let rp = rot(ro + rd * tt); // Per-structure hue: each layer keeps its own colour identity. - let gid = nearest_gid(rp); + // `nearest_gid` is a full NP loop, so — like the normal — it is + // gated to near-surface pixels; the faint outer glow just uses + // the base hue (it is dim enough not to matter) so the expensive + // path can never run over a screen-sized halo. var wire = base; - if (gid > 2.5) { wire = mix(base, accent, 0.5); } // spokes - else if (gid > 1.5) { wire = mix(accent, vec3(1.0), 0.7); } // debris - else if (gid > 0.5) { wire = accent; } // ribs + if (dmin < 0.020) { + let gid = nearest_gid(rp); + if (gid > 2.5) { wire = mix(base, accent, 0.5); } // spokes + else if (gid > 1.5) { wire = mix(accent, vec3(1.0, 0.55, 0.25), 0.6); } // debris (hot) + else if (gid > 0.5) { wire = accent; } // ribs + } // A little depth blend toward accent keeps the form readable in 3D. let wcol = mix(wire, accent, 0.25 * depth); col = wcol * inten; - col = col + mix(accent, vec3(1.0), 0.6) * pow(inten, 6.0) * 0.6; + // Hotter exponent + lower weight: only the very centre of a wire + // blows toward white, so hue survives across the body. + col = col + mix(accent, vec3(1.0), 0.5) * pow(inten, 8.0) * 0.35; col = col + accent * flash * pow(inten, 3.0) * 0.35; // onset spark // Surface lighting — rim/fresnel + a hi-band specular glint give - // the tube real 3D form instead of a flat glow ribbon. - let n = calc_normal(rp); - let vdir = normalize(rot(-rd)); - let rim_e = mix(4.0, 1.6, flat_n); // noisy → broader rim - let rim = pow(1.0 - clamp(dot(n, vdir), 0.0, 1.0), rim_e); - col = col + wcol * rim * inten * 0.45; - let ldir = normalize(vec3(0.4, 0.7, -0.55)); - let hdir = normalize(ldir + vdir); - let spec = pow(clamp(dot(n, hdir), 0.0, 1.0), 42.0); - col = col + vec3(1.0) * spec * inten * (0.25 + 0.9 * high_on); + // the tube real 3D form. The 6×map() normal is the single most + // expensive thing in the shader, so it is gated to pixels that + // genuinely landed ON a wire (tiny `dmin`), NOT the whole soft + // glow halo. This caps the expensive-pixel count to the thin wire + // silhouette regardless of framing — it is what keeps a dense + // drop from blowing the GPU frame budget (device-lost). + if (dmin < 0.006) { + let n = calc_normal(rp); + let vdir = normalize(rot(-rd)); + let rim_e = mix(4.0, 1.6, flat_n); // noisy → broader rim + let rim = pow(1.0 - clamp(dot(n, vdir), 0.0, 1.0), rim_e); + col = col + wcol * rim * inten * 0.45; + let ldir = normalize(vec3(0.4, 0.7, -0.55)); + let hdir = normalize(ldir + vdir); + let spec = pow(clamp(dot(n, hdir), 0.0, 1.0), 42.0); + col = col + vec3(1.0) * spec * inten * (0.25 + 0.9 * high_on); + } // Build-up heat: warm toward accent + a warm hot core. col = col + accent * heat * pow(inten, 2.0) * 0.20; @@ -292,8 +309,12 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // Faint base-hue background so the void breathes with loudness without // ever washing (≤0.05, centre-weighted). Added after feedback so it is a // stable floor, not something the trail can accumulate. + // Force a dark *cool* tint (not the raw base hue, which lands olive and + // the phosphor feedback then accumulates into a full-frame wash). Tiny + // amplitude so it can never build up through the trail. let vig = max(1.0 - 0.75 * length(ndc), 0.0); - col = col + base * u.p6.z * vig * 0.05; + let bgt = mix(base, vec3(0.04, 0.08, 0.16), 0.70); + col = col + bgt * u.p6.z * vig * 0.02; // CRT scanline — depth is audio-driven (loudness + tension) and the // lines crawl with the beat phase, so the "display" feels alive.