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
+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()
}
}