refactor into modular for future

This commit is contained in:
2026-05-19 18:51:40 +02:00
parent bf351be01c
commit 686d538743
10 changed files with 654 additions and 391 deletions
+1
View File
@@ -1,3 +1,4 @@
.cargo/
target/
*.mp4
*.flac
+131
View File
@@ -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<T>` | 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<U>` | 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<U>`)
`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<U>` 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<T>`
`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<T>` 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<U>` 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<U> {
// pipeline, bindings, and ping-pong textures
}
impl<U: bytemuck::Pod> ShaderPipeline<U> {
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<T>` 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
```
+12
View File
@@ -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
+26 -1
View File
@@ -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()
+28 -274
View File
@@ -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.
+218
View File
@@ -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)
}
}
}
}
+52
View File
@@ -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
}
+3
View File
@@ -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;
+5 -116
View File
@@ -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,
+178
View File
@@ -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()
}
}