refactor into modular for future
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
.cargo/
|
||||
target/
|
||||
*.mp4
|
||||
*.flac
|
||||
|
||||
+131
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user