From 686d538743215731f9cab02f4d6e159355d234d2 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 19 May 2026 18:51:40 +0200 Subject: [PATCH] refactor into modular for future --- .gitignore | 1 + improvements.md | 131 +++++++++++++++++++ sigil.cfg | 12 ++ src/bin/sigil.rs | 27 +++- src/viz/breakcore.rs | 302 ++++--------------------------------------- src/viz/geometry.rs | 218 +++++++++++++++++++++++++++++++ src/viz/math.rs | 52 ++++++++ src/viz/mod.rs | 3 + src/viz/scope.rs | 121 +---------------- src/viz/structure.rs | 178 +++++++++++++++++++++++++ 10 files changed, 654 insertions(+), 391 deletions(-) create mode 100644 improvements.md create mode 100644 sigil.cfg create mode 100644 src/viz/geometry.rs create mode 100644 src/viz/math.rs create mode 100644 src/viz/structure.rs diff --git a/.gitignore b/.gitignore index 027c6d7..aee8dec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.cargo/ target/ *.mp4 *.flac diff --git a/improvements.md b/improvements.md new file mode 100644 index 0000000..c461f57 --- /dev/null +++ b/improvements.md @@ -0,0 +1,131 @@ +# Audio-Visualizer Architecture Improvements + +This document outlines a strategy for refactoring the `audio-visualizer` codebase to improve modularity, simplify the creation of new visualizers, and extract highly valuable components currently locked within specific implementations. + +## 0. Prioritization & Risk Assessment + +Not all three sections carry the same value-to-risk ratio. Recommended order: + +| Priority | Section | Verdict | +|----------|---------|---------| +| **Do first** | §3 Extracting reusable components | High value, low risk. Pure logic, no GPU, mechanical move + re-export. | +| **Maybe** | §3 `MorphState` | Worthwhile, but limited — see caveat below. | +| **Defer** | §1 `Visualizer` trait | Lateral, not a net win — see caveat. Worth it only if several new visualizers are planned. | +| **Decide first** | §2 generic `ShaderPipeline` | Contradicts a stated architecture policy. Do not start without an explicit decision. | + +### Risk callouts (must survive any refactor) + +- **Determinism / `--render` bit-reproducibility.** Any moved component that integrates state (`Spring`, the `Attr` RK4 attractors) must keep the *advance-only-in-`update`* discipline. This is the same rule that holds today; it just has to survive relocation. Verify by diffing a `--render` of the same input before/after the move — output must be bit-identical. +- **Central analysis invariant.** `Analyzer` remains the only place STFT/AGC/onset math lives (live ≡ offline). `Structure`/`Arch` is a viz-side *consumer* of `Bands`, so moving it is safe — but if it becomes shared, keep its state mutation confined to `update` for the same determinism reason. +- **`breakcore::NP` (64) Rust↔WGSL coupling + hard-capped march steps.** A generic shader base (§2) must *not* abstract these guardrails away — a runaway march = GPU device-lost / machine hang. naga only validates the WGSL at pipeline-create on a real GPU, so a bad layout cannot be caught in the sandbox. + +### Caveat on §1 (`Visualizer` trait) + +The `is_gpu()` branch is a *real* dichotomy, not accidental complexity: `Draw`-based visualizers (`Sigil`/`Scope`) share the chromatic-aberration channel passes dispatched in the bin, whereas `Breakcore` presents/captures its own raymarch target directly. A trait with optional `draw` / `render_gpu` methods does not delete this branch — the caller still switches on `is_gpu_driven()`. The trait *relocates* the branch and standardizes the contract; it does not remove the structural split. Net positive only if multiple new visualizers are actually on the roadmap. + +### Caveat on §2 (generic `ShaderPipeline`) + +`CLAUDE.md` deliberately designates `breakcore.rs` as *"the lone, walled-off exception"* to the "no hand-written wgpu pipelines" rule (`post.rs` and the rest stay on nannou's validated renderer). A reusable `ShaderPipeline` base is, by design, an invitation to add more raw wgpu pipelines — i.e. it reverses that policy. This may well be desirable, but it is a deliberate architectural decision that must be made explicitly *before* the extraction, not a side effect of it. If adopted, the base must still preserve the `NP`/UBO-layout coupling and the march-step caps as non-negotiable guardrails. + +### Caveat on `MorphState` + +`scope` and `breakcore` share the *shape* of the morph (hold a seeded state, lerp `current`→`target` over a timer), but their *triggers* differ fundamentally: scope morphs on a cooldown-gated rising-flux transient or a 12 s idle; breakcore cross-fades on a long-vs-short loudness-EMA section state machine. A generic `MorphState` can own only the `current`/`target`/`t` bookkeeping and the lerp/timing; the trigger logic stays per-visualizer. Useful, but scoped — it does not unify the interesting (musical) part. + +## 1. Modularizing the Visualizer System + +Currently, the `Visual` type in `src/bin/sigil.rs` is an `enum` hardcoding the available visualizers (`Sigil`, `Scope`, `Breakcore`). It also uses an awkward branching mechanism (`is_gpu()`) to handle the divide between `nannou::Draw`-based visualizers and raw `wgpu` pipelines. This violates the Open-Closed Principle and makes adding new visualizers cumbersome. + +**Recommendation: The `Visualizer` Trait** +Refactor the enum into a dynamic trait object system. This cleans up `sigil.rs` and standardizes the contract for all visualizers. + +```rust +pub struct RenderContext<'a> { + pub pal: &'a Palette, + pub scale: f32, + pub warp: f32, + pub glow: bool, + pub tint: [f32; 3], + // ... other shared gains/tunables +} + +pub trait Visualizer { + fn name(&self) -> &'static str; + fn seed(&self) -> u64; + fn reseed(&mut self, seed: u64); + fn update(&mut self, bands: &Bands, dt: f32); + fn element_count(&self) -> usize; + + /// Indicates if this visualizer manages its own wgpu pipeline. + fn is_gpu_driven(&self) -> bool { false } + + /// Used by CPU-driven visualizers (nannou::Draw) + fn draw(&self, draw: &Draw, ctx: &RenderContext) {} + + /// Used by GPU-driven visualizers to return their raymarch/compute target + fn render_gpu(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, ctx: &RenderContext) -> Option<&wgpu::Texture> { None } +} +``` + +## 2. Designing a New Visualizer Base + +To support rapid development of completely new visualizer modes (especially WGSL-based ones like `Breakcore`), we should abstract the boilerplate into unified bases. + +### The `ShaderPipeline` Base +The `Gpu` struct inside `breakcore.rs` is a fantastic, self-contained raw `wgpu` pipeline. It should be extracted into a generic `viz::shader::ShaderPipeline` where `U` is a `bytemuck::Pod` trait bound for the Uniform Buffer Object. + +This base would automatically handle: +- WGSL compilation and wgpu layout boilerplate. +- Dual-texture ping-ponging for frame feedback. +- UBO layout mapping and writing. + +```rust +pub struct ShaderPipeline { + // pipeline, bindings, and ping-pong textures +} + +impl ShaderPipeline { + pub fn new(device: &wgpu::Device, wgsl_source: &str) -> Self { ... } + pub fn render(&mut self, queue: &wgpu::Queue, ubo: &U) -> &wgpu::Texture { ... } +} +``` +A new GPU-based visualizer would then only need to define its UBO struct, its `.wgsl` shader, and implement `Visualizer::render_gpu`. + +## 3. Extracting Reusable Components + +Several brilliant components are currently tightly coupled to `breakcore.rs` and `scope.rs`. They should be hoisted to common modules so *any* new visualizer can use them. + +### From `breakcore.rs` +1. **`Structure` & `Arch` (Musical Archetype Classifier):** + The slow feature-vector classifier that detects `Arch` (Ambient, Build, Drop, Breakdown, Groove) and manages `tension`/`release` is incredibly powerful. It converts raw FFT data into *musical context*. + - **Action:** Move this to `src/viz/structure.rs` (or even `src/audio.rs`). Any new visualizer should be able to query `structure.tension` to drive camera FOV, or trigger a massive event on `Arch::Drop`. + +2. **Critically Damped `Spring`:** + The `Spring` struct applies physics-based smoothing to visual targets. It prevents jitter while maintaining immediate snap on transient hits. + - **Action:** Move to a new `src/viz/math.rs`. This is the perfect primitive for mapping raw audio bands to visual scales/rotations across all visualizers. + +3. **RK4 Attractors & Knots:** + The `Attr` (Lorenz/Rössler) and `Knot` geometry generators create stable, chaotic 3D paths. + - **Action:** Move to a new `src/viz/geometry.rs`. + +### From `scope.rs` +1. **3D Vector Figures:** + The `Figure` generator (Gielis supershapes, 3D Lissajous, Harmonographs) is excellent for generating deterministic, organic geometry. + - **Action:** Move to `src/viz/geometry.rs` alongside the attractors. + +2. **The Morphing State Machine:** + Both `scope` and `breakcore` implement a pattern of holding a seed, tracking an `idle` timer or waiting for a transient, and smoothly `morph`ing to a new state. + - **Action:** Abstract this into a `MorphState` primitive that wraps a `current` and `target` state, handling the lerp/transition logic automatically. + +## Summary of Proposed Directory Structure +```text +src/viz/ +├── mod.rs +├── math.rs <-- (NEW) Spring, smoothing, interpolations +├── geometry.rs <-- (NEW) Figures, Knots, Attractors +├── structure.rs <-- (NEW) Arch, Structure classifier +├── shader.rs <-- (NEW) Generic wgpu ShaderPipeline +├── core.rs <-- (NEW) Visualizer trait and RenderContext +├── breakcore.rs (Refactored to use new bases) +├── scope.rs (Refactored to use new bases) +└── sigil.rs +``` \ No newline at end of file diff --git a/sigil.cfg b/sigil.cfg new file mode 100644 index 0000000..4d5dc6f --- /dev/null +++ b/sigil.cfg @@ -0,0 +1,12 @@ +low=0.85 +warp=1 +fade=0.11 +zoom=1.006 +ca=7 +drive=1 +seg=12 +glow=true +feedback=true +out_scale=0 +crf=16 +x264_preset=veryslow diff --git a/src/bin/sigil.rs b/src/bin/sigil.rs index c712101..4cd0117 100644 --- a/src/bin/sigil.rs +++ b/src/bin/sigil.rs @@ -31,6 +31,23 @@ use audio_visualizer::viz::scope::Scope; use audio_visualizer::viz::sigil::Sigil; use nannou::prelude::*; +// Hybrid-graphics laptops (Intel/AMD iGPU + NVIDIA/AMD dGPU): export the +// vendor opt-in symbols so the driver hands this *process* the discrete GPU +// (RTX 3050) before any wgpu adapter request. This is the driver-level +// guarantee; the wgpu `HighPerformance` hint + non-GL backends below are the +// API-level half. `--export-all-symbols` (set for the windows target in +// .cargo/config.toml) puts these in the PE export table where the driver +// looks. Last-resort manual override: NVIDIA Control Panel → set sigil.exe to +// "High-performance NVIDIA processor". +#[cfg(windows)] +#[unsafe(no_mangle)] +#[used] +pub static NvOptimusEnablement: u32 = 1; +#[cfg(windows)] +#[unsafe(no_mangle)] +#[used] +pub static AmdPowerXpressRequestHighPerformance: i32 = 1; + const W: f32 = 1080.0; const H: f32 = 1080.0; const RENDER_FPS: f32 = 30.0; @@ -377,7 +394,14 @@ fn main() { return; } - nannou::app(model).update(update).exit(on_exit).run(); + // Drop GL from the default backend set: on Optimus laptops the GL path + // commonly resolves to the Intel iGPU. Vulkan/DX12 (PRIMARY) + the + // HighPerformance window hint land on the RTX 3050. + nannou::app(model) + .backends(nannou::wgpu::Backends::PRIMARY) + .update(update) + .exit(on_exit) + .run(); } fn die(msg: impl std::fmt::Display) -> ! { @@ -400,6 +424,7 @@ fn model(app: &App) -> Model { .new_window() .size(W as u32, H as u32) .title("living sigil") + .power_preference(nannou::wgpu::PowerPreference::HighPerformance) .view(view) .key_pressed(key_pressed) .build() diff --git a/src/viz/breakcore.rs b/src/viz/breakcore.rs index 0cb4915..2c9532a 100644 --- a/src/viz/breakcore.rs +++ b/src/viz/breakcore.rs @@ -38,8 +38,11 @@ use crate::audio::{Bands, CHROMA_N, SPEC_N}; use crate::viz::curve::{Rng, fbm}; +use crate::viz::geometry::{Attr, Knot, normalize}; +use crate::viz::math::{Spring, angle_to, smoothstep}; use crate::viz::palette::{Palette, oklch}; use crate::viz::post::read_texture_rgba; +use crate::viz::structure::{Arch, Structure}; use nannou::prelude::*; use nannou::wgpu; @@ -75,40 +78,8 @@ const PARKED: [f32; 4] = [PARK, PARK, PARK, 0.001]; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb; const TAU: f32 = std::f32::consts::TAU; -fn smoothstep(t: f32) -> f32 { - let t = t.clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) -} - -/// Ease an angle toward a target along the *shortest* arc (no 2π flicker -/// when the hue wraps). `a` is the per-frame lerp fraction. -fn angle_to(cur: f32, target: f32, a: f32) -> f32 { - let mut d = (target - cur) % TAU; - if d > std::f32::consts::PI { - d -= TAU; - } else if d < -std::f32::consts::PI { - d += TAU; - } - cur + d * a -} - -/// Critically-damped spring (premise §3). `omega` is the natural frequency -/// (stiffness); damping is fixed at ζ=1 so it never overshoots — a kick -/// expands instantly then glides back with no ring. -#[derive(Clone, Copy, Default)] -struct Spring { - x: f32, - v: f32, -} - -impl Spring { - fn step(&mut self, target: f32, omega: f32, dt: f32) { - // Semi-implicit Euler of x'' = -ω²(x-target) - 2ω x' (ζ = 1). - let a = -(self.x - target) * omega * omega - 2.0 * omega * self.v; - self.v += a * dt; - self.x += self.v * dt; - } -} +// `Spring` (premise §3), `smoothstep` and `angle_to` are generic visual math +// shared with the other visualisers — see `crate::viz::math` (imported above). /// Which backbone geometry a section shows. #[derive(Clone, Copy, PartialEq)] @@ -117,111 +88,19 @@ enum Kind { Knot, } -/// Lorenz or Rössler — both chaotic, integrated by RK4. -#[derive(Clone, Copy)] -enum Attr { - Lorenz { sigma: f32, rho: f32, beta: f32 }, - Rossler { a: f32, b: f32, c: f32 }, -} - -impl Attr { - fn random(rng: &mut Rng) -> Self { - if rng.chance(0.5) { - Attr::Lorenz { - sigma: 10.0, - rho: rng.range(26.0, 32.0), - beta: 8.0 / 3.0, - } - } else { - Attr::Rossler { - a: rng.range(0.1, 0.22), - b: rng.range(0.1, 0.3), - c: rng.range(4.5, 9.0), - } - } - } - - fn deriv(&self, p: Vec3) -> Vec3 { - match *self { - Attr::Lorenz { sigma, rho, beta } => vec3( - sigma * (p.y - p.x), - 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)) - } - } - } - - fn rk4(&self, p: Vec3, h: f32) -> Vec3 { - let k1 = self.deriv(p); - let k2 = self.deriv(p + k1 * (h * 0.5)); - let k3 = self.deriv(p + k2 * (h * 0.5)); - let k4 = self.deriv(p + k3 * h); - p + (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (h / 6.0) - } -} - -/// One torus-knot config: coprime-ish (p,q) + turns, distorted by highs. -#[derive(Clone, Copy)] -struct Knot { - p: f32, - q: f32, - turns: f32, -} - -impl Knot { - fn random(rng: &mut Rng) -> Self { - Knot { - p: (2 + rng.idx(6)) as f32, - q: (1 + rng.idx(7)) as f32, - turns: rng.range(2.0, 4.0), - } - } - fn at(&self, u: f32) -> Vec3 { - let th = TAU * self.turns * u; - let r = (self.q * th).cos() + 2.0; - vec3( - r * (self.p * th).cos(), - r * (self.p * th).sin(), - -(self.q * th).sin(), - ) - } -} - -/// Normalise a point set to ~unit radius so framing stays readable whatever -/// the attractor/knot extent (premise: chaotic but never an unreadable mess). -fn normalize(pts: &mut [Vec3]) { - let mut c = Vec3::ZERO; - for p in pts.iter() { - c += *p; - } - c /= pts.len().max(1) as f32; - let mut m = 1e-6f32; - for p in pts.iter() { - m = m.max((*p - c).length()); - } - let s = 0.92 / m; - for p in pts.iter_mut() { - *p = (*p - c) * s; - } -} +// The backbone geometry — `Attr` (Lorenz/Rössler, RK4), `Knot`, and the +// `normalize` framing helper — is shared with the scope; see +// `crate::viz::geometry` (imported above). `Kind` (above) stays here: it is +// breakcore morph state, not a geometry generator. // --------------------------------------------------------------------------- // §5 musical-structure model // --------------------------------------------------------------------------- -/// Coarse song-section archetype. The classifier never sees a label in the -/// audio; this is inferred from the slow feature vector in [`Structure`]. -#[derive(Clone, Copy, PartialEq, Debug)] -enum Arch { - Ambient, // sparse / intro / pad - Build, // rising tension (riser, snare roll, filter open) - Drop, // post-build hit / dense break - Breakdown, // energy collapsed after a loud section - Groove, // sustained mid-energy default -} +// `Arch` (the archetype tag) and the `Structure` classifier are the +// visualiser-agnostic part of §5 — see `crate::viz::structure` (imported +// above). The `Arch → Regime` mapping below is breakcore's own visual +// vocabulary and stays here. /// The per-archetype visual regime: spring/uniform *targets*, never values. /// Audio still rides on top of these via the springs (premise §3). @@ -324,134 +203,9 @@ impl Arch { } } -/// Slow feature-vector model of where we are in the track. All inputs are -/// already AGC-normalised 0..1 in [`Bands`]; this just adds time structure. -struct Structure { - e: f32, // energy (loud) — fast EMA - br: f32, // bright (centroid) - ba: f32, // bass (low) - bu: f32, // busy (flux + per-band onsets) — onset density - e_l: f32, // long refs (~8 s) for relative comparison - ba_l: f32, - bu_l: f32, - ch: [f32; CHROMA_N], // chroma EMA → harmonic-novelty distance - prev_br: f32, - prev_bu: f32, - tension: f32, // 0..1 build-up ramp (premise §5 / axis B) - novelty: f32, // 0..1 decaying harmonic-change pulse - release: f32, // 0..1 decaying drop-release pulse - arch: Arch, - cand: Arch, // hysteresis: a candidate must dwell before it commits - cand_t: f32, -} - -impl Structure { - fn new() -> Self { - Structure { - e: 0.0, - br: 0.0, - ba: 0.0, - bu: 0.0, - e_l: 0.0, - ba_l: 0.0, - bu_l: 0.0, - ch: [0.0; CHROMA_N], - prev_br: 0.0, - prev_bu: 0.0, - tension: 0.0, - novelty: 0.0, - release: 0.0, - arch: Arch::Ambient, - cand: Arch::Ambient, - cand_t: 0.0, - } - } - - /// Advance the model one frame. Returns `true` on a committed archetype - /// change (the event the geometry restructures on). - fn update(&mut self, b: &Bands, dt: f32) -> bool { - let inv = 1.0 / dt.max(1e-4); - let a_fast = 1.0 - (-dt / 1.2).exp(); - 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); - self.e += (b.loud - self.e) * a_fast; - self.br += (b.centroid - self.br) * a_fast; - self.ba += (b.low - self.ba) * a_bass; - self.bu += (busy_in - self.bu) * a_fast; - self.e_l += (b.loud - self.e_l) * a_long; - self.ba_l += (b.low - self.ba_l) * a_long; - self.bu_l += (busy_in - self.bu_l) * a_long; - - // Harmonic novelty: L1 drift of the chroma vector vs its slow EMA. - // A chord/key change spikes this even when loudness is flat. - let a_ch = 1.0 - (-dt / 4.0).exp(); - let mut drift = 0.0; - for i in 0..CHROMA_N { - drift += (b.chroma[i] - self.ch[i]).abs(); - self.ch[i] += (b.chroma[i] - self.ch[i]) * a_ch; - } - self.novelty = (self.novelty - dt / 0.6) - .max(0.0) - .max((drift / CHROMA_N as f32 * 3.0).min(1.0)); - - // §B tension: integrate rising brightness + busyness, but only while - // the bass is *suppressed* relative to its long ref (the kick drops - // out under a build-up). Bass back ⇒ tension bleeds off fast. - let d_br = (self.br - self.prev_br) * inv; - let d_bu = (self.bu - self.prev_bu) * inv; - self.prev_br = self.br; - self.prev_bu = self.bu; - let bass_supp = (1.0 - self.ba / (self.ba_l + 1e-3)).clamp(0.0, 1.0); - let rise = (d_br.max(0.0) * 1.4 + d_bu.max(0.0) * 1.0) * (0.35 + 0.65 * bass_supp); - self.tension = (self.tension + (rise * 2.6 - 0.22) * dt).clamp(0.0, 1.0); - if self.ba > 0.85 * self.ba_l { - self.tension = (self.tension - dt * 1.6).max(0.0); - } - - // Release: a primed build collapsing into a bass+flux hit = the drop. - let primed = self.tension > 0.5; - let hit = b.low_on > 0.5 || (self.ba - self.ba_l) > 0.18; - let mut drop_now = false; - if primed && hit && b.flux > 0.4 { - self.release = 1.0; - self.tension = 0.0; - drop_now = true; - } - self.release = (self.release - dt / 0.5).max(0.0); - - // Classify. Drop on release is instant; everything else must dwell to - // beat hysteresis so a section is one change, not a stutter. - let cand = if drop_now { - Arch::Drop - } else if self.tension > 0.45 { - Arch::Build - } else if self.e < 0.32 && self.bu < 0.28 { - Arch::Ambient - } else if self.e > 0.60 && self.ba > 0.52 && self.bu > 0.48 { - Arch::Drop - } else if self.e_l > 0.30 && self.e < 0.52 * self.e_l { - Arch::Breakdown - } else { - Arch::Groove - }; - - if cand == self.cand { - self.cand_t += dt; - } else { - self.cand = cand; - self.cand_t = 0.0; - } - let dwell = if drop_now { 0.0 } else { 0.55 }; - if self.cand != self.arch && self.cand_t >= dwell { - self.arch = self.cand; - return true; - } - false - } -} +// `Structure` (the slow feature-vector classifier + tension/release state +// machine) now lives in `crate::viz::structure`; breakcore reads it through +// `tension()` / `arch()` / `novelty()` / `release()`. /// One transient debris fragment (premise §1, breakcore "shrapnel"). #[derive(Clone, Copy, Default)] @@ -624,9 +378,9 @@ impl Breakcore { // §5 structure model first — the regime drives every target below. let arch_changed = self.st.update(b, dt); - let rg = self.st.arch.regime(); - let tn = self.st.tension; - let rel = self.st.release; + let rg = self.st.arch().regime(); + let tn = self.st.tension(); + let rel = self.st.release(); // §3 springs — regime sets the base, audio + tension ride on top. // Build-up contracts the backbone toward a dense core; release kicks @@ -679,7 +433,7 @@ impl Breakcore { dom = i; } } - let offs = match self.st.arch { + let offs = match self.st.arch() { Arch::Drop => std::f32::consts::PI, Arch::Ambient | Arch::Breakdown => 0.6, Arch::Build => 2.2, @@ -719,7 +473,7 @@ impl Breakcore { // A swap fires on a real structure event — an archetype commit or a // strong harmonic novelty — gated by morph/cooldown. The idle // fallback is a rare safety so a flat drone still eventually evolves. - let event = arch_changed || self.st.novelty > 0.75; + let event = arch_changed || self.st.novelty() > 0.75; if self.morph >= 1.0 && self.cooldown <= 0.0 && (event || self.idle > 20.0) { self.restructure(rg.prefer_knot); } @@ -766,8 +520,8 @@ impl Breakcore { let from = sel(self.from); let to = sel(self.to); - let rg = self.st.arch.regime(); - let warpd = rg.warpd + 0.10 * self.st.tension; + let rg = self.st.arch().regime(); + let warpd = rg.warpd + 0.10 * self.st.tension(); let scale = self.world_scale(); let s = self.seed as u32; let bp = self.b.beat_phase; @@ -810,7 +564,7 @@ impl Breakcore { /// length + girth tracks a log-spectrum band; a calm band collapses the /// strut and parks it (invisible). fn build_ribs(&self, out: &mut [[f32; 4]; NP]) { - let rg = self.st.arch.regime(); + let rg = self.st.arch().regime(); let scale = self.world_scale(); for k in 0..N_RIBS { let band = self.b.spec[(k * SPEC_N) / N_RIBS]; @@ -914,9 +668,9 @@ impl Breakcore { drive: f32, ) -> &wgpu::Texture { let pts = self.build_points(); - let rg = self.st.arch.regime(); - let tn = self.st.tension; - let rel = self.st.release; + let rg = self.st.arch().regime(); + let tn = self.st.tension(); + let rel = self.st.release(); // `drive` is the expressive-effect master: it scales the *screen-space // drama* (heat / glitch / grain / CA swing / swirl / scanline) but // never the geometry, springs or section model, so detection and @@ -983,7 +737,7 @@ impl Breakcore { // row6 trans_pulse, glitch, scan, grain u[24] = self.trans.max(rel); // continuous slice glitch from highs/flux + harmonic novelty. - u[25] = ((0.06 * self.b.high + 0.12 * self.b.flux + 0.5 * self.st.novelty) * dr) + u[25] = ((0.06 * self.b.high + 0.12 * self.b.flux + 0.5 * self.st.novelty()) * dr) .clamp(0.0, 0.30); // CRT scanline depth: a faint floor that deepens with loudness and // build-up tension — the "display" powers up with the track. diff --git a/src/viz/geometry.rs b/src/viz/geometry.rs new file mode 100644 index 0000000..f196b3a --- /dev/null +++ b/src/viz/geometry.rs @@ -0,0 +1,218 @@ +//! Deterministic 3D geometry generators shared by the visualisers. +//! +//! `Attr` (Lorenz/Rössler strange attractors, RK4-integrated) and `Knot` +//! (torus knots) come from the breakcore backbone; `Figure` (torus knot, +//! Gielis supershape, 3D Lissajous, harmonograph, rose-helix) from the scope. +//! All are *pure* given their seeded parameters — `*::random` advances the +//! caller's [`Rng`], `deriv`/`rk4`/`at` take no hidden state — so a visualiser +//! stays `--render`-reproducible as long as it only generates these in +//! `update` (the existing discipline; relocating the code cannot change it). + +use crate::viz::curve::Rng; +use nannou::prelude::*; + +const TAU: f32 = std::f32::consts::TAU; + +/// Lorenz or Rössler — both chaotic, integrated by RK4. +#[derive(Clone, Copy)] +pub enum Attr { + Lorenz { sigma: f32, rho: f32, beta: f32 }, + Rossler { a: f32, b: f32, c: f32 }, +} + +impl Attr { + pub fn random(rng: &mut Rng) -> Self { + if rng.chance(0.5) { + Attr::Lorenz { + sigma: 10.0, + rho: rng.range(26.0, 32.0), + beta: 8.0 / 3.0, + } + } else { + Attr::Rossler { + a: rng.range(0.1, 0.22), + b: rng.range(0.1, 0.3), + c: rng.range(4.5, 9.0), + } + } + } + + fn deriv(&self, p: Vec3) -> Vec3 { + match *self { + Attr::Lorenz { sigma, rho, beta } => vec3( + sigma * (p.y - p.x), + 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)) + } + } + } + + pub fn rk4(&self, p: Vec3, h: f32) -> Vec3 { + let k1 = self.deriv(p); + let k2 = self.deriv(p + k1 * (h * 0.5)); + let k3 = self.deriv(p + k2 * (h * 0.5)); + let k4 = self.deriv(p + k3 * h); + p + (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (h / 6.0) + } +} + +/// One torus-knot config: coprime-ish (p,q) + turns, distorted by highs. +#[derive(Clone, Copy)] +pub struct Knot { + p: f32, + q: f32, + turns: f32, +} + +impl Knot { + pub fn random(rng: &mut Rng) -> Self { + Knot { + p: (2 + rng.idx(6)) as f32, + q: (1 + rng.idx(7)) as f32, + turns: rng.range(2.0, 4.0), + } + } + pub fn at(&self, u: f32) -> Vec3 { + let th = TAU * self.turns * u; + let r = (self.q * th).cos() + 2.0; + vec3( + r * (self.p * th).cos(), + r * (self.p * th).sin(), + -(self.q * th).sin(), + ) + } +} + +/// Normalise a point set to ~unit radius so framing stays readable whatever +/// the attractor/knot extent (premise: chaotic but never an unreadable mess). +pub fn normalize(pts: &mut [Vec3]) { + let mut c = Vec3::ZERO; + for p in pts.iter() { + c += *p; + } + c /= pts.len().max(1) as f32; + let mut m = 1e-6f32; + for p in pts.iter() { + m = m.max((*p - c).length()); + } + let s = 0.92 / m; + for p in pts.iter_mut() { + *p = (*p - c) * s; + } +} + +const KINDS: u32 = 5; +const PARAMS: usize = 7; + +/// One figure: a kind tag + its numeric parameters. Sampled into a Vec3 path. +#[derive(Clone, Copy)] +pub struct Figure { + kind: u32, + p: [f32; PARAMS], +} + +impl Figure { + /// Seed a fresh figure. Ratios/petals are small integers so the curves + /// close cleanly (the oscilloscope-art look); free exponents add variety. + pub fn random(rng: &mut Rng) -> Self { + let kind = (rng.idx(KINDS as usize)) as u32; + let mut p = [0.0f32; PARAMS]; + match kind { + // torus knot (p,q): coprime-ish small ints, tube radius + 0 => { + p[0] = (2 + rng.idx(6)) as f32; + p[1] = (1 + rng.idx(7)) as f32; + p[2] = rng.range(0.25, 0.6); + p[3] = rng.range(2.0, 4.0); // winds (path loops) + } + // 3D supershape (Gielis): two superformulas, spherical product + 1 => { + p[0] = (rng.idx(12) as f32) + 1.0; // m + p[1] = rng.range(0.3, 3.0); // n1 + p[2] = rng.range(0.3, 4.0); // n2 + p[3] = rng.range(0.3, 4.0); // n3 + p[4] = (1 + rng.idx(8)) as f32; // surface-spiral turns + } + // 3D Lissajous: integer freqs + phase offsets + 2 => { + p[0] = (1 + rng.idx(7)) as f32; + p[1] = (1 + rng.idx(7)) as f32; + p[2] = (1 + rng.idx(7)) as f32; + p[3] = rng.range(0.0, std::f32::consts::TAU); + p[4] = rng.range(0.0, std::f32::consts::TAU); + } + // harmonograph: damped sum of sinusoids + 3 => { + for s in p.iter_mut().take(4) { + *s = (1 + rng.idx(5)) as f32; + } + p[4] = rng.range(0.0, std::f32::consts::TAU); + p[5] = rng.range(0.0, std::f32::consts::TAU); + p[6] = rng.range(0.6, 2.4); // decay + } + // rose-helix: k-petal rose climbing in z + _ => { + p[0] = (2 + rng.idx(9)) as f32; // petals + p[1] = rng.range(3.0, 9.0); // turns + p[2] = rng.range(0.4, 1.1); // height + } + } + Figure { kind, p } + } + + /// Sample at `u` in 0..1, returned roughly within a unit-ish box. + pub fn at(&self, u: f32) -> Vec3 { + let tau = std::f32::consts::TAU; + let p = &self.p; + match self.kind { + 0 => { + 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 + } + 1 => { + let sf = |ang: f32, m: f32, n1: f32, n2: f32, n3: f32| -> f32 { + let a = ((m * ang / 4.0).cos().abs()).powf(n2); + let b = ((m * ang / 4.0).sin().abs()).powf(n3); + (a + b).powf(-1.0 / n1.max(0.05)).min(3.0) + }; + // wind a spiral over the supershape surface + let lon = (u * p[4].max(1.0) * tau).rem_euclid(tau) - std::f32::consts::PI; + let lat = (u - 0.5) * std::f32::consts::PI; + let r1 = sf(lon, p[0], p[1], p[2], p[3]); + let r2 = sf(lat, p[0], p[1], p[2], p[3]); + vec3( + r1 * lon.cos() * r2 * lat.cos(), + r1 * lon.sin() * r2 * lat.cos(), + r2 * lat.sin(), + ) * 0.7 + } + 2 => { + let t = tau * u; + vec3( + (p[0] * t + p[3]).sin(), + (p[1] * t + p[4]).sin(), + (p[2] * t).sin(), + ) + } + 3 => { + let t = u * tau * 4.0; + let d = (-p[6] * u).exp(); + vec3( + d * ((p[0] * t).sin() + 0.6 * (p[2] * t + p[4]).sin()), + d * ((p[1] * t + p[5]).sin() + 0.6 * (p[3] * t).sin()), + d * (0.5 * ((p[0] + p[1]) * 0.5 * t).sin()), + ) + } + _ => { + let th = u * tau * p[1].max(1.0); + let r = (p[0] * th).cos(); + vec3(r * th.cos(), r * th.sin(), p[2] * (u - 0.5) * 2.0) + } + } + } +} diff --git a/src/viz/math.rs b/src/viz/math.rs new file mode 100644 index 0000000..15f820a --- /dev/null +++ b/src/viz/math.rs @@ -0,0 +1,52 @@ +//! Generic visual-math primitives shared by every visualiser. +//! +//! All pure: a [`Spring`] is a function of (state, target, dt); [`smoothstep`] +//! and [`angle_to`] take no hidden state. Nothing here advances an `Rng` or a +//! clock, so moving a call between modules cannot change `--render` output — +//! determinism lives entirely at the call site (advance only in `update`). +//! +//! Note `curve::smoothstep` is a *different* function — the 3-arg GLSL +//! `(edge0, edge1, x)` Hermite. This module's [`smoothstep`] is the 1-arg +//! `t \in [0,1]` ease used for morph/blend fractions. + +const TAU: f32 = std::f32::consts::TAU; +const PI: f32 = std::f32::consts::PI; + +/// Critically-damped spring. `omega` is the natural frequency (stiffness); +/// damping is fixed at ζ=1 so it never overshoots — a kick expands instantly +/// then glides back with no ring. Audio sets the *target*, never `x` directly. +/// +/// `x`/`v` are public: callers seed the rest state with a struct literal +/// (`Spring { x: 1.0, v: 0.0 }`) and read the smoothed value as `spring.x`. +#[derive(Clone, Copy, Default)] +pub struct Spring { + pub x: f32, + pub v: f32, +} + +impl Spring { + pub fn step(&mut self, target: f32, omega: f32, dt: f32) { + // Semi-implicit Euler of x'' = -ω²(x-target) - 2ω x' (ζ = 1). + let a = -(self.x - target) * omega * omega - 2.0 * omega * self.v; + self.v += a * dt; + self.x += self.v * dt; + } +} + +/// Hermite ease on a fraction already in `0..1` (clamped). +pub fn smoothstep(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Ease an angle toward a target along the *shortest* arc (no 2π flicker +/// when the hue wraps). `a` is the per-frame lerp fraction. +pub fn angle_to(cur: f32, target: f32, a: f32) -> f32 { + let mut d = (target - cur) % TAU; + if d > PI { + d -= TAU; + } else if d < -PI { + d += TAU; + } + cur + d * a +} diff --git a/src/viz/mod.rs b/src/viz/mod.rs index af5d405..4249f2c 100644 --- a/src/viz/mod.rs +++ b/src/viz/mod.rs @@ -6,7 +6,10 @@ pub mod breakcore; pub mod curve; +pub mod geometry; +pub mod math; pub mod palette; pub mod post; pub mod scope; pub mod sigil; +pub mod structure; diff --git a/src/viz/scope.rs b/src/viz/scope.rs index a1c9df7..7daf4be 100644 --- a/src/viz/scope.rs +++ b/src/viz/scope.rs @@ -25,13 +25,13 @@ use crate::audio::{Bands, WAVE_N}; use crate::viz::curve::{Rng, flow}; +use crate::viz::geometry::Figure; +use crate::viz::math::smoothstep; use crate::viz::palette::Palette; use nannou::prelude::*; const FIELD: f32 = 960.0; // design-space extent (matches sigil/post) const N: usize = 1600; // beam samples per figure -const KINDS: u32 = 5; -const PARAMS: usize = 7; const MORPH_SECS: f32 = 0.85; // figure cross-fade time /// Stateless hash -> 0..1 (ordered dither + grain; deterministic per frame). @@ -43,120 +43,9 @@ fn h01(a: u32, b: u32) -> f32 { (x >> 9) as f32 / (1u32 << 23) as f32 } -fn smoothstep(t: f32) -> f32 { - let t = t.clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) -} - -/// One figure: a kind tag + its numeric parameters. Sampled into a Vec3 path. -#[derive(Clone, Copy)] -struct Figure { - kind: u32, - p: [f32; PARAMS], -} - -impl Figure { - /// Seed a fresh figure. Ratios/petals are small integers so the curves - /// close cleanly (the oscilloscope-art look); free exponents add variety. - fn random(rng: &mut Rng) -> Self { - let kind = (rng.idx(KINDS as usize)) as u32; - let mut p = [0.0f32; PARAMS]; - match kind { - // torus knot (p,q): coprime-ish small ints, tube radius - 0 => { - p[0] = (2 + rng.idx(6)) as f32; - p[1] = (1 + rng.idx(7)) as f32; - p[2] = rng.range(0.25, 0.6); - p[3] = rng.range(2.0, 4.0); // winds (path loops) - } - // 3D supershape (Gielis): two superformulas, spherical product - 1 => { - p[0] = (rng.idx(12) as f32) + 1.0; // m - p[1] = rng.range(0.3, 3.0); // n1 - p[2] = rng.range(0.3, 4.0); // n2 - p[3] = rng.range(0.3, 4.0); // n3 - p[4] = (1 + rng.idx(8)) as f32; // surface-spiral turns - } - // 3D Lissajous: integer freqs + phase offsets - 2 => { - p[0] = (1 + rng.idx(7)) as f32; - p[1] = (1 + rng.idx(7)) as f32; - p[2] = (1 + rng.idx(7)) as f32; - p[3] = rng.range(0.0, std::f32::consts::TAU); - p[4] = rng.range(0.0, std::f32::consts::TAU); - } - // harmonograph: damped sum of sinusoids - 3 => { - for s in p.iter_mut().take(4) { - *s = (1 + rng.idx(5)) as f32; - } - p[4] = rng.range(0.0, std::f32::consts::TAU); - p[5] = rng.range(0.0, std::f32::consts::TAU); - p[6] = rng.range(0.6, 2.4); // decay - } - // rose-helix: k-petal rose climbing in z - _ => { - p[0] = (2 + rng.idx(9)) as f32; // petals - p[1] = rng.range(3.0, 9.0); // turns - p[2] = rng.range(0.4, 1.1); // height - } - } - Figure { kind, p } - } - - /// Sample at `u` in 0..1, returned roughly within a unit-ish box. - fn at(&self, u: f32) -> Vec3 { - let tau = std::f32::consts::TAU; - let p = &self.p; - match self.kind { - 0 => { - 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 - } - 1 => { - let sf = |ang: f32, m: f32, n1: f32, n2: f32, n3: f32| -> f32 { - let a = ((m * ang / 4.0).cos().abs()).powf(n2); - let b = ((m * ang / 4.0).sin().abs()).powf(n3); - (a + b).powf(-1.0 / n1.max(0.05)).min(3.0) - }; - // wind a spiral over the supershape surface - let lon = (u * p[4].max(1.0) * tau).rem_euclid(tau) - std::f32::consts::PI; - let lat = (u - 0.5) * std::f32::consts::PI; - let r1 = sf(lon, p[0], p[1], p[2], p[3]); - let r2 = sf(lat, p[0], p[1], p[2], p[3]); - vec3( - r1 * lon.cos() * r2 * lat.cos(), - r1 * lon.sin() * r2 * lat.cos(), - r2 * lat.sin(), - ) * 0.7 - } - 2 => { - let t = tau * u; - vec3( - (p[0] * t + p[3]).sin(), - (p[1] * t + p[4]).sin(), - (p[2] * t).sin(), - ) - } - 3 => { - let t = u * tau * 4.0; - let d = (-p[6] * u).exp(); - vec3( - d * ((p[0] * t).sin() + 0.6 * (p[2] * t + p[4]).sin()), - d * ((p[1] * t + p[5]).sin() + 0.6 * (p[3] * t).sin()), - d * (0.5 * ((p[0] + p[1]) * 0.5 * t).sin()), - ) - } - _ => { - let th = u * tau * p[1].max(1.0); - let r = (p[0] * th).cos(); - vec3(r * th.cos(), r * th.sin(), p[2] * (u - 0.5) * 2.0) - } - } - } -} +// `Figure` (torus knot / Gielis supershape / 3D Lissajous / harmonograph / +// rose-helix) is shared geometry — see `crate::viz::geometry` (imported +// above). The scope only seeds (`Figure::random`) and samples (`.at`) it. pub struct Scope { pub seed: u64, diff --git a/src/viz/structure.rs b/src/viz/structure.rs new file mode 100644 index 0000000..c147745 --- /dev/null +++ b/src/viz/structure.rs @@ -0,0 +1,178 @@ +//! Musical-structure model — the visualiser-agnostic half of breakcore's §5. +//! +//! [`Structure`] turns the already-AGC-normalised [`Bands`] feature stream +//! into *musical context*: slow EMAs of energy/brightness/bass/busyness plus +//! a chroma-novelty term, sorted into coarse [`Arch`] archetypes with a +//! `tension`/`release` build-up state machine. Any visualiser can query +//! `structure.tension()` / `arch()` to drive its own look — how an `Arch` +//! maps to concrete visual targets (breakcore's `Regime`) stays in the +//! visualiser, so this module carries no per-visualiser vocabulary. +//! +//! Determinism: `Structure` holds no `Rng` and no clock; `update` is a pure +//! function of (previous state, `Bands`, dt). Called once per frame (live and +//! `--render`) it advances identically — relocating it cannot change output. + +use crate::audio::{Bands, CHROMA_N}; + +/// Coarse song-section archetype. The classifier never sees a label in the +/// audio; this is inferred from the slow feature vector in [`Structure`]. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum Arch { + Ambient, // sparse / intro / pad + Build, // rising tension (riser, snare roll, filter open) + Drop, // post-build hit / dense break + Breakdown, // energy collapsed after a loud section + Groove, // sustained mid-energy default +} + +/// Slow feature-vector model of where we are in the track. All inputs are +/// already AGC-normalised 0..1 in [`Bands`]; this just adds time structure. +pub struct Structure { + e: f32, // energy (loud) — fast EMA + br: f32, // bright (centroid) + ba: f32, // bass (low) + bu: f32, // busy (flux + per-band onsets) — onset density + e_l: f32, // long refs (~8 s) for relative comparison + ba_l: f32, + bu_l: f32, + ch: [f32; CHROMA_N], // chroma EMA → harmonic-novelty distance + prev_br: f32, + prev_bu: f32, + tension: f32, // 0..1 build-up ramp (premise §5 / axis B) + novelty: f32, // 0..1 decaying harmonic-change pulse + release: f32, // 0..1 decaying drop-release pulse + arch: Arch, + cand: Arch, // hysteresis: a candidate must dwell before it commits + cand_t: f32, +} + +impl Structure { + pub fn new() -> Self { + Structure { + e: 0.0, + br: 0.0, + ba: 0.0, + bu: 0.0, + e_l: 0.0, + ba_l: 0.0, + bu_l: 0.0, + ch: [0.0; CHROMA_N], + prev_br: 0.0, + prev_bu: 0.0, + tension: 0.0, + novelty: 0.0, + release: 0.0, + arch: Arch::Ambient, + cand: Arch::Ambient, + cand_t: 0.0, + } + } + + /// Current build-up tension (0..1). + pub fn tension(&self) -> f32 { + self.tension + } + /// Committed section archetype. + pub fn arch(&self) -> Arch { + self.arch + } + /// Decaying harmonic-change pulse (0..1). + pub fn novelty(&self) -> f32 { + self.novelty + } + /// Decaying drop-release pulse (0..1). + pub fn release(&self) -> f32 { + self.release + } + + /// Advance the model one frame. Returns `true` on a committed archetype + /// change (the event the geometry restructures on). + pub fn update(&mut self, b: &Bands, dt: f32) -> bool { + let inv = 1.0 / dt.max(1e-4); + let a_fast = 1.0 - (-dt / 1.2).exp(); + 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); + self.e += (b.loud - self.e) * a_fast; + self.br += (b.centroid - self.br) * a_fast; + self.ba += (b.low - self.ba) * a_bass; + self.bu += (busy_in - self.bu) * a_fast; + self.e_l += (b.loud - self.e_l) * a_long; + self.ba_l += (b.low - self.ba_l) * a_long; + self.bu_l += (busy_in - self.bu_l) * a_long; + + // Harmonic novelty: L1 drift of the chroma vector vs its slow EMA. + // A chord/key change spikes this even when loudness is flat. + let a_ch = 1.0 - (-dt / 4.0).exp(); + let mut drift = 0.0; + for i in 0..CHROMA_N { + drift += (b.chroma[i] - self.ch[i]).abs(); + self.ch[i] += (b.chroma[i] - self.ch[i]) * a_ch; + } + self.novelty = (self.novelty - dt / 0.6) + .max(0.0) + .max((drift / CHROMA_N as f32 * 3.0).min(1.0)); + + // §B tension: integrate rising brightness + busyness, but only while + // the bass is *suppressed* relative to its long ref (the kick drops + // out under a build-up). Bass back ⇒ tension bleeds off fast. + let d_br = (self.br - self.prev_br) * inv; + let d_bu = (self.bu - self.prev_bu) * inv; + self.prev_br = self.br; + self.prev_bu = self.bu; + let bass_supp = (1.0 - self.ba / (self.ba_l + 1e-3)).clamp(0.0, 1.0); + let rise = (d_br.max(0.0) * 1.4 + d_bu.max(0.0) * 1.0) * (0.35 + 0.65 * bass_supp); + self.tension = (self.tension + (rise * 2.6 - 0.22) * dt).clamp(0.0, 1.0); + if self.ba > 0.85 * self.ba_l { + self.tension = (self.tension - dt * 1.6).max(0.0); + } + + // Release: a primed build collapsing into a bass+flux hit = the drop. + let primed = self.tension > 0.5; + let hit = b.low_on > 0.5 || (self.ba - self.ba_l) > 0.18; + let mut drop_now = false; + if primed && hit && b.flux > 0.4 { + self.release = 1.0; + self.tension = 0.0; + drop_now = true; + } + self.release = (self.release - dt / 0.5).max(0.0); + + // Classify. Drop on release is instant; everything else must dwell to + // beat hysteresis so a section is one change, not a stutter. + let cand = if drop_now { + Arch::Drop + } else if self.tension > 0.45 { + Arch::Build + } else if self.e < 0.32 && self.bu < 0.28 { + Arch::Ambient + } else if self.e > 0.60 && self.ba > 0.52 && self.bu > 0.48 { + Arch::Drop + } else if self.e_l > 0.30 && self.e < 0.52 * self.e_l { + Arch::Breakdown + } else { + Arch::Groove + }; + + if cand == self.cand { + self.cand_t += dt; + } else { + self.cand = cand; + self.cand_t = 0.0; + } + let dwell = if drop_now { 0.0 } else { 0.55 }; + if self.cand != self.arch && self.cand_t >= dwell { + self.arch = self.cand; + return true; + } + false + } +} + +impl Default for Structure { + fn default() -> Self { + Self::new() + } +}