This commit is contained in:
2026-05-19 01:04:39 +02:00
commit 689c70b530
15 changed files with 7226 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
target/
*.mp4
*.flac
Generated
+3463
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "audio-visualizer"
version = "0.1.0"
edition = "2024"
[dependencies]
cpal = "0.17"
rustfft = "6"
ringbuf = "0.5"
anyhow = "1"
nannou = "0.19"
triple_buffer = "9"
symphonia = { version = "0.6", features = ["mp3", "isomp4", "aac", "flac", "vorbis", "ogg", "wav", "pcm"] }
[profile.release]
opt-level = 3
lto = true
+752
View File
@@ -0,0 +1,752 @@
//! Capture / file-playback + analysis pipeline.
//!
//! source thread (cpal RT callback OR symphonia decode) --lock-free ring-->
//! analysis thread (overlapping STFT, Hann, rustfft, per-band AGC + envelope,
//! spectral-flux onset) --triple_buffer--> consumer (latest frame, no backlog).
//!
//! The STFT core lives in [`Analyzer`] so the live thread and the offline
//! batch path ([`analyze_file`]) share exactly the same numbers.
//!
//! [`Bands`] carries smoothed level (AGC-normalised 0..1), a fast onset spike
//! per band, plus richer descriptors used for colour and growth: a log-spaced
//! spectrum, spectral centroid (brightness), broadband flux, loudness, and a
//! 12-bin chroma vector (harmonic palette). Drop the [`AudioHandle`] to stop.
use std::fs::File;
use std::path::Path;
use std::thread;
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, Sample, SampleFormat};
use ringbuf::HeapRb;
use ringbuf::traits::{Consumer, Observer, Producer, Split};
use rustfft::FftPlanner;
use rustfft::num_complex::Complex;
use symphonia::core::codecs::CodecParameters;
use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions};
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader, TrackType};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
pub const FFT_SIZE: usize = 2048;
pub const HOP: usize = 512; // 75% overlap
const RING_CAP: usize = FFT_SIZE * 16;
/// Log-spaced spectrum buckets exposed to the visualiser (per-strand drive).
pub const SPEC_N: usize = 16;
/// Pitch classes for the chroma / harmonic palette.
pub const CHROMA_N: usize = 12;
/// Decimated raw time-domain window taps exposed for the oscilloscope mode.
pub const WAVE_N: usize = 256;
const SPEC_LO: f32 = 30.0;
const SPEC_HI: f32 = 16_000.0;
// Band edges in Hz. Low = kick/sub, Mid = synths/vocals, High = hats/glitch.
const LOW: (f32, f32) = (20.0, 250.0);
const MID: (f32, f32) = (250.0, 2000.0);
const HIGH: (f32, f32) = (2000.0, 16000.0);
// Level envelope follower: snap up fast, decay slow.
const ATTACK: f32 = 0.6;
const RELEASE: f32 = 0.08;
// AGC: rolling per-metric ceiling decays slowly so quiet/loud tracks self-level.
const AGC_DECAY: f32 = 0.9992;
const AGC_FLOOR: f32 = 1e-4;
// Onset: instantaneous attack on rising spectral flux, fast release -> a hit.
const ONSET_RELEASE: f32 = 0.78;
/// Per-band level (AGC-normalised, smoothed) + onset spike + rich descriptors.
/// All scalar fields are 0..~1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bands {
pub low: f32,
pub mid: f32,
pub high: f32,
pub low_on: f32,
pub mid_on: f32,
pub high_on: f32,
/// Log-spaced normalised magnitude buckets (`SPEC_LO`..`SPEC_HI`).
pub spec: [f32; SPEC_N],
/// Spectral centroid, AGC-normalised 0..1 -> palette hue.
pub centroid: f32,
/// Broadband half-wave spectral flux onset -> global transient flashes.
pub flux: f32,
/// Overall loudness (mean magnitude), AGC-normalised -> lightness/scale.
pub loud: f32,
/// Relative pitch-class energy (max-normalised) -> harmonic accent hues.
pub chroma: [f32; CHROMA_N],
/// Decimated raw waveform (latest `FFT_SIZE` window, un-windowed, ~-1..1).
/// Not smoothed/AGC'd — the time-domain trace the scope mode draws.
pub wave: [f32; WAVE_N],
}
impl Default for Bands {
fn default() -> Self {
Bands {
low: 0.0,
mid: 0.0,
high: 0.0,
low_on: 0.0,
mid_on: 0.0,
high_on: 0.0,
spec: [0.0; SPEC_N],
centroid: 0.0,
flux: 0.0,
loud: 0.0,
chroma: [0.0; CHROMA_N],
wave: [0.0; WAVE_N],
}
}
}
/// What to feed the analyser.
pub enum Source {
/// cpal input by index (None = name "monitor", else system default).
Capture(Option<usize>),
/// First cpal input whose name contains this substring (case-insensitive).
CaptureNamed(String),
/// Decode + play this audio file, analysing it in lock-step.
File(std::path::PathBuf),
}
/// Live handle. Keeps streams/threads alive; poll [`Self::bands`] per frame.
pub struct AudioHandle {
_streams: Vec<cpal::Stream>,
out: triple_buffer::Output<Bands>,
}
impl AudioHandle {
/// Most recently published bands (latest-wins, never blocks).
pub fn bands(&mut self) -> Bands {
*self.out.read()
}
}
/// A fully pre-analysed file: one [`Bands`] frame per STFT hop.
///
/// Used by the offline renderer so frame *N* of video maps deterministically
/// to audio time `N / video_fps`, regardless of how fast frames render.
pub struct Timeline {
pub frames: Vec<Bands>,
/// Analysis frame rate (`sample_rate / HOP`).
pub rate_hz: f32,
pub sample_rate: f32,
/// Total decoded mono samples (track length = this / sample_rate).
pub samples: usize,
}
impl Timeline {
/// Bands at a given wall-clock second (clamped, nearest hop).
pub fn at(&self, t_secs: f32) -> Bands {
if self.frames.is_empty() {
return Bands::default();
}
let i = (t_secs * self.rate_hz).round().max(0.0) as usize;
self.frames[i.min(self.frames.len() - 1)]
}
/// Track duration in seconds.
pub fn duration(&self) -> f32 {
self.samples as f32 / self.sample_rate.max(1.0)
}
}
/// List input devices to stdout, plus PipeWire routing hints.
#[allow(deprecated)] // cpal 0.17 deprecates Device::name; it's still the clearest label
pub fn print_devices() -> anyhow::Result<()> {
let host = cpal::default_host();
println!("Input devices:");
for (i, d) in host.input_devices()?.enumerate() {
println!(" [{i}] {}", d.name().unwrap_or_else(|_| "<unknown>".into()));
}
println!(
"Tips: pass an index, or `monitor`/`loopback`, or a file path.\n \
pw-link route: run, then `pw-link <sink>:monitor_FL <thisapp>:input_FL` (and _FR)."
);
Ok(())
}
#[allow(deprecated)]
fn pick_device(host: &cpal::Host, sel: &Source) -> anyhow::Result<cpal::Device> {
let devices: Vec<_> = host.input_devices()?.collect();
let by_substr = |s: &str, devices: Vec<cpal::Device>| {
devices.into_iter().find(|d| {
d.name()
.map(|n| n.to_lowercase().contains(s))
.unwrap_or(false)
})
};
let dev = match sel {
Source::Capture(Some(i)) => devices.into_iter().nth(*i),
Source::Capture(None) => {
by_substr("monitor", devices.clone()).or_else(|| host.default_input_device())
}
Source::CaptureNamed(s) => by_substr(&s.to_lowercase(), devices),
Source::File(_) => unreachable!(),
};
dev.ok_or_else(|| anyhow::anyhow!("no matching input device"))
}
/// Start capture or file playback + analysis.
#[allow(deprecated)]
pub fn start(src: Source) -> anyhow::Result<AudioHandle> {
let (input, out) = triple_buffer::triple_buffer(&Bands::default());
let rb = HeapRb::<f32>::new(RING_CAP);
let (mut prod, cons) = rb.split();
let mut push_mono = move |m: f32| {
let _ = prod.try_push(m);
};
let mut streams: Vec<cpal::Stream> = Vec::new();
let host = cpal::default_host();
let sample_rate = match &src {
Source::File(path) => spawn_file_source(path, push_mono, &mut streams)?,
_ => {
let device = pick_device(&host, &src)?;
let cfg = device.default_input_config()?;
let sr = cfg.sample_rate() as f32;
let channels = cfg.channels() as usize;
eprintln!(
"audio: capture {} @ {} Hz, {} ch, {:?}",
device.name().unwrap_or_else(|_| "<unknown>".into()),
sr as u32,
channels,
cfg.sample_format(),
);
let scfg = cfg.config();
let err_fn = |e| eprintln!("stream error: {e}");
fn run<T>(
device: &cpal::Device,
cfg: &cpal::StreamConfig,
channels: usize,
mut push: impl FnMut(f32) + Send + 'static,
err_fn: impl FnMut(cpal::StreamError) + Send + 'static,
) -> Result<cpal::Stream, cpal::BuildStreamError>
where
T: cpal::SizedSample,
f32: FromSample<T>,
{
device.build_input_stream(
cfg,
move |data: &[T], _| {
for f in data.chunks(channels) {
let mut s = 0.0;
for &v in f {
s += f32::from_sample(v);
}
push(s / f.len().max(1) as f32);
}
},
err_fn,
None,
)
}
let stream = match cfg.sample_format() {
SampleFormat::F32 => device.build_input_stream(
&scfg,
move |data: &[f32], _| {
for f in data.chunks(channels) {
let s: f32 = f.iter().sum::<f32>() / f.len().max(1) as f32;
push_mono(s);
}
},
err_fn,
None,
)?,
SampleFormat::I16 => run::<i16>(&device, &scfg, channels, push_mono, err_fn)?,
SampleFormat::U16 => run::<u16>(&device, &scfg, channels, push_mono, err_fn)?,
SampleFormat::I32 => run::<i32>(&device, &scfg, channels, push_mono, err_fn)?,
other => anyhow::bail!("unsupported sample format: {other:?}"),
};
stream.play()?;
streams.push(stream);
sr
}
};
thread::spawn(move || analysis_loop(cons, sample_rate, input));
Ok(AudioHandle {
_streams: streams,
out,
})
}
/// Decode `path`, play it on the default output, tee mono into `push_mono`.
/// Returns the source sample rate. Falls back to the output device's native
/// rate with linear resampling if the device rejects the file's rate.
/// A probed file ready to decode: format reader + audio decoder + the
/// resolved track id, sample rate and channel count. (symphonia 0.6 API.)
struct DecodedFile {
reader: Box<dyn FormatReader>,
decoder: Box<dyn AudioDecoder>,
track_id: u32,
sample_rate: u32,
channels: usize,
}
fn open_file(path: &Path) -> anyhow::Result<DecodedFile> {
let file = File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let reader = symphonia::default::get_probe().probe(
&hint,
mss,
FormatOptions::default(),
MetadataOptions::default(),
)?;
let (track_id, sample_rate, channels, decoder) = {
let track = reader
.default_track(TrackType::Audio)
.ok_or_else(|| anyhow::anyhow!("no decodable audio track"))?;
let id = track.id;
let Some(CodecParameters::Audio(ap)) = track.codec_params.as_ref() else {
anyhow::bail!("track has no audio codec parameters");
};
let sr = ap.sample_rate.unwrap_or(44_100);
let ch = ap.channels.as_ref().map(|c| c.count()).unwrap_or(2).max(1);
let dec = symphonia::default::get_codecs()
.make_audio_decoder(ap, &AudioDecoderOptions::default())?;
(id, sr, ch, dec)
};
Ok(DecodedFile {
reader,
decoder,
track_id,
sample_rate,
channels,
})
}
fn spawn_file_source(
path: &Path,
mut push_mono: impl FnMut(f32) + Send + 'static,
streams: &mut Vec<cpal::Stream>,
) -> anyhow::Result<f32> {
let DecodedFile {
mut reader,
mut decoder,
track_id,
sample_rate: file_sr,
channels: file_ch,
} = open_file(path)?;
// Output device + config. Try the file's rate; fall back + resample.
let host = cpal::default_host();
let dev = host
.default_output_device()
.ok_or_else(|| anyhow::anyhow!("no output device"))?;
let default_cfg = dev.default_output_config()?;
let out_ch = default_cfg.channels() as usize;
// Use the file's rate only if the device actually supports it; else fall
// back to the device's native rate and linear-resample.
let supports_file_sr = dev
.supported_output_configs()
.map(|mut it| {
it.any(|c| c.min_sample_rate() <= file_sr && file_sr <= c.max_sample_rate())
})
.unwrap_or(false);
let (scfg, out_sr) = if supports_file_sr {
(
cpal::StreamConfig {
channels: default_cfg.channels(),
sample_rate: file_sr,
buffer_size: cpal::BufferSize::Default,
},
file_sr as f32,
)
} else {
(default_cfg.config(), default_cfg.sample_rate() as f32)
};
// Playback ring holds interleaved f32 at the *output* device rate.
let pb = HeapRb::<f32>::new(out_ch * out_sr as usize); // ~1s
let (mut pb_prod, mut pb_cons) = pb.split();
let err_fn = |e| eprintln!("output stream error: {e}");
let stream = dev.build_output_stream(
&scfg,
move |data: &mut [f32], _| {
for s in data.iter_mut() {
*s = pb_cons.try_pop().unwrap_or(0.0);
}
},
err_fn,
None,
)?;
stream.play()?;
streams.push(stream);
eprintln!(
"audio: file {} @ {} Hz {} ch -> output {} Hz {} ch",
path.display(),
file_sr,
file_ch,
out_sr as u32,
out_ch
);
let resample = (out_sr / file_sr as f32).max(0.01);
thread::spawn(move || {
// Linear-resample state per output channel (mono dup across out_ch).
let mut frac = 0.0f32;
let mut prev_mono = 0.0f32;
let mut ilv: Vec<f32> = Vec::new();
loop {
let packet = match reader.next_packet() {
Ok(Some(p)) => p,
Ok(None) => break, // EOF -> stop feeding; output silences
Err(_) => break,
};
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(_) => continue,
};
let ch = decoded.spec().channels().count().max(1);
decoded.copy_to_vec_interleaved::<f32>(&mut ilv);
for frame in ilv.chunks(ch) {
let mono = frame.iter().sum::<f32>() / ch as f32;
// Emit `resample` output frames per input frame (linear).
frac += resample;
while frac >= 1.0 {
frac -= 1.0;
let a = 1.0 - frac.min(1.0);
let s = prev_mono * (1.0 - a) + mono * a;
push_mono(s);
// Block until playback ring has room (back-pressure ==
// play speed; keeps analysis in lock-step with audio).
for _ in 0..out_ch {
while pb_prod.try_push(s).is_err() {
thread::sleep(Duration::from_millis(1));
}
}
}
prev_mono = mono;
}
}
});
Ok(file_sr as f32)
}
/// Streaming STFT analyser. Feed mono samples; emits one [`Bands`] per hop.
///
/// Holds all envelope / AGC / onset state so the live thread and the offline
/// batch produce bit-identical frames for the same input.
pub struct Analyzer {
hann: Vec<f32>,
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
win: Vec<f32>,
filled: usize,
since_hop: usize,
spectrum: Vec<Complex<f32>>,
prev_mag: Vec<f32>,
bin_hz: f32,
env: Bands,
// AGC ceilings: 3 level, 3 flux, SPEC_N spectrum, centroid, loud, broad flux.
agc_lvl: [f32; 3],
agc_flux: [f32; 3],
agc_spec: [f32; SPEC_N],
agc_centroid: f32,
agc_loud: f32,
agc_broad: f32,
pop: [f32; 3], // low/mid/high onset envelopes
broad_pop: f32, // broadband onset envelope
spec_edges: [(usize, usize); SPEC_N],
}
fn norm(v: f32, c: &mut f32) -> f32 {
*c = (*c * AGC_DECAY).max(AGC_FLOOR).max(v);
(v / *c).clamp(0.0, 1.0)
}
fn follow(env: &mut f32, x: f32) {
let coeff = if x > *env { ATTACK } else { RELEASE };
*env += (x - *env) * coeff;
}
impl Analyzer {
pub fn new(sample_rate: f32) -> Self {
let hann: Vec<f32> = (0..FFT_SIZE)
.map(|n| {
let x = std::f32::consts::PI * 2.0 * n as f32 / (FFT_SIZE as f32 - 1.0);
0.5 - 0.5 * x.cos()
})
.collect();
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(FFT_SIZE);
let bin_hz = sample_rate / FFT_SIZE as f32;
// Pre-compute log-spaced spectrum bucket bin ranges once.
let half = FFT_SIZE / 2;
let mut spec_edges = [(0usize, 0usize); SPEC_N];
for (i, e) in spec_edges.iter_mut().enumerate() {
let f0 = SPEC_LO * (SPEC_HI / SPEC_LO).powf(i as f32 / SPEC_N as f32);
let f1 = SPEC_LO * (SPEC_HI / SPEC_LO).powf((i + 1) as f32 / SPEC_N as f32);
let a = ((f0 / bin_hz).floor() as usize).min(half - 1);
let b = ((f1 / bin_hz).ceil() as usize).clamp(a + 1, half);
*e = (a, b);
}
Analyzer {
hann,
fft,
win: vec![0.0; FFT_SIZE],
filled: 0,
since_hop: 0,
spectrum: vec![Complex::new(0.0, 0.0); FFT_SIZE],
prev_mag: vec![0.0; half],
bin_hz,
env: Bands::default(),
agc_lvl: [AGC_FLOOR; 3],
agc_flux: [AGC_FLOOR; 3],
agc_spec: [AGC_FLOOR; SPEC_N],
agc_centroid: AGC_FLOOR,
agc_loud: AGC_FLOOR,
agc_broad: AGC_FLOOR,
pop: [0.0; 3],
broad_pop: 0.0,
spec_edges,
}
}
/// Push one mono sample. Returns `Some(bands)` when a hop completes.
pub fn push(&mut self, s: f32) -> Option<Bands> {
self.win.copy_within(1..FFT_SIZE, 0);
self.win[FFT_SIZE - 1] = s;
self.filled = (self.filled + 1).min(FFT_SIZE);
self.since_hop += 1;
if self.filled < FFT_SIZE || self.since_hop < HOP {
return None;
}
self.since_hop = 0;
Some(self.compute())
}
fn compute(&mut self) -> Bands {
for (i, (&x, &w)) in self.win.iter().zip(&self.hann).enumerate() {
self.spectrum[i] = Complex::new(x * w, 0.0);
}
self.fft.process(&mut self.spectrum);
let half = FFT_SIZE / 2;
let bin_hz = self.bin_hz;
let range = |lo: f32, hi: f32| -> (usize, usize) {
let a = (lo / bin_hz).floor() as usize;
let b = ((hi / bin_hz).ceil() as usize).min(half);
(a, b.max(a + 1))
};
// Magnitudes (cache once), running flux + centroid + loudness.
let mut mags = vec![0.0f32; half];
let mut broad_flux = 0.0f32;
let mut cen_num = 0.0f32;
let mut cen_den = 0.0f32;
let mut loud_sum = 0.0f32;
for k in 0..half {
let m = self.spectrum[k].norm();
mags[k] = m;
let d = m - self.prev_mag[k];
if d > 0.0 {
broad_flux += d;
}
let f = k as f32 * bin_hz;
cen_num += f * m;
cen_den += m;
loud_sum += m;
}
// Three classic bands: mean level + half-wave flux.
let mut lvl = [0.0f32; 3];
let mut flux = [0.0f32; 3];
for (bi, &(lo, hi)) in [LOW, MID, HIGH].iter().enumerate() {
let (a, b) = range(lo, hi);
let mut sum = 0.0;
let mut fl = 0.0;
for k in a..b {
let m = mags[k];
sum += m;
let d = m - self.prev_mag[k];
if d > 0.0 {
fl += d;
}
}
let w = (b - a) as f32;
lvl[bi] = sum / w;
flux[bi] = fl / w;
}
// Log-spaced spectrum buckets (mean magnitude per bucket).
let mut spec_raw = [0.0f32; SPEC_N];
for (i, &(a, b)) in self.spec_edges.iter().enumerate() {
let mut sum = 0.0;
for &m in &mags[a..b] {
sum += m;
}
spec_raw[i] = sum / (b - a) as f32;
}
// Chroma: fold bin magnitude onto 12 pitch classes (relative energy).
let mut chroma = [0.0f32; CHROMA_N];
for k in 1..half {
let f = k as f32 * bin_hz;
if f < 55.0 || f > 5000.0 {
continue;
}
let pc = (12.0 * (f / 440.0).log2()).round() as i32;
let idx = pc.rem_euclid(CHROMA_N as i32) as usize;
chroma[idx] += mags[k];
}
let cmax = chroma.iter().cloned().fold(0.0f32, f32::max).max(1e-6);
for c in &mut chroma {
*c /= cmax;
}
// Advance prev_mag now that flux is computed.
self.prev_mag.copy_from_slice(&mags);
// AGC-normalise each metric against its decaying ceiling.
let l = [
norm(lvl[0], &mut self.agc_lvl[0]),
norm(lvl[1], &mut self.agc_lvl[1]),
norm(lvl[2], &mut self.agc_lvl[2]),
];
let f = [
norm(flux[0], &mut self.agc_flux[0]),
norm(flux[1], &mut self.agc_flux[1]),
norm(flux[2], &mut self.agc_flux[2]),
];
let mut spec = [0.0f32; SPEC_N];
for i in 0..SPEC_N {
spec[i] = norm(spec_raw[i], &mut self.agc_spec[i]);
}
let centroid_hz = if cen_den > 1e-6 { cen_num / cen_den } else { 0.0 };
let centroid = norm(centroid_hz, &mut self.agc_centroid);
let loud = norm(loud_sum / half as f32, &mut self.agc_loud);
let broad = norm(broad_flux / half as f32, &mut self.agc_broad);
// Smoothed levels.
follow(&mut self.env.low, l[0]);
follow(&mut self.env.mid, l[1]);
follow(&mut self.env.high, l[2]);
follow(&mut self.env.centroid, centroid);
follow(&mut self.env.loud, loud);
for i in 0..SPEC_N {
follow(&mut self.env.spec[i], spec[i]);
}
self.env.chroma = chroma;
// Onsets: jump to flux instantly, decay fast -> spike.
for i in 0..3 {
self.pop[i] = if f[i] > self.pop[i] {
f[i]
} else {
self.pop[i] * ONSET_RELEASE
};
}
self.broad_pop = if broad > self.broad_pop {
broad
} else {
self.broad_pop * ONSET_RELEASE
};
self.env.low_on = self.pop[0];
self.env.mid_on = self.pop[1];
self.env.high_on = self.pop[2];
self.env.flux = self.broad_pop;
// Raw waveform tap: decimate the un-windowed sample window so the scope
// mode has a real time-domain trace. Same numbers live + offline.
let stride = FFT_SIZE / WAVE_N;
for i in 0..WAVE_N {
self.env.wave[i] = self.win[i * stride];
}
self.env
}
}
fn analysis_loop(
mut cons: impl Consumer<Item = f32> + Observer,
sample_rate: f32,
mut out: triple_buffer::Input<Bands>,
) {
let mut an = Analyzer::new(sample_rate);
let mut scratch = vec![0.0f32; HOP * 8];
loop {
let avail = cons.occupied_len();
if avail == 0 {
thread::sleep(Duration::from_millis(2));
continue;
}
let take = avail.min(scratch.len());
let got = cons.pop_slice(&mut scratch[..take]);
for &s in &scratch[..got] {
if let Some(b) = an.push(s) {
out.write(b);
}
}
}
}
/// Decode a whole file to mono and run the analyser over it, returning a
/// per-hop [`Bands`] [`Timeline`]. Used by the offline renderer; does not
/// touch any audio device. (Audio is muxed into the video later by ffmpeg.)
pub fn analyze_file(path: &Path) -> anyhow::Result<Timeline> {
let DecodedFile {
mut reader,
mut decoder,
track_id,
sample_rate: sr,
..
} = open_file(path)?;
let sample_rate = sr as f32;
let mut an = Analyzer::new(sample_rate);
let mut frames = Vec::new();
let mut samples = 0usize;
let mut ilv: Vec<f32> = Vec::new();
loop {
let packet = match reader.next_packet() {
Ok(Some(p)) => p,
Ok(None) => break,
Err(_) => break,
};
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(_) => continue,
};
let ch = decoded.spec().channels().count().max(1);
decoded.copy_to_vec_interleaved::<f32>(&mut ilv);
for frame in ilv.chunks(ch) {
let mono = frame.iter().sum::<f32>() / ch as f32;
samples += 1;
if let Some(b) = an.push(mono) {
frames.push(b);
}
}
}
Ok(Timeline {
frames,
rate_hz: sample_rate / HOP as f32,
sample_rate,
samples,
})
}
+766
View File
@@ -0,0 +1,766 @@
// Audio-reactive living cyber-organic sigil.
//
// A fixed cyber skeleton with organic overgrowth that grows / branches /
// withers / restructures with the music, drawn as noise-warped curves in
// audio-driven OKLCH colour, through a feedback-trail + bloom post stack with
// transient chromatic aberration. See src/viz/*.
//
// cargo run --release --bin sigil -- [--mode sigil|scope|breakcore] [<index>|monitor|loopback|<file>]
// cargo run --release --bin sigil -- --render [--mode breakcore] [out.mp4] <file>
//
// --render decodes the whole file and renders every frame at a fixed fps with
// no frame budget, *streaming* each raw frame straight into one long-lived
// ffmpeg over stdin (no PNG sequence / temp dir). Output res/quality is
// cfg-tunable (out_scale / crf / x264_preset). Live mode plays the file/feed
// and reacts in real time. --mode picks the visualiser (default sigil);
// `breakcore` is a wgpu SDF raymarcher that bypasses the Draw/Post path.
//
// Keys: R reseed · M cycle mode · P save PNG · F fullscreen · H HUD · G glow
// B feedback · C write cfg · 1/2 low-scale · 3/4 warp · 5/6 trail-decay
// 7/8 bloom · 9/0 chroma-aberration · -/= curve quality · Esc quit
use std::io::Write;
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use audio_visualizer::audio::{self, AudioHandle, Bands, Source, Timeline};
use audio_visualizer::viz::breakcore::Breakcore;
use audio_visualizer::viz::palette::Palette;
use audio_visualizer::viz::post::{Post, ADDITIVE};
use audio_visualizer::viz::scope::Scope;
use audio_visualizer::viz::sigil::Sigil;
use nannou::prelude::*;
const W: f32 = 1080.0;
const H: f32 = 1080.0;
const RENDER_FPS: f32 = 60.0;
const SEED: u64 = 0x5C1_6E1_5EED;
// x264 speed/quality preset. Copy so `Gains` stays Copy.
#[derive(Clone, Copy, PartialEq)]
enum Preset {
Ultrafast,
Veryfast,
Faster,
Fast,
Medium,
Slow,
Slower,
Veryslow,
}
impl Preset {
fn as_str(self) -> &'static str {
match self {
Preset::Ultrafast => "ultrafast",
Preset::Veryfast => "veryfast",
Preset::Faster => "faster",
Preset::Fast => "fast",
Preset::Medium => "medium",
Preset::Slow => "slow",
Preset::Slower => "slower",
Preset::Veryslow => "veryslow",
}
}
fn parse(s: &str) -> Option<Self> {
Some(match s {
"ultrafast" => Preset::Ultrafast,
"veryfast" => Preset::Veryfast,
"faster" => Preset::Faster,
"fast" => Preset::Fast,
"medium" => Preset::Medium,
"slow" => Preset::Slow,
"slower" => Preset::Slower,
"veryslow" => Preset::Veryslow,
_ => return None,
})
}
}
// Runtime-tunable, persisted to <project>/sigil.cfg.
#[derive(Clone, Copy)]
struct Gains {
low: f32, // low level -> breathing scale
warp: f32, // organic noise-warp amplitude multiplier
fade: f32, // feedback decay per frame (0 endless .. 1 none)
zoom: f32, // feedback bloom expansion (~1.006)
ca: f32, // chromatic aberration px at full broadband flux
seg: usize, // Catmull-Rom samples per control segment (quality)
glow: bool, // faux-glow halo passes
feedback: bool, // feedback/bloom post (vs. direct draw)
out_scale: u32, // --render: output square px (0 = native RES, no rescale)
crf: u32, // --render: x264 crf (lower = bigger/better; ~16..28)
x264: Preset, // --render: x264 speed/quality preset
}
impl Default for Gains {
fn default() -> Self {
Gains {
low: 0.85,
warp: 1.0,
fade: 0.11,
zoom: 1.006,
ca: 7.0,
seg: 9,
glow: true,
feedback: true,
out_scale: 0,
crf: 18,
x264: Preset::Slow,
}
}
}
impl Gains {
fn load(path: &PathBuf) -> Self {
let mut g = Gains::default();
let Ok(txt) = std::fs::read_to_string(path) else {
return g;
};
for line in txt.lines() {
let Some((k, v)) = line.split_once('=') else {
continue;
};
let (k, v) = (k.trim(), v.trim());
match k {
"low" => g.low = v.parse().unwrap_or(g.low),
"warp" => g.warp = v.parse().unwrap_or(g.warp),
"fade" => g.fade = v.parse().unwrap_or(g.fade),
"zoom" => g.zoom = v.parse().unwrap_or(g.zoom),
"ca" => g.ca = v.parse().unwrap_or(g.ca),
"seg" => g.seg = v.parse().unwrap_or(g.seg),
"glow" => g.glow = v.parse().unwrap_or(g.glow),
"feedback" => g.feedback = v.parse().unwrap_or(g.feedback),
"out_scale" => g.out_scale = v.parse().unwrap_or(g.out_scale),
"crf" => g.crf = v.parse().unwrap_or(g.crf),
"x264_preset" => g.x264 = Preset::parse(v).unwrap_or(g.x264),
_ => {}
}
}
g.seg = g.seg.clamp(2, 24);
g.crf = g.crf.clamp(0, 51);
if g.out_scale != 0 {
g.out_scale &= !1; // x264 yuv420p needs even dimensions
}
g
}
fn save(&self, path: &PathBuf) {
let s = format!(
"low={}\nwarp={}\nfade={}\nzoom={}\nca={}\nseg={}\nglow={}\nfeedback={}\n\
out_scale={}\ncrf={}\nx264_preset={}\n",
self.low,
self.warp,
self.fade,
self.zoom,
self.ca,
self.seg,
self.glow,
self.feedback,
self.out_scale,
self.crf,
self.x264.as_str(),
);
if std::fs::write(path, s).is_ok() {
println!("wrote {}", path.display());
}
}
}
enum Mode {
Live(AudioHandle),
Render {
tl: Timeline,
frame: u64,
total: u64,
ff: Child, // long-lived ffmpeg encoder
stdin: Option<ChildStdin>, // raw-frame pipe (None once closed)
out: PathBuf,
},
}
/// The active visualiser. `Sigil`/`Scope` are `Draw`-based and share the draw
/// signature so the chromatic-aberration channel passes dispatch uniformly;
/// `Breakcore` owns its own wgpu raymarch target instead (see `is_gpu`).
#[allow(clippy::large_enum_variant)] // Sigil/Scope are inline; Breakcore is boxed
enum Visual {
Sigil(Sigil),
Scope(Scope),
Breakcore(Box<Breakcore>), // owns its wgpu pipeline -> boxed
}
impl Visual {
fn update(&mut self, b: &Bands, dt: f32) {
match self {
Visual::Sigil(s) => s.update(b, dt),
Visual::Scope(s) => s.update(b, dt),
Visual::Breakcore(s) => s.update(b, dt),
}
}
fn reseed(&mut self, seed: u64) {
match self {
Visual::Sigil(s) => s.reseed(seed),
Visual::Scope(s) => s.reseed(seed),
Visual::Breakcore(s) => s.reseed(seed),
}
}
fn seed(&self) -> u64 {
match self {
Visual::Sigil(s) => s.seed,
Visual::Scope(s) => s.seed,
Visual::Breakcore(s) => s.seed,
}
}
fn count(&self) -> usize {
match self {
Visual::Sigil(s) => s.tendril_count(),
Visual::Scope(s) => s.point_count(),
Visual::Breakcore(s) => s.point_count(),
}
}
fn name(&self) -> &'static str {
match self {
Visual::Sigil(_) => "sigil",
Visual::Scope(_) => "scope",
Visual::Breakcore(_) => "breakcore",
}
}
/// `Breakcore` renders through its own wgpu pipeline, not the shared
/// `Draw` → `Post` path.
fn is_gpu(&self) -> bool {
matches!(self, Visual::Breakcore(_))
}
/// Cycle visualisers, keeping the current seed (live only). `Breakcore`
/// needs the device to (re)build its pipeline.
fn cycle(&mut self, device: &nannou::wgpu::Device) {
let s = self.seed();
*self = match self {
Visual::Sigil(_) => Visual::Scope(Scope::new(s)),
Visual::Scope(_) => Visual::Breakcore(Box::new(Breakcore::new(s, device))),
Visual::Breakcore(_) => Visual::Sigil(Sigil::new(s)),
};
}
#[allow(clippy::too_many_arguments)]
fn draw(
&self,
d: &Draw,
pal: &Palette,
fit: f32,
scale: f32,
warp: f32,
glow: bool,
seg: usize,
tint: [f32; 3],
) {
match self {
Visual::Sigil(s) => s.draw(d, pal, fit, scale, warp, glow, seg, tint),
Visual::Scope(s) => s.draw(d, pal, fit, scale, warp, glow, seg, tint),
Visual::Breakcore(_) => {} // rendered via its own pipeline
}
}
// --- gpu path (Breakcore only); the other arms never run these ---------
#[allow(clippy::too_many_arguments)]
fn render_gpu<'a>(
&'a mut self,
device: &nannou::wgpu::Device,
queue: &nannou::wgpu::Queue,
pal: &Palette,
scale: f32,
warp: f32,
feedback: bool,
fade: f32,
ca_px: f32,
) -> &'a nannou::wgpu::Texture {
match self {
Visual::Breakcore(s) => {
s.render(device, queue, pal, scale, warp, feedback, fade, ca_px)
}
_ => unreachable!("render_gpu on a Draw-based visual"),
}
}
fn current_tex(&self) -> &nannou::wgpu::Texture {
match self {
Visual::Breakcore(s) => s.current(),
_ => unreachable!("current_tex on a Draw-based visual"),
}
}
fn capture_raw(
&self,
device: &nannou::wgpu::Device,
queue: &nannou::wgpu::Queue,
) -> anyhow::Result<Vec<u8>> {
match self {
Visual::Breakcore(s) => s.capture_raw(device, queue),
_ => unreachable!("capture_raw on a Draw-based visual"),
}
}
}
struct Model {
visual: Visual,
post: Post,
mode: Mode,
g: Gains,
cfg: PathBuf,
hud: bool,
fullscreen: bool,
ca_env: f32,
last: Bands, // for the HUD
}
fn main() {
// Headless diagnostic: decode + analyse a file and print timeline stats.
// No window/GPU/audio device. Useful for validating the analysis path.
// sigil --analyze <file>
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--analyze") {
let f = args
.get(i + 1)
.map(PathBuf::from)
.unwrap_or_else(|| die("--analyze needs a file path"));
match audio::analyze_file(&f) {
Ok(tl) => {
let mut peak = Bands::default();
for b in &tl.frames {
peak.low = peak.low.max(b.low);
peak.loud = peak.loud.max(b.loud);
peak.flux = peak.flux.max(b.flux);
peak.centroid = peak.centroid.max(b.centroid);
}
println!(
"ok: {} frames, {:.2}s, {} Hz, {:.1} fps\n peak low {:.2} loud {:.2} flux {:.2} centroid {:.2}",
tl.frames.len(),
tl.duration(),
tl.sample_rate as u32,
tl.rate_hz,
peak.low,
peak.loud,
peak.flux,
peak.centroid,
);
}
Err(e) => die(format!("analyze: {e}")),
}
return;
}
nannou::app(model).update(update).exit(on_exit).run();
}
fn die(msg: impl std::fmt::Display) -> ! {
eprintln!("error: {msg}");
std::process::exit(1);
}
/// FNV-1a 64 — stable string hash to fold a file name into the visual seed.
fn fnv1a(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01B3);
}
h
}
fn model(app: &App) -> Model {
if let Err(e) = app
.new_window()
.size(W as u32, H as u32)
.title("living sigil")
.view(view)
.key_pressed(key_pressed)
.build()
{
die(format!("window: {e}"));
}
// Parse args:
// [--render [out.mp4]] [--mode sigil|scope] [<index>|monitor|loopback|<file>]
let args: Vec<String> = std::env::args().skip(1).collect();
let render = args.iter().any(|a| a == "--render");
let mode_sel = args
.iter()
.position(|a| a == "--mode")
.and_then(|i| args.get(i + 1))
.cloned();
// Drop the flags (and --mode's value) so what remains is [out.mp4?] <src>.
let rest: Vec<&String> = {
let mut skip = false;
args.iter()
.filter(|a| {
if skip {
skip = false;
return false;
}
match a.as_str() {
"--render" => false,
"--mode" => {
skip = true;
false
}
_ => true,
}
})
.collect()
};
// Config (and thus the render encode settings) must load before we spawn
// ffmpeg for a render.
let cfg = app
.project_path()
.map(|p| p.join("sigil.cfg"))
.unwrap_or_else(|_| PathBuf::from("sigil.cfg"));
let g = Gains::load(&cfg);
let mode = if render {
// rest = [out.mp4?] <file>. Last arg must be the audio file.
let file = rest
.last()
.map(|s| PathBuf::from(s.as_str()))
.filter(|p| p.exists())
.unwrap_or_else(|| die("--render needs an existing audio file as last arg"));
let out = if rest.len() >= 2 {
PathBuf::from(rest[0].as_str())
} else {
file.with_extension("mp4")
};
eprintln!("analysing {} ...", file.display());
let tl = audio::analyze_file(&file).unwrap_or_else(|e| die(format!("analyze: {e}")));
let total = (tl.duration() * RENDER_FPS).ceil() as u64;
let res = Post::res() as u32;
let scaled = g.out_scale != 0 && g.out_scale != res;
eprintln!(
"render: {:.1}s, {} frames @ {} fps -> {} ({}p, crf {}, {})",
tl.duration(),
total,
RENDER_FPS as u32,
out.display(),
if scaled { g.out_scale } else { res },
g.crf,
g.x264.as_str(),
);
// One long-lived ffmpeg: raw RGBA frames in over stdin, original audio
// muxed from the file. No PNG sequence / temp dir.
let mut cmd = Command::new("ffmpeg");
cmd.args([
"-y",
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"-s",
&format!("{res}x{res}"),
"-r",
&format!("{}", RENDER_FPS as u32),
"-i",
"-",
]);
cmd.arg("-i").arg(&file);
cmd.args(["-map", "0:v:0", "-map", "1:a:0"]);
if scaled {
cmd.args(["-vf", &format!("scale={s}:{s}", s = g.out_scale)]);
}
cmd.args([
"-c:v",
"libx264",
"-preset",
g.x264.as_str(),
"-crf",
&g.crf.to_string(),
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"320k",
"-shortest",
]);
cmd.arg(&out).stdin(Stdio::piped());
let mut ff = cmd
.spawn()
.unwrap_or_else(|e| die(format!("ffmpeg spawn failed ({e}); is it on PATH?")));
let stdin = ff.stdin.take();
Mode::Render {
tl,
frame: 0,
total,
ff,
stdin,
out,
}
} else {
let src = match rest.first().map(|s| s.as_str()) {
None => Source::Capture(None),
Some("monitor") => Source::CaptureNamed("monitor".into()),
Some("loopback") => Source::CaptureNamed("loopback".into()),
Some(a) => match a.parse::<usize>() {
Ok(i) => Source::Capture(Some(i)),
Err(_) => Source::File(a.into()),
},
};
Mode::Live(audio::start(src).unwrap_or_else(|e| die(format!("audio: {e}"))))
};
// Mix the audio-file name into the seed so each track looks distinct by
// default (R still reseeds; capture sources keep the constant seed).
let src_name: Option<&str> = if render {
rest.last().map(|s| s.as_str())
} else {
match rest.first().map(|s| s.as_str()) {
Some("monitor") | Some("loopback") | None => None,
Some(a) if a.parse::<usize>().is_ok() => None,
Some(a) => Some(a),
}
};
let seed = SEED ^ src_name.map(fnv1a).unwrap_or(0);
let visual = match mode_sel.as_deref() {
Some("scope") => Visual::Scope(Scope::new(seed)),
Some("breakcore") => {
Visual::Breakcore(Box::new(Breakcore::new(seed, app.main_window().device())))
}
Some("sigil") | None => Visual::Sigil(Sigil::new(seed)),
Some(other) => die(format!("unknown --mode {other:?} (sigil|scope|breakcore)")),
};
let post = Post::new(app.main_window().device());
Model {
visual,
post,
mode,
g,
cfg,
hud: true,
fullscreen: false,
ca_env: 0.0,
last: Bands::default(),
}
}
fn key_pressed(app: &App, m: &mut Model, key: Key) {
let g = &mut m.g;
match key {
Key::R => {
let s = (app.duration.since_start.as_nanos() as u64) ^ 0x9E37_79B9_7F4A_7C15;
m.visual.reseed(s);
println!("seed = {:#018x}", s);
}
Key::M => {
// Mode switch only makes sense live; a render is a fixed pass.
if matches!(m.mode, Mode::Live(_)) {
m.visual.cycle(app.main_window().device());
println!("mode = {}", m.visual.name());
}
}
Key::P => {
let path = app
.project_path()
.map(|p| p.join(format!("{}_{:016x}.png", m.visual.name(), m.visual.seed())))
.unwrap_or_else(|_| PathBuf::from("sigil.png"));
let window = app.main_window();
match m.post.capture_png(window.device(), window.queue(), &path) {
Ok(()) => println!("saved {}", path.display()),
Err(e) => eprintln!("save failed: {e}"),
}
}
Key::F => {
m.fullscreen = !m.fullscreen;
app.main_window().set_fullscreen(m.fullscreen);
}
Key::H => m.hud = !m.hud,
Key::G => g.glow = !g.glow,
Key::B => g.feedback = !g.feedback,
Key::C => g.save(&m.cfg),
Key::Key1 => g.low = (g.low - 0.1).max(0.0),
Key::Key2 => g.low += 0.1,
Key::Key3 => g.warp = (g.warp - 0.1).max(0.0),
Key::Key4 => g.warp += 0.1,
Key::Key5 => g.fade = (g.fade - 0.02).max(0.0),
Key::Key6 => g.fade = (g.fade + 0.02).min(1.0),
Key::Key7 => g.zoom = (g.zoom - 0.002).max(1.0),
Key::Key8 => g.zoom += 0.002,
Key::Key9 => g.ca = (g.ca - 1.0).max(0.0),
Key::Key0 => g.ca += 1.0,
Key::Minus => g.seg = g.seg.saturating_sub(1).max(2),
Key::Equals => g.seg = (g.seg + 1).min(24),
_ => {}
}
}
fn update(app: &App, m: &mut Model, upd: Update) {
// Pull this frame's analysis + the simulation time step.
let (b, dt) = match &mut m.mode {
Mode::Live(h) => (h.bands(), upd.since_last.as_secs_f32().clamp(0.0, 0.05)),
Mode::Render {
tl, frame, total, ..
} => {
if *frame >= *total {
// All frames piped — quit; on_exit closes the pipe so ffmpeg
// flushes its trailer and we wait on it there.
app.quit();
return;
}
let t = *frame as f32 / RENDER_FPS;
*frame += 1;
(tl.at(t), 1.0 / RENDER_FPS)
}
};
m.last = b;
// Audio-driven motion. The visual's own update grows/morphs/restructures.
m.visual.update(&b, dt);
m.ca_env += (b.flux - m.ca_env) * if b.flux > m.ca_env { 0.55 } else { 0.10 };
let pal = Palette::from_audio(&b);
let fit = Post::res() / W;
let scale = 1.0 + (b.low * m.g.low).min(0.9) + b.low_on * 0.4;
let warp = m.g.warp * (5.0 + 24.0 * b.mid + 13.0 * b.low);
let ca_px = (m.g.ca * m.ca_env).min(28.0);
let window = app.main_window();
let device = window.device();
let queue = window.queue();
if m.visual.is_gpu() {
// Breakcore renders through its own raymarch pipeline; no Draw/Post.
m.visual
.render_gpu(device, queue, &pal, scale, warp, m.g.feedback, m.g.fade, ca_px);
} else {
// Build the scene off-screen, then push it through the feedback chain.
let scene = Draw::new();
if ca_px > 0.4 {
// Per-channel offset passes, summed additively -> RGB split fringe.
let add = scene.color_blend(ADDITIVE);
for (tint, dx) in [
([1.0, 0.0, 0.0], -ca_px),
([0.0, 1.0, 0.0], 0.0),
([0.0, 0.0, 1.0], ca_px),
] {
let d = add.xy(vec2(dx, 0.0));
m.visual
.draw(&d, &pal, fit, scale, warp, m.g.glow, m.g.seg, tint);
}
} else {
m.visual
.draw(&scene, &pal, fit, scale, warp, m.g.glow, m.g.seg, [1.0; 3]);
}
if m.g.feedback {
m.post
.render(device, queue, &scene, pal.bg(), m.g.fade, m.g.zoom);
} else {
m.post.render_direct(device, queue, &scene);
}
}
// Offline: read this frame back synchronously and stream it into ffmpeg.
if matches!(m.mode, Mode::Render { .. }) {
let cap = if m.visual.is_gpu() {
m.visual.capture_raw(device, queue)
} else {
m.post.capture_raw(device, queue)
};
match cap {
Ok(buf) => {
if let Mode::Render { stdin, .. } = &mut m.mode {
if let Some(si) = stdin.as_mut() {
if let Err(e) = si.write_all(&buf) {
eprintln!("ffmpeg pipe broke ({e}); finalising early");
*stdin = None;
app.quit();
}
}
}
}
Err(e) => eprintln!("frame capture failed: {e}"),
}
}
}
fn view(app: &App, m: &Model, frame: Frame) {
let win = app.window_rect();
let bg = match &m.mode {
Mode::Render { tl, frame: f, .. } => Palette::from_audio(&tl.at(*f as f32 / RENDER_FPS)),
_ => Palette::from_audio(&m.last),
}
.bg();
let d = app.draw();
d.background().color(srgb(bg[0], bg[1], bg[2]));
// Present the accumulator over the field, downsampled to the window
// (cheap AA). It is a bounded convex composite, so a plain over-draw is
// correct; transparent regions (direct mode) fall through to bg.
// Breakcore presents its own raymarch target instead of the Post chain.
let tex = if m.visual.is_gpu() {
m.visual.current_tex()
} else {
m.post.current()
};
d.texture(tex).w_h(win.w(), win.h());
// Subtle vignette: concentric edge-stroked rects darkening outward.
for i in 0..6 {
let inset = i as f32 * 26.0;
let a = (i as f32 / 6.0).powi(2) * 0.5;
d.rect()
.x_y(0.0, 0.0)
.w_h(win.w() - inset, win.h() - inset)
.no_fill()
.stroke_weight(28.0)
.stroke(srgba(bg[0], bg[1], bg[2], a));
}
if m.hud {
let g = m.g;
let extra = match &m.mode {
Mode::Render { frame: f, total, .. } => {
format!("RENDER {}/{}", f, total)
}
Mode::Live(_) => format!("fps {:.0}", app.fps()),
};
let txt = format!(
"{} · seed {:#x} · n {}\nlow {:.2} warp {:.2} fade {:.2} bloom {:.3} ca {:.0} seg {}\nglow {} feedback {} {}",
m.visual.name(),
m.visual.seed(),
m.visual.count(),
g.low,
g.warp,
g.fade,
g.zoom,
g.ca,
g.seg,
g.glow,
g.feedback,
extra,
);
d.text(&txt)
.font_size(13)
.left_justify()
.x_y(win.left() + 220.0, win.top() - 40.0)
.color(srgba(0.65, 0.74, 0.82, 0.8));
}
// (Render frames are captured + piped to ffmpeg in `update`, not here.)
d.to_frame(app, &frame).unwrap();
}
// Finalise a render: close the frame pipe so ffmpeg flushes its trailer,
// then wait for it. (For Live this is a no-op.) Reached both on normal
// completion and on an early quit, so a partial render still produces a
// playable file.
fn on_exit(_app: &App, m: Model) {
let Mode::Render {
mut ff, stdin, out, ..
} = m.mode
else {
return;
};
drop(stdin); // EOF on ffmpeg's stdin -> it encodes the tail and exits
eprintln!("finalising {} ...", out.display());
match ff.wait() {
Ok(s) if s.success() => eprintln!("done: {}", out.display()),
Ok(s) => eprintln!("ffmpeg exited {s}; {} may be incomplete", out.display()),
Err(e) => eprintln!("waiting on ffmpeg failed: {e}"),
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Shared crate so both bins (`audio-visualizer` probe, `sigil`) consume the
//! same capture+FFT pipeline, and the visualiser modules are reusable/testable.
pub mod audio;
pub mod viz;
+59
View File
@@ -0,0 +1,59 @@
// Probe: console band meter. Thin client of the shared pipeline.
//
// cargo run --bin audio-visualizer -- [<index> | monitor | loopback | <file>]
// cargo run --bin audio-visualizer -- --list
//
// Levels are AGC-normalised 0..1. `*` flashes on a band onset (transient).
// Ctrl-C terminates (probe tool, no graceful handler).
use std::io::Write;
use std::thread;
use std::time::Duration;
use audio_visualizer::audio::{self, Source};
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--list") {
return audio::print_devices();
}
audio::print_devices()?;
let src = match args.get(1).map(|s| s.as_str()) {
None => Source::Capture(None),
Some("monitor") => Source::CaptureNamed("monitor".into()),
Some("loopback") => Source::CaptureNamed("loopback".into()),
Some(a) => match a.parse::<usize>() {
Ok(i) => Source::Capture(Some(i)),
Err(_) => Source::File(a.into()),
},
};
let mut handle = audio::start(src)?;
eprintln!("(capture: route the sink monitor in via pavucontrol Recording tab)\n");
loop {
let b = handle.bands();
print!(
"\rLOW {}{} MID {}{} HIGH {}{} ",
bar(b.low),
hit(b.low_on),
bar(b.mid),
hit(b.mid_on),
bar(b.high),
hit(b.high_on),
);
let _ = std::io::stdout().flush();
thread::sleep(Duration::from_millis(16));
}
}
// 20-cell linear meter on already-normalised input.
fn bar(v: f32) -> String {
let fill = (v.clamp(0.0, 1.0) * 20.0) as usize;
format!("[{}{}]", "#".repeat(fill), " ".repeat(20 - fill))
}
fn hit(on: f32) -> &'static str {
if on > 0.5 { "*" } else { " " }
}
+583
View File
@@ -0,0 +1,583 @@
//! breakcore — chaotic-IDM energy on a smooth, dark cybersigil.
//!
//! Premise → implementation map:
//! §1 geometry : a Lorenz/Rössler strange attractor (chaotic break
//! sections) cross-faded with a distorted parametric
//! torus-knot (held sections), both sampled into `NP`
//! capsule control points.
//! §2 audio : derived entirely from [`Bands`] (low/mid/high split,
//! spectral-flux onsets, centroid, loudness) — `audio.rs`
//! already does the FFT; this never touches it.
//! §3 smoothing: every reactive scalar is a critically-damped [`Spring`];
//! audio sets the *target*, never the value directly, so a
//! kick snaps out and glides back.
//! §4 render : a hand-written wgpu raymarcher (`breakcore.wgsl`) — an
//! SDF capsule chain unioned with a polynomial smooth-min so
//! folds melt, accumulated as volumetric glow over black.
//! §5 sections : long- vs short-term loudness EMAs; a spike past threshold
//! (cooldown-gated) flips attractor⇄knot and reseeds, the
//! two point-sets cross-faded over ~2.6 s.
//!
//! This is the one module that owns a hand-written wgpu pipeline; `post.rs`
//! and the other visualisers stay on nannou's validated renderer. It is *not*
//! a `Draw`-based `Visual`: the bin renders it through [`Breakcore::render`]
//! and presents/captures its target texture directly.
//!
//! Determinism: `Rng` and all integration advance only in [`Breakcore::update`]
//! (one call per frame, live and `--render` alike); the shader is a pure
//! function of the uniform block + hash(fragCoord, frame). So `--render` stays
//! bit-reproducible and there is no per-frame chaos.
use crate::audio::Bands;
use crate::viz::curve::{Rng, fbm};
use crate::viz::palette::Palette;
use crate::viz::post::read_texture_rgba;
use nannou::prelude::*;
use nannou::wgpu;
/// Capsule control points. **MUST** equal the `array<vec4, N>` size and the
/// loop bound in `breakcore.wgsl` (flat-f32 UBO layout depends on it). Kept
/// low: shader cost is O(pixels · march_steps · NP).
pub const NP: usize = 64;
/// UBO length in f32: 6 std140 rows (24) + NP·vec4.
const UBO_LEN: usize = 24 + 4 * NP;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
fn smoothstep(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// 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;
}
}
/// Which geometry a section shows.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Attractor,
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 = std::f32::consts::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;
}
}
pub struct Breakcore {
pub seed: u64,
rng: Rng,
attr: Attr,
knot: Knot,
trail: [Vec3; NP], // rolling attractor trajectory (oldest..newest)
head: Vec3, // current attractor state
from: Kind,
to: Kind,
morph: f32, // 0..1 from→to (1 = settled)
lte: f32, // long-term loudness EMA
ste: f32, // short-term loudness EMA
cooldown: f32,
idle: f32,
sp_scale: Spring,
sp_tube: Spring,
sp_dist: Spring,
sp_glow: Spring,
yaw: f32,
pitch: f32,
roll: f32,
t: f32,
frame: u32,
b: Bands,
gpu: Gpu,
}
impl Breakcore {
pub fn new(seed: u64, device: &wgpu::Device) -> Self {
let mut rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
let attr = Attr::random(&mut rng);
let knot = Knot::random(&mut rng);
Breakcore {
seed,
rng,
attr,
knot,
trail: [Vec3::ZERO; NP],
head: vec3(0.1, 0.0, 0.0),
from: Kind::Knot,
to: Kind::Knot,
morph: 1.0,
lte: 0.0,
ste: 0.0,
cooldown: 0.0,
idle: 0.0,
sp_scale: Spring { x: 1.0, v: 0.0 },
sp_tube: Spring { x: 0.022, v: 0.0 },
sp_dist: Spring { x: 3.2, v: 0.0 },
sp_glow: Spring { x: 0.8, v: 0.0 },
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
t: 0.0,
frame: 0,
b: Bands::default(),
gpu: Gpu::new(device),
}
}
pub fn reseed(&mut self, seed: u64) {
self.seed = seed;
self.rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
self.attr = Attr::random(&mut self.rng);
self.knot = Knot::random(&mut self.rng);
self.from = Kind::Knot;
self.to = Kind::Knot;
self.morph = 1.0;
self.head = vec3(0.1, 0.0, 0.0);
self.trail = [Vec3::ZERO; NP];
self.idle = 0.0;
}
pub fn point_count(&self) -> usize {
NP
}
/// Begin a section change: flip kind, reseed both configs, restart morph.
fn restructure(&mut self) {
self.from = self.to;
self.to = match self.to {
Kind::Attractor => Kind::Knot,
Kind::Knot => Kind::Attractor,
};
self.attr = Attr::random(&mut self.rng);
self.knot = Knot::random(&mut self.rng);
self.morph = 0.0;
self.cooldown = 2.0;
self.idle = 0.0;
}
pub fn update(&mut self, b: &Bands, dt: f32) {
let dt = dt.clamp(0.0, 0.05);
self.t += dt;
self.frame = self.frame.wrapping_add(1);
self.b = *b;
// §3 springs — audio sets targets, motion stays buttery.
self.sp_scale
.step(1.0 + 0.55 * b.low + 0.5 * b.low_on, 14.0, dt);
self.sp_tube
.step(0.016 + 0.05 * b.mid + 0.02 * b.mid_on, 11.0, dt);
self.sp_dist.step(3.4 - 0.9 * b.low, 6.0, dt); // sub → macro/FOV
self.sp_glow
.step(0.45 + 0.5 * b.loud + 0.4 * b.flux, 9.0, dt);
// Smooth music-locked rotation (no random snaps).
self.yaw += (0.12 + 0.7 * b.mid) * dt;
self.pitch += (0.05 + 0.4 * b.low) * dt + 0.02 * dt;
self.roll += 0.035 * dt + 0.35 * b.high * dt;
// §1 attractor: RK4 substeps, integration speed tracks sub/bass so the
// thread surges on heavy lows. Push the rolling trajectory.
let speed = 0.45 + 1.4 * b.low + 0.5 * b.low_on;
let h = (speed * dt).clamp(0.0, 0.03);
for _ in 0..6 {
self.head = self.attr.rk4(self.head, h);
if !self.head.is_finite() {
self.head = vec3(0.1, 0.0, 0.0);
}
}
self.trail.copy_within(1..NP, 0);
self.trail[NP - 1] = self.head;
// §5 section state machine: long vs short loudness EMAs.
let a_l = 1.0 - (-dt / 8.0).exp();
let a_s = 1.0 - (-dt / 0.22).exp();
self.lte += (b.loud - self.lte) * a_l;
self.ste += (b.loud - self.ste) * a_s;
let ratio = self.ste / (self.lte + 1e-3);
if self.morph < 1.0 {
self.morph = (self.morph + dt / 2.6).min(1.0);
if self.morph >= 1.0 {
self.from = self.to;
}
}
self.cooldown = (self.cooldown - dt).max(0.0);
self.idle += dt;
let drop = ratio > 1.8 && b.flux > 0.55;
if self.morph >= 1.0 && self.cooldown <= 0.0 && (drop || self.idle > 14.0) {
self.restructure();
}
}
/// Sample the active geometry into `NP` control points (xyz + radius).
fn build_points(&self) -> [[f32; 4]; NP] {
let knot = {
let mut v = [Vec3::ZERO; NP];
for (i, slot) in v.iter_mut().enumerate() {
let u = i as f32 / NP as f32;
let mut p = self.knot.at(u);
// §2 highs → high-frequency displacement (jagged sigil edge).
let s = self.seed as u32;
let n = vec3(
fbm(vec2(u * 23.0, 1.0), s),
fbm(vec2(u * 23.0, 5.0), s ^ 0x9E37),
fbm(vec2(u * 23.0, 9.0), s ^ 0x85EB),
);
p += n * (0.05 + 0.6 * self.b.high + 0.5 * self.b.high_on);
*slot = p;
}
normalize(&mut v);
v
};
let attr = {
let mut v = self.trail;
normalize(&mut v);
v
};
let pick = |k: Kind| -> &[Vec3; NP] {
match k {
Kind::Attractor => &attr,
Kind::Knot => &knot,
}
};
let e = smoothstep(self.morph);
let from = pick(self.from);
let to = pick(self.to);
let scale = self.sp_scale.x.clamp(0.4, 2.4);
let mut out = [[0.0f32; 4]; NP];
for i in 0..NP {
let p = (from[i] + (to[i] - from[i]) * e) * scale;
// Radius: tube spring + per-point energy bump from the spectrum.
let band = self.b.spec[(i * crate::audio::SPEC_N) / NP];
let r = (self.sp_tube.x * (0.5 + 0.8 * band)).clamp(0.003, 0.022);
out[i] = [p.x, p.y, p.z, r];
}
out
}
/// Render this frame's raymarch into the target and return it. Mirrors the
/// other modes' tunables: `scale`/`warp` come from the live gain keys,
/// `fade` is the phosphor persistence, `ca_px` the aberration amount.
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
pal: &Palette,
scale: f32,
warp: f32,
feedback: bool,
fade: f32,
ca_px: f32,
) -> &wgpu::Texture {
let pts = self.build_points();
let base = pal.bone(0.0);
let acc = pal.stroke(1.0, 0.85, 0.0);
let mut u = [0.0f32; UBO_LEN];
// row0 cam
u[0] = self.yaw;
u[1] = self.pitch;
u[2] = self.roll;
u[3] = self.sp_dist.x.clamp(2.0, 6.0) * (1.0 + 0.05 * (1.0 - scale));
// row1 scale,tube,glow,ca
u[4] = scale;
u[5] = self.sp_tube.x;
u[6] = self.sp_glow.x.clamp(0.35, 1.4); // closest-approach glow ≤1.4
u[7] = ca_px;
// row2 base.rgb, fade
u[8] = base[0];
u[9] = base[1];
u[10] = base[2];
u[11] = fade.clamp(0.0, 1.0);
// row3 accent.rgb, flash
u[12] = acc[0];
u[13] = acc[1];
u[14] = acc[2];
u[15] = pal.flash;
// row4 res, frame, n_pts, time
u[16] = Gpu::RES as f32;
u[17] = (self.frame & 0xffff) as f32;
u[18] = NP as f32;
u[19] = self.t;
// row5 march_steps, melt_k, feedback_on, world_r
// Steps are also hard-capped at 40 in the shader; keep this modest —
// cost is O(pixels · steps · NP) and a runaway here is a GPU hang.
u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, 40.0);
u[21] = (0.015 + 0.03 * self.b.loud + 0.02 * self.b.flux).clamp(0.01, 0.05);
// first frame has no valid history; gate it like Post::primed
u[22] = if feedback && self.gpu.primed { 1.0 } else { 0.0 };
// bounding-sphere radius: normalized curve (0.92·scale) + max tube.
u[23] = 0.92 * self.sp_scale.x.clamp(0.4, 2.4) + 0.14;
// points
for (i, p) in pts.iter().enumerate() {
let o = 24 + 4 * i;
u[o] = p[0];
u[o + 1] = p[1];
u[o + 2] = p[2];
u[o + 3] = p[3];
}
self.gpu.render(device, queue, &u)
}
pub fn current(&self) -> &wgpu::Texture {
self.gpu.current()
}
pub fn capture_raw(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
) -> anyhow::Result<Vec<u8>> {
read_texture_rgba(device, queue, self.gpu.current())
}
}
// ---------------------------------------------------------------------------
// Isolated wgpu raymarch pipeline. The deliberate exception to the codebase's
// "no hand-written wgpu pipelines" rule; everything risky is contained here.
// ---------------------------------------------------------------------------
struct Gpu {
pipeline: wgpu::RenderPipeline,
ubo: wgpu::Buffer,
tex: [wgpu::Texture; 2],
view: [wgpu::TextureViewHandle; 2],
bind: [wgpu::BindGroup; 2], // bind[w]: writes view[w], samples view[1-w]
cur: usize, // index last written / presented
primed: bool,
}
fn as_bytes(v: &[f32]) -> &[u8] {
// A flat f32 slice has no padding; reinterpret as bytes for write_buffer.
unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
}
impl Gpu {
const RES: u32 = crate::viz::post::RES;
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("breakcore-shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("breakcore.wgsl").into()),
});
let mk = || {
wgpu::TextureBuilder::new()
.size([Self::RES, Self::RES])
.format(FMT)
.usage(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
)
.build(device)
};
let tex = [mk(), mk()];
let view = [
tex[0].create_view(&wgpu::TextureViewDescriptor::default()),
tex[1].create_view(&wgpu::TextureViewDescriptor::default()),
];
// Bindings (order = WGSL @binding 0/1/2): uniform, prev texture, sampler.
let bgl = wgpu::BindGroupLayoutBuilder::new()
.uniform_buffer(wgpu::ShaderStages::FRAGMENT, false)
.texture_from(wgpu::ShaderStages::FRAGMENT, &tex[0])
.sampler(wgpu::ShaderStages::FRAGMENT, true)
.build(device);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("breakcore-pl"),
bind_group_layouts: &[&bgl],
push_constant_ranges: &[],
});
// Fullscreen triangle: no vertex buffers; default triangle-list.
let pipeline = wgpu::RenderPipelineBuilder::from_layout(&layout, &shader)
.vertex_entry_point("vs_main")
.fragment_shader(&shader)
.fragment_entry_point("fs_main")
.color_format(FMT)
.build(device);
let ubo = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("breakcore-ubo"),
size: (UBO_LEN * 4) as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sampler = wgpu::SamplerBuilder::new()
.address_mode(wgpu::AddressMode::ClampToEdge)
.mag_filter(wgpu::FilterMode::Linear)
.min_filter(wgpu::FilterMode::Linear)
.mipmap_filter(wgpu::FilterMode::Nearest)
.build(device);
let mk_bind = |w: usize| {
wgpu::BindGroupBuilder::new()
.binding(ubo.as_entire_binding())
.texture_view(&view[1 - w])
.sampler(&sampler)
.build(device, &bgl)
};
let bind = [mk_bind(0), mk_bind(1)];
Gpu {
pipeline,
ubo,
tex,
view,
bind,
cur: 0,
primed: false,
}
}
fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
ubo: &[f32; UBO_LEN],
) -> &wgpu::Texture {
let w = 1 - self.cur; // write target; sample the last-written (self.cur)
queue.write_buffer(&self.ubo, 0, as_bytes(ubo));
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("breakcore-enc"),
});
{
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("breakcore-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view[w],
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: true,
},
})],
depth_stencil_attachment: None,
});
rp.set_pipeline(&self.pipeline);
rp.set_bind_group(0, &self.bind[w], &[]);
rp.draw(0..3, 0..1);
}
queue.submit(Some(enc.finish()));
self.cur = w;
self.primed = true;
&self.tex[self.cur]
}
fn current(&self) -> &wgpu::Texture {
&self.tex[self.cur]
}
}
+168
View File
@@ -0,0 +1,168 @@
// breakcore raymarch — dark volumetric cybersigil.
//
// A capsule chain through `pts` (a CPU-integrated strange-attractor /
// distorted-torus-knot curve) unioned with a polynomial smin so folds melt.
// Cost is bounded hard: a ray/bounding-sphere test discards background pixels
// in ~one op, the march is sphere-traced with a low step cap, and brightness
// is a *closest-approach* falloff (not unbounded accumulation) so the field
// stays black with a crisp neon tube + soft halo — no white-out, no GPU hang.
//
// Pure function of the uniform block + hash(fragCoord, frame): no wall-clock,
// no per-pixel state — so `--render` is bit-reproducible. `NP` (64) MUST
// equal `breakcore::NP` in the Rust side; the UBO is a flat f32 layout, each
// field below is one std140 16-byte row (see Breakcore::render in breakcore.rs).
struct U {
cam: vec4<f32>, // yaw, pitch, roll, dist
p0: vec4<f32>, // scale, tube, glow_gain, ca_px
col0: vec4<f32>, // base.rgb, fade
col1: vec4<f32>, // accent.rgb, flash
p1: vec4<f32>, // res, frame, n_pts, time
p2: vec4<f32>, // march_steps, melt_k, feedback_on, world_r
pts: array<vec4<f32>, 64>, // xyz = point, w = capsule radius
};
@group(0) @binding(0) var<uniform> u: U;
@group(0) @binding(1) var prev_tex: texture_2d<f32>;
@group(0) @binding(2) var prev_smp: sampler;
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
// Fullscreen triangle (no vertex buffer): 3 verts covering clip space.
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
var o: VsOut;
let x = f32((vi << 1u) & 2u);
let y = f32(vi & 2u);
o.uv = vec2<f32>(x, y);
o.pos = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
return o;
}
fn hash21(p: vec2<f32>) -> f32 {
var q = fract(p * vec2<f32>(123.34, 345.45));
q = q + dot(q, q + 34.345);
return fract(q.x * q.y);
}
// Polynomial smooth-min (premise §4): melts intersecting curve folds.
fn smin(a: f32, b: f32, k: f32) -> f32 {
let h = max(k - abs(a - b), 0.0) / max(k, 1e-4);
return min(a, b) - h * h * k * 0.25;
}
fn sd_capsule(p: vec3<f32>, a: vec3<f32>, b: vec3<f32>, r: f32) -> f32 {
let pa = p - a;
let ba = b - a;
let t = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
return length(pa - ba * t) - r;
}
// yaw(Y) -> pitch(X) -> roll(Z), matching the scope mode's convention.
// Rigid, so it never changes distance-to-origin (bounding sphere stays valid).
fn rot(v: vec3<f32>) -> vec3<f32> {
let sy = sin(u.cam.x); let cy = cos(u.cam.x);
let sp = sin(u.cam.y); let cp = cos(u.cam.y);
let sr = sin(u.cam.z); let cr = cos(u.cam.z);
let x1 = v.x * cy - v.z * sy;
let z1 = v.x * sy + v.z * cy;
let y2 = v.y * cp - z1 * sp;
let z2 = v.y * sp + z1 * cp;
let x3 = x1 * cr - y2 * sr;
let y3 = x1 * sr + y2 * cr;
return vec3<f32>(x3, y3, z2);
}
// Scene SDF: smin-union of the capsule chain (already in rotated space).
fn map(p: vec3<f32>) -> f32 {
let n = i32(u.p1.z);
let k = u.p2.y;
var d = 1e9;
for (var i = 0; i < n - 1; i = i + 1) {
let a = u.pts[i];
let c = u.pts[i + 1];
let r = max(0.5 * (a.w + c.w), 0.004);
d = smin(d, sd_capsule(p, a.xyz, c.xyz, r), k);
}
return d;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let res = u.p1.x;
let frame = u.p1.y;
let gain = u.p0.z;
let ca_px = u.p0.w;
let base = u.col0.xyz;
let accent = u.col1.xyz;
let flash = u.col1.w;
let rb = u.p2.w; // bounding-sphere radius (curve extent + tube)
let ndc = vec2<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
let dist = u.cam.w;
let ro = vec3<f32>(0.0, 0.0, -dist);
let rd = normalize(vec3<f32>(ndc.x, ndc.y, 1.6));
// Ray vs bounding sphere — discards every background pixel in ~one op,
// which is what keeps this from melting the GPU.
let b = dot(ro, rd);
let c = dot(ro, ro) - rb * rb;
let disc = b * b - c;
var glow = 0.0;
var depth = 0.0;
if (disc > 0.0) {
let sq = sqrt(disc);
var t = max(-b - sq, 0.0);
let t_end = -b + sq;
let span = max(t_end - t, 1e-3);
let min_step = span / 40.0; // guarantees the march finishes
let steps = min(i32(u.p2.x), 40);
var dmin = 1e9;
for (var s = 0; s < steps; s = s + 1) {
let d = map(rot(ro + rd * t));
dmin = min(dmin, d);
if (d < 0.004) {
dmin = 0.0;
depth = clamp((t + b) / max(2.0 * sq, 1e-3), 0.0, 1.0);
break;
}
t = t + max(d * 0.85, min_step);
if (t > t_end) { break; }
}
// Closest-approach falloff. A near-Gaussian core gives a *thin*
// filament; a small, fast-decaying halo is the only volumetric
// spill. Everything more than ~0.1 from the curve is pure black —
// that's what kills the wash. Bounded in [0, ~1.1].
let core = exp(-dmin * dmin * 900.0);
let halo = 0.22 * exp(-dmin * 24.0);
glow = clamp((core + halo) * gain, 0.0, 1.2);
}
// Colour: a saturated, fairly dark hue carries the line; luminance is the
// glow alone, so off-line pixels are black, not grey haze.
var col = mix(base, accent, depth) * (0.45 + 0.55 * depth) * glow;
col = col + accent * flash * glow * glow * 0.4;
// Faint grain so the black field is alive (very low amplitude).
col = max(col + (hash21(in.uv * res + vec2<f32>(frame, frame * 1.7)) - 0.5) * 0.006,
vec3<f32>(0.0));
// Phosphor persistence: a *decaying* trail via max() — can never brighten
// past the fresh frame, so no additive runaway to white. Cheap radial
// chromatic aberration on the trail term only.
if (u.p2.z > 0.5) {
// Shorter than Post's trail: a long phosphor tail on a fat glow reads
// as smear/wash. fade 0.11 -> ~0.67 decay (~a dozen frames).
let decay = clamp(1.0 - 3.0 * u.col0.w, 0.30, 0.90);
let off = (in.uv - vec2<f32>(0.5)) * (ca_px / max(res, 1.0));
let pr = textureSample(prev_tex, prev_smp, in.uv + off).r;
let pg = textureSample(prev_tex, prev_smp, in.uv).g;
let pb = textureSample(prev_tex, prev_smp, in.uv - off).b;
col = max(col, vec3<f32>(pr, pg, pb) * decay);
}
return vec4<f32>(min(col, vec3<f32>(1.0)), 1.0);
}
+132
View File
@@ -0,0 +1,132 @@
//! Geometry helpers: Catmull-Rom smoothing + hand-rolled gradient noise.
//!
//! The sigil stores sparse *control* points; everything is rendered as a
//! Catmull-Rom spline so straight skeleton walks read as flowing curves. A
//! small fBm gradient-noise field domain-warps the sampled points so the whole
//! figure breathes and morphs instead of rigidly transforming.
use nannou::prelude::*;
/// xorshift64* — deterministic, no rng-crate global state. Shared by the
/// visual modules so live and offline renders are bit-identical per seed.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
Rng(seed | 1)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
pub fn unit(&mut self) -> f32 {
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
}
pub fn range(&mut self, a: f32, b: f32) -> f32 {
a + (b - a) * self.unit()
}
pub fn idx(&mut self, n: usize) -> usize {
(self.next_u64() as usize) % n.max(1)
}
pub fn chance(&mut self, p: f32) -> bool {
self.unit() < p
}
}
pub fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Centripetal-ish Catmull-Rom: sample `seg` points between each control pair.
/// Endpoints are duplicated so the curve passes through the first/last point.
pub fn catmull_rom(ctrl: &[Vec2], seg: usize) -> Vec<Vec2> {
if ctrl.len() < 3 {
return ctrl.to_vec();
}
let n = ctrl.len();
let pt = |i: i32| ctrl[i.clamp(0, n as i32 - 1) as usize];
let mut out = Vec::with_capacity(n * seg);
for i in 0..n - 1 {
let p0 = pt(i as i32 - 1);
let p1 = pt(i as i32);
let p2 = pt(i as i32 + 1);
let p3 = pt(i as i32 + 2);
for s in 0..seg {
let t = s as f32 / seg as f32;
let t2 = t * t;
let t3 = t2 * t;
// Standard Catmull-Rom basis (tension 0.5).
let a = p1 * 2.0;
let b = (p2 - p0) * t;
let c = (p0 * 2.0 - p1 * 5.0 + p2 * 4.0 - p3) * t2;
let d = (-p0 + p1 * 3.0 - p2 * 3.0 + p3) * t3;
out.push((a + b + c + d) * 0.5);
}
}
out.push(ctrl[n - 1]);
out
}
// --- gradient (Perlin-style) noise, 2D, hash-based, no external crate -------
fn hash2(mut x: u32) -> f32 {
x ^= x >> 16;
x = x.wrapping_mul(0x7feb_352d);
x ^= x >> 15;
x = x.wrapping_mul(0x846c_a68b);
x ^= x >> 16;
x as f32 / u32::MAX as f32
}
fn grad(ix: i32, iy: i32, seed: u32) -> Vec2 {
let h = (ix as u32)
.wrapping_mul(0x9E37_79B1)
^ (iy as u32).wrapping_mul(0x85EB_CA77)
^ seed.wrapping_mul(0xC2B2_AE3D);
let a = hash2(h) * std::f32::consts::TAU;
vec2(a.cos(), a.sin())
}
/// Perlin gradient noise in roughly -1..1.
pub fn noise2(p: Vec2, seed: u32) -> f32 {
let xi = p.x.floor() as i32;
let yi = p.y.floor() as i32;
let fx = p.x - xi as f32;
let fy = p.y - yi as f32;
let u = fx * fx * (3.0 - 2.0 * fx);
let v = fy * fy * (3.0 - 2.0 * fy);
let n = |cx: i32, cy: i32| {
let g = grad(cx, cy, seed);
g.dot(vec2(p.x - cx as f32, p.y - cy as f32))
};
let x1 = n(xi, yi) * (1.0 - u) + n(xi + 1, yi) * u;
let x2 = n(xi, yi + 1) * (1.0 - u) + n(xi + 1, yi + 1) * u;
(x1 * (1.0 - v) + x2 * v) * 1.4
}
/// Fractal sum of [`noise2`] — 3 octaves, ~ -1..1.
pub fn fbm(p: Vec2, seed: u32) -> f32 {
let mut a = 0.5;
let mut f = 1.0;
let mut sum = 0.0;
for o in 0..3 {
sum += a * noise2(p * f, seed.wrapping_add(o * 1013));
f *= 2.03;
a *= 0.5;
}
sum
}
/// Curl-ish 2D warp vector from the fBm field (divergence-free-ish flow).
pub fn flow(p: Vec2, t: f32, seed: u32) -> Vec2 {
let e = 0.15;
let q = p * 0.004 + vec2(t * 0.05, -t * 0.04);
let n1 = fbm(q, seed);
let n2 = fbm(q + vec2(e, 0.0), seed);
let n3 = fbm(q + vec2(0.0, e), seed);
vec2(n3 - n1, -(n2 - n1)) / e
}
+12
View File
@@ -0,0 +1,12 @@
//! Visual layer: organic curve geometry, audio-driven OKLCH colour, the
//! living hybrid cyber-organic sigil, and the feedback/bloom post stack.
//!
//! `breakcore` is the one module that owns a hand-written wgpu raymarch
//! pipeline; `post` and everything else stay on nannou's validated renderer.
pub mod breakcore;
pub mod curve;
pub mod palette;
pub mod post;
pub mod scope;
pub mod sigil;
+95
View File
@@ -0,0 +1,95 @@
//! Audio-driven colour. Perceptual OKLCH so hue sweeps stay even in
//! brightness/chroma; converted to gamma sRGB for nannou's `srgba`.
//!
//! centroid -> base hue (brightness of the mix picks the colour)
//! dominant chroma pitch class -> accent hue rotation (harmony tints the edges)
//! loudness -> lightness · broadband flux -> a chroma/lightness flash
use crate::audio::Bands;
fn srgb_encode(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.0031308 {
12.92 * c
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
/// OKLCH (L 0..1, C ~0..0.4, H radians) -> gamma sRGB `[r,g,b]`.
pub fn oklch(l: f32, c: f32, h: f32) -> [f32; 3] {
let a = c * h.cos();
let b = c * h.sin();
let l_ = l + 0.3963377774 * a + 0.2158037573 * b;
let m_ = l - 0.1055613458 * a - 0.0638541728 * b;
let s_ = l - 0.0894841775 * a - 1.2914855480 * b;
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
let r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
let bl = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
[srgb_encode(r), srgb_encode(g), srgb_encode(bl)]
}
/// A momentary colour state derived from one analysis frame.
#[derive(Clone, Copy)]
pub struct Palette {
base_h: f32, // radians
accent_h: f32, // radians
light: f32,
chroma: f32,
pub flash: f32, // 0..1 broadband onset, used by post too
}
const TAU: f32 = std::f32::consts::TAU;
impl Palette {
pub fn from_audio(b: &Bands) -> Self {
// Centroid sweeps a deep-violet -> cyan -> warm-gold arc (~250°..30°).
let base_h = (4.4 - b.centroid * 3.1) % TAU;
// Dominant pitch class rotates an accent around the wheel.
let (mut dom, mut dv) = (0usize, 0.0f32);
for (i, &c) in b.chroma.iter().enumerate() {
if c > dv {
dv = c;
dom = i;
}
}
let accent_h = base_h + 1.7 + dom as f32 / 12.0 * TAU * 0.5;
let light = 0.42 + b.loud * 0.34;
let chroma = 0.10 + (b.loud * 0.5 + b.mid * 0.4).min(1.0) * 0.13;
Palette {
base_h,
accent_h,
light,
chroma,
flash: b.flux,
}
}
/// Colour along a strand. `t` 0..1 root->tip blends base->accent hue and
/// fades lightness toward the tip; `vigor` 0..1 scales presence.
/// Returns gamma-sRGB `[r,g,b,a]`.
pub fn stroke(&self, t: f32, vigor: f32, hue_off: f32) -> [f32; 4] {
let h = self.base_h + (self.accent_h - self.base_h) * t + hue_off;
let l = (self.light + 0.18 * (1.0 - t) + self.flash * 0.25).min(0.97);
let c = self.chroma * (0.55 + 0.45 * vigor) + self.flash * 0.04;
let [r, g, bl] = oklch(l, c, h);
let a = (0.30 + 0.70 * vigor) * (0.55 + 0.45 * (1.0 - t * 0.6));
[r, g, bl, a.clamp(0.0, 1.0)]
}
/// Bright structural colour for the cyber skeleton (less hue travel,
/// higher lightness so the bones stay legible under the overgrowth).
pub fn bone(&self, t: f32) -> [f32; 4] {
let h = self.base_h + 0.25 * t;
let l = (self.light + 0.30 + self.flash * 0.2).min(0.99);
let [r, g, bl] = oklch(l, self.chroma * 0.7, h);
[r, g, bl, 0.92]
}
/// Dim background field tint (very low lightness, base hue).
pub fn bg(&self) -> [f32; 3] {
let [r, g, bl] = oklch(0.06 + self.flash * 0.02, 0.03, self.base_h);
[r, g, bl]
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Frame-feedback + bloom post stack, built only from nannou's own validated
//! Draw + offscreen renderer (no hand-written render pipelines).
//!
//! Per frame, at a fixed internal resolution (super-sampled for cheap AA):
//! 1. the sigil is rendered into `scene`;
//! 2. a composite pass writes `acc_next = fade(acc_prev) + ADD(scene)` — the
//! previous accumulator, dimmed toward the background by drawing a
//! translucent rect over it (nannou textures can't be tinted, so decay is
//! done this way), with the fresh scene added on top. A slight zoom on the
//! fed-back copy makes trails bloom outward instead of just smearing.
//! 3. the bin presents the accumulator to the window (downsampled → AA) and
//! can capture it for the offline video.
//!
//! Chromatic aberration is done at draw time in the bin (per-channel offset
//! passes) because nannou's texture primitive ignores vertex colour.
use nannou::draw::{Renderer, RendererBuilder};
use nannou::prelude::*;
use nannou::wgpu;
/// Internal render resolution (square; super-sampled vs. the 960² design).
pub const RES: u32 = 1440;
// sRGB8 so a frame can be read straight back to a PNG without HDR/f16
// conversion. Blending on an sRGB target is done in linear space by the GPU,
// so additive bloom still behaves.
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
/// Plain additive blend (`dst + src`). nannou's `blend::ADD` is
/// `src·src + dst·dst`, which is *not* what we want for HDR accumulation.
pub const ADDITIVE: wgpu::BlendComponent = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
};
/// Fully-transparent clear so render-to-texture starts each pass blank
/// (nannou loads existing contents when a Draw has no background).
fn clear() -> nannou::color::Srgba<f32> {
srgba(0.0, 0.0, 0.0, 0.0)
}
pub struct Post {
renderer: Renderer,
scene: wgpu::Texture,
acc: [wgpu::Texture; 2],
cur: usize,
primed: bool, // false until the accumulator holds real (non-garbage) data
}
fn make_tex(device: &wgpu::Device) -> wgpu::Texture {
wgpu::TextureBuilder::new()
.size([RES, RES])
.format(FMT)
.usage(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
)
.build(device)
}
impl Post {
pub fn res() -> f32 {
RES as f32
}
pub fn new(device: &wgpu::Device) -> Self {
let renderer = RendererBuilder::new().build(device, [RES, RES], 1.0, 1, FMT);
Post {
renderer,
scene: make_tex(device),
acc: [make_tex(device), make_tex(device)],
cur: 0,
primed: false,
}
}
/// Render `scene_draw` through the feedback chain. `bg` is the field
/// colour the trails decay toward, `fade` the per-frame decay (0 = endless
/// trails, 1 = none), `zoom` the feedback bloom expansion (~1.004).
/// Returns the texture to present this frame.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
scene_draw: &Draw,
bg: [f32; 3],
fade: f32,
zoom: f32,
) -> &wgpu::Texture {
let prev = self.cur;
let next = 1 - self.cur;
let s = RES as f32;
// Pass 1: sigil -> scene texture (cleared transparent first).
scene_draw.background().color(clear());
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("sigil-post"),
});
self.renderer
.render_to_texture(device, &mut enc, scene_draw, &self.scene);
// Pass 2: composite -> acc[next]. Every step is a convex over-blend so
// the buffer can never exceed 1.0 (no additive runaway to white):
// clear
// -> previous accumulator, slightly zoomed (trail, opaque)
// -> bg rect at alpha=fade (decays trail toward bg)
// -> fresh scene composited over (new figure on trails)
// Trail length ~ 1/fade frames; bloom comes from the zoom spread plus
// the per-stroke faux-glow halos, not from unbounded accumulation.
let comp = Draw::new();
if self.primed {
comp.background().color(clear());
comp.texture(&self.acc[prev]).w_h(s * zoom, s * zoom);
comp.rect()
.w_h(s, s)
.color(srgba(bg[0], bg[1], bg[2], fade.clamp(0.0, 1.0)));
} else {
// First frame: no valid history yet — start from the bg field
// instead of the texture's uninitialised garbage.
comp.background().color(srgba(bg[0], bg[1], bg[2], 1.0));
self.primed = true;
}
comp.texture(&self.scene).w_h(s, s);
self.renderer
.render_to_texture(device, &mut enc, &comp, &self.acc[next]);
queue.submit(Some(enc.finish()));
self.cur = next;
&self.acc[self.cur]
}
/// Most recent accumulator (for the no-feedback bypass / direct present).
pub fn current(&self) -> &wgpu::Texture {
&self.acc[self.cur]
}
/// Synchronously read the current accumulator back to the CPU as tightly
/// packed RGBA8. The per-frame source for both the PNG path and the
/// streaming-to-ffmpeg render path. See [`read_texture_rgba`].
pub fn capture_raw(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
) -> anyhow::Result<Vec<u8>> {
read_texture_rgba(device, queue, &self.acc[self.cur])
}
/// Read the current accumulator back and write it as a PNG (manual `P`
/// screenshot). Thin wrapper over [`Self::capture_raw`].
pub fn capture_png(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
path: &std::path::Path,
) -> anyhow::Result<()> {
let pixels = self.capture_raw(device, queue)?;
let img = nannou::image::RgbaImage::from_raw(RES, RES, pixels)
.ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))?;
img.save(path)?;
Ok(())
}
/// Render `scene_draw` straight into the accumulator (feedback bypass).
pub fn render_direct(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
scene_draw: &Draw,
) -> &wgpu::Texture {
let next = 1 - self.cur;
scene_draw.background().color(clear());
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("sigil-direct"),
});
self.renderer
.render_to_texture(device, &mut enc, scene_draw, &self.acc[next]);
queue.submit(Some(enc.finish()));
self.cur = next;
&self.acc[self.cur]
}
}
/// Synchronously read a `RES`×`RES` `Rgba8UnormSrgb` texture (must carry
/// `COPY_SRC`) back to the CPU as tightly packed RGBA8 (`RES*RES*4` bytes, no
/// row padding). Uses an explicit `device.poll(Wait)` so the buffer map always
/// resolves — unlike nannou's async `capture_frame`, which leaks/cancels its
/// map callbacks when the app loop tears the device down. Shared by [`Post`]
/// and the `breakcore` raymarch target so the leak-safe path lives once.
pub fn read_texture_rgba(
device: &wgpu::Device,
queue: &wgpu::Queue,
tex: &wgpu::Texture,
) -> anyhow::Result<Vec<u8>> {
let (w, h) = (RES, RES);
let unpadded = w * 4; // Rgba8
let align = 256u32;
let padded = unpadded.div_ceil(align) * align;
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sigil-readback"),
size: (padded as u64) * (h as u64),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("sigil-readback"),
});
enc.copy_texture_to_buffer(
wgpu::ImageCopyTexture {
texture: &**tex, // nannou Texture -> raw wgpu::Texture
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::ImageCopyBuffer {
buffer: &buf,
layout: wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
device.poll(wgpu::Maintain::Wait);
rx.recv()
.map_err(|_| anyhow::anyhow!("map channel closed"))?
.map_err(|e| anyhow::anyhow!("buffer map failed: {e:?}"))?;
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((unpadded * h) as usize);
for row in 0..h {
let s = (row * padded) as usize;
pixels.extend_from_slice(&data[s..s + unpadded as usize]);
}
drop(data);
buf.unmap();
Ok(pixels)
}
+390
View File
@@ -0,0 +1,390 @@
//! Oscilloscope *art* — vector-display structures in the spirit of
//! oscilloscope-music visuals (Jerobeam Fenderson / sakr / OsciStudio).
//!
//! A phosphor beam traces a deterministic 3D wireframe **figure** — a torus
//! knot, a Gielis supershape, a 3D Lissajous, a harmonograph, a rose-helix —
//! whose parameters are seeded so every track/seed yields a distinct object.
//! The figure is not chaotic frame-to-frame: it holds, and *morphs* into a
//! freshly-seeded figure on a strong broadband transient (cooldown-gated, like
//! the sigil's restructure), the two point-sets lerped so the change reads as
//! the music turning a corner rather than a glitch.
//!
//! Audio drives it continuously: rotation from mid/low, a breathing scale from
//! low/loud, slow figure-parameter drift from spectral brightness, and a gentle
//! beam-noise wobble from the live waveform + flux — so it captures what is
//! playing *now* while staying a coherent shape.
//!
//! Rendering is vector-display: a faint continuous beam, brightened where the
//! beam moves slowly (the real-scope intensity trick), dithered into dots, over
//! a faint CRT grain, near-monochrome (the palette desaturated so the hue still
//! drifts with timbre). The post stack's feedback gives the phosphor decay.
//!
//! Determinism: `Rng` is only advanced in `update` (figure selection); the
//! dither/grain are pure hashes of (index, frame). `update` runs once per
//! frame, so live and `--render` stay bit-identical per seed + timeline.
use crate::audio::{Bands, WAVE_N};
use crate::viz::curve::{Rng, flow};
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).
fn h01(a: u32, b: u32) -> f32 {
let mut x = a.wrapping_mul(0x9E37_79B1) ^ b.wrapping_mul(0x85EB_CA77) ^ 0xC2B2_AE3D;
x ^= x >> 15;
x = x.wrapping_mul(0x2545_F491);
x ^= x >> 13;
(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)
}
}
}
}
pub struct Scope {
pub seed: u64,
rng: Rng,
cur: Figure,
tgt: Figure,
morph: f32, // 0..1 cur->tgt (1 = settled)
yaw: f32,
pitch: f32,
roll: f32,
breathe: f32,
restruct_cd: f32,
prev_flux: f32,
idle: f32, // seconds since last change (quiet-track fallback)
wave: [f32; WAVE_N],
loud: f32,
flux: f32,
centroid: f32,
t: f32,
}
impl Scope {
pub fn new(seed: u64) -> Self {
let mut rng = Rng::new(seed ^ 0x05C0_BE11);
let cur = Figure::random(&mut rng);
Scope {
seed,
rng,
cur,
tgt: cur,
morph: 1.0,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
breathe: 0.0,
restruct_cd: 0.0,
prev_flux: 0.0,
idle: 0.0,
wave: [0.0; WAVE_N],
loud: 0.0,
flux: 0.0,
centroid: 0.0,
t: 0.0,
}
}
pub fn reseed(&mut self, seed: u64) {
*self = Scope::new(seed);
}
pub fn point_count(&self) -> usize {
N
}
/// Begin a morph into a freshly-seeded figure.
fn restructure(&mut self) {
self.tgt = Figure::random(&mut self.rng);
self.morph = 0.0;
self.idle = 0.0;
}
pub fn update(&mut self, b: &Bands, dt: f32) {
let dt = dt.clamp(0.0, 0.05);
self.t += dt;
self.wave = b.wave;
self.loud = b.loud;
self.flux = b.flux;
self.centroid = b.centroid;
// Smooth, music-locked motion (no random snaps).
self.yaw += (0.14 + 0.85 * b.mid) * dt;
self.pitch += (0.06 + 0.45 * b.low) * dt + 0.025 * dt;
self.roll += 0.04 * dt + 0.35 * b.high * dt;
self.breathe += dt * (0.25 + 1.1 * b.mid + 0.5 * b.low);
// Advance an in-flight morph; settle onto the target.
if self.morph < 1.0 {
self.morph = (self.morph + dt / MORPH_SECS).min(1.0);
if self.morph >= 1.0 {
self.cur = self.tgt;
}
}
// Change figure on a rising broadband transient (cooldown-gated), or
// on a long idle so quiet passages still evolve.
self.restruct_cd = (self.restruct_cd - dt).max(0.0);
self.idle += dt;
let rising = b.flux > 0.6 && self.prev_flux <= 0.6;
if self.morph >= 1.0
&& self.restruct_cd <= 0.0
&& (rising || self.idle > 12.0)
{
self.restruct_cd = 1.2;
self.restructure();
}
self.prev_flux = b.flux;
}
/// Near-monochrome phosphor: keep the palette's hue drift but pull most of
/// the chroma out and lift luminance so it reads as a vector display.
fn phosphor(c: [f32; 4]) -> [f32; 4] {
let lum = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
let mix = 0.62;
[
((c[0] * (1.0 - mix) + lum * mix) * 1.18).min(1.0),
((c[1] * (1.0 - mix) + lum * mix) * 1.18).min(1.0),
((c[2] * (1.0 - mix) + lum * mix) * 1.18).min(1.0),
c[3],
]
}
#[allow(clippy::too_many_arguments)]
pub fn draw(
&self,
draw: &Draw,
pal: &Palette,
fit: f32,
scale: f32,
warp: f32,
glow: bool,
_seg: usize,
tint: [f32; 3],
) {
let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos();
let (sr, cr) = self.roll.sin_cos();
let dist = FIELD * 1.7;
let amp = FIELD * 0.40 * (0.85 + 0.30 * scale.min(1.6) + 0.10 * self.breathe.sin());
let e = smoothstep(self.morph);
// Slow figure-character drift from spectral brightness, beam-noise from
// the live waveform + flux — subtle, so the shape stays coherent.
let drift = 1.0 + 0.10 * (self.centroid - 0.5);
let beam_amp = (0.012 + 0.05 * self.flux) * warp.max(0.2);
let project = |i: usize| -> (Vec2, f32) {
let u = i as f32 / N as f32;
let a = self.cur.at(u);
let mut q = if e < 1.0 {
let bpt = self.tgt.at(u);
a + (bpt - a) * e
} else {
a
};
q *= drift * amp;
// beam-signal wobble: the actual waveform perturbs the trace
let wv = self.wave[(i * WAVE_N / N) % WAVE_N];
let nz = flow(vec2(q.x, q.y), self.t, self.seed as u32);
q.x += nz.x * amp * beam_amp + wv * amp * beam_amp * 1.5;
q.y += nz.y * amp * beam_amp;
// rotate yaw(Y) -> pitch(X) -> roll(Z)
let (x1, z1) = (q.x * cy - q.z * sy, q.x * sy + q.z * cy);
let (y2, z2) = (q.y * cp - z1 * sp, q.y * sp + z1 * cp);
let (x3, y3) = (x1 * cr - y2 * sr, x1 * sr + y2 * cr);
let f = dist / (dist + z2.max(-dist * 0.9));
(vec2(x3 * f, y3 * f) * fit, z2)
};
// Build the screen path + per-segment beam speed (for brightness).
let mut scr: Vec<Vec2> = Vec::with_capacity(N);
for i in 0..N {
scr.push(project(i).0);
}
let roll_h = self.roll.rem_euclid(std::f32::consts::TAU) / std::f32::consts::TAU;
let base = Self::phosphor(pal.stroke(0.5, (0.5 + 0.5 * self.loud).min(1.0), roll_h));
let put = |a: Vec2, c: Vec2, w: f32, col: [f32; 4]| {
draw.polyline()
.weight(w)
.points([a, c])
.color(srgba(
col[0] * tint[0],
col[1] * tint[1],
col[2] * tint[2],
col[3],
));
};
// Faint continuous beam for path continuity (phosphor base + halo).
if glow {
draw.polyline()
.weight(5.0)
.points(scr.iter().cloned())
.color(srgba(
base[0] * tint[0],
base[1] * tint[1],
base[2] * tint[2],
0.035,
));
}
draw.polyline()
.weight(1.0)
.points(scr.iter().cloned())
.color(srgba(
base[0] * tint[0],
base[1] * tint[1],
base[2] * tint[2],
0.10,
));
// Dithered beam: bright where it moves slowly (real-scope intensity),
// gated by an ordered dither so it reads as grain, not a solid line.
let fr = (self.t * 60.0) as u32;
let s32 = self.seed as u32;
for i in 1..N {
let (a, c) = (scr[i - 1], scr[i]);
let len = (c - a).length().max(1e-3);
// slow beam -> bright; fast beam -> dim (energy spreads over px)
let inten = (10.0 / (1.0 + 0.05 * len)).min(1.0);
let dith = h01(s32 ^ i as u32, fr ^ (i as u32 >> 3));
if inten < dith * 0.85 {
continue;
}
let mut col = Self::phosphor(pal.stroke(i as f32 / N as f32, 0.6 + 0.4 * self.loud, roll_h));
col[3] = (0.18 + 0.55 * inten) * (0.7 + 0.3 * self.loud);
put(a, c, 1.0 + 1.4 * inten, col);
}
// Faint CRT grain so the field is alive even between strokes.
let grain = 90 + (self.loud * 140.0) as usize;
for k in 0..grain {
let gx = (h01(s32 ^ 0x00A1 ^ k as u32, fr) - 0.5) * FIELD * fit;
let gy = (h01(s32 ^ 0x005C ^ k as u32, fr.wrapping_add(7)) - 0.5) * FIELD * fit;
draw.rect().x_y(gx, gy).w_h(1.0, 1.0).color(srgba(
base[0] * tint[0],
base[1] * tint[1],
base[2] * tint[2],
0.05,
));
}
}
}
+531
View File
@@ -0,0 +1,531 @@
//! The living hybrid cyber-organic sigil.
//!
//! A fixed *cyber skeleton* (spine + mirrored branch walks + rings + glyph
//! nodes) gives a stable occult identity. Over it crawls *organic overgrowth*:
//! tendrils that grow along the music, each bound to one log-spectrum band —
//! they extend and branch while their band is loud, wither and retract when it
//! goes quiet, and the whole population is periodically restructured by
//! broadband transients. Everything is rendered as noise-warped Catmull-Rom
//! curves, so the figure breathes and morphs rather than rigidly transforming.
use crate::audio::{Bands, SPEC_N};
use nannou::prelude::*;
use crate::viz::curve::{Rng, catmull_rom, flow, smoothstep};
use crate::viz::palette::Palette;
const FIELD: f32 = 960.0; // design-space extent (matches old W/H)
const R_MAX: f32 = FIELD * 0.475;
const SOFT_CAP: usize = 88; // tendril population the field settles toward
const MAX_NODES: usize = 30;
const TURNS: [f32; 7] = [
-PI / 3.0,
-PI / 6.0,
-PI / 12.0,
0.0,
PI / 12.0,
PI / 6.0,
PI / 3.0,
];
/// A skeleton stroke (control points, smoothed at draw time).
struct Bone {
ctrl: Vec<Vec2>,
weight: f32,
glyph: bool,
}
/// One organic overgrowth strand bound to a spectrum band.
struct Tendril {
nodes: Vec<Vec2>,
band: usize,
hue_off: f32,
curl: f32,
width: f32,
vigor: f32, // 0..1 health; drives growth, decays when band quiet
budget: f32, // accumulated growth credit
quiet: f32, // seconds the band has been quiet (-> retract)
depth: u8,
}
/// Expanding shockwave ring spawned by a big transient.
struct Ring {
r: f32,
speed: f32,
life: f32,
hue_off: f32,
}
pub struct Sigil {
pub seed: u64,
bones: Vec<Bone>,
anchors: Vec<Vec2>, // seed points for new tendrils (skeleton extremities)
tendrils: Vec<Tendril>,
rings: Vec<Ring>,
rng: Rng,
rot: f32,
breathe: f32,
restruct_cd: f32,
prev_flux: f32,
}
impl Sigil {
pub fn new(seed: u64) -> Self {
let mut s = Sigil {
seed,
bones: Vec::new(),
anchors: Vec::new(),
tendrils: Vec::new(),
rings: Vec::new(),
rng: Rng::new(seed),
rot: 0.0,
breathe: 0.0,
restruct_cd: 0.0,
prev_flux: 0.0,
};
s.build_skeleton();
s
}
pub fn reseed(&mut self, seed: u64) {
*self = Sigil::new(seed);
}
pub fn tendril_count(&self) -> usize {
self.tendrils.len()
}
// --- generation --------------------------------------------------------
fn build_skeleton(&mut self) {
let mut rng = Rng::new(self.seed ^ 0xB1FF_5EED);
let spine_h = FIELD * 0.40;
let segs = 5 + rng.idx(4);
let mut spine = Vec::with_capacity(segs + 1);
let dy = (2.0 * spine_h) / segs as f32;
let mut y = -spine_h;
for _ in 0..=segs {
spine.push(vec2(rng.range(-22.0, 22.0), y));
y += dy;
}
self.bones.push(Bone {
ctrl: spine.clone(),
weight: 3.0,
glyph: true,
});
let walks = 4 + rng.idx(4);
for _ in 0..walks {
let anchor = spine[1 + rng.idx(spine.len() - 2)];
let mut p = anchor;
let mut ang = rng.range(-PI / 2.5, PI / 2.5);
let steps = 3 + rng.idx(6);
let mut walk = vec![p];
for _ in 0..steps {
ang += TURNS[rng.idx(TURNS.len())];
let len = rng.range(30.0, 95.0);
let mut np = p + vec2(ang.cos(), ang.sin()) * len;
np.x = np.x.clamp(2.0, FIELD * 0.46);
np.y = np.y.clamp(-FIELD * 0.46, FIELD * 0.46);
walk.push(np);
p = np;
}
let tip = *walk.last().unwrap();
self.anchors.push(tip);
self.anchors.push(vec2(-tip.x, tip.y));
let w = rng.range(1.2, 2.0);
let mirror: Vec<Vec2> = walk.iter().map(|v| vec2(-v.x, v.y)).collect();
self.bones.push(Bone {
ctrl: walk,
weight: w,
glyph: false,
});
self.bones.push(Bone {
ctrl: mirror,
weight: w,
glyph: false,
});
}
// Closed ring arcs — full circles read as smooth curves.
let rings = 2 + rng.idx(2);
for _ in 0..rings {
let r = rng.range(FIELD * 0.30, R_MAX);
let mut arc = Vec::with_capacity(33);
for i in 0..=32 {
let th = TAU * i as f32 / 32.0;
arc.push(vec2(r * th.cos(), r * th.sin()));
}
self.bones.push(Bone {
ctrl: arc,
weight: rng.range(0.9, 1.6),
glyph: false,
});
}
// A few anchors on the spine itself so growth also erupts from the core.
for &p in spine.iter().skip(1).step_by(2) {
self.anchors.push(p);
}
// Seed an initial sparse population.
for _ in 0..18 {
self.spawn_from_anchor(0.4);
}
}
fn spawn_from_anchor(&mut self, vigor: f32) {
if self.anchors.is_empty() {
return;
}
let a = self.anchors[self.rng.idx(self.anchors.len())];
let band = self.rng.idx(SPEC_N);
let out = a.normalize_or_zero();
let dir = if out.length() < 0.01 {
let t = self.rng.range(0.0, TAU);
vec2(t.cos(), t.sin())
} else {
out
};
self.tendrils.push(Tendril {
nodes: vec![a, a + dir * 6.0],
band,
hue_off: self.rng.range(-0.6, 0.6),
curl: self.rng.range(-0.5, 0.5),
width: self.rng.range(0.9, 2.1),
vigor,
budget: 0.0,
quiet: 0.0,
depth: 0,
});
}
// --- per-frame growth --------------------------------------------------
pub fn update(&mut self, b: &Bands, dt: f32) {
let dt = dt.clamp(0.0, 0.05);
self.rot += b.mid * 0.55 * dt + 0.04 * dt;
self.breathe += dt * (0.3 + b.mid * 1.4 + b.low * 0.6);
// Map each band to its onset group for branching decisions.
let group_on = |band: usize| -> f32 {
let f = band as f32 / SPEC_N as f32;
if f < 0.33 {
b.low_on
} else if f < 0.66 {
b.mid_on
} else {
b.high_on
}
};
let mut spawn_children: Vec<(usize, usize)> = Vec::new();
for (ti, t) in self.tendrils.iter_mut().enumerate() {
let drive = b.spec[t.band];
let target = smoothstep(0.04, 0.55, drive) * (0.5 + 0.5 * b.loud);
let k = if target > t.vigor { 0.16 } else { 0.05 };
t.vigor += (target - t.vigor) * k;
if drive < 0.06 {
t.quiet += dt;
} else {
t.quiet = (t.quiet - dt * 2.0).max(0.0);
}
// Grow: spend an energy budget into new curved nodes.
t.budget += (drive * t.vigor) * 26.0 * dt;
while t.budget >= 1.0 && t.nodes.len() < MAX_NODES {
t.budget -= 1.0;
let n = t.nodes.len();
let prev = t.nodes[n - 2];
let last = t.nodes[n - 1];
let mut d = (last - prev).normalize_or_zero();
if d.length() < 0.01 {
d = vec2(1.0, 0.0);
}
let ang = d.y.atan2(d.x)
+ t.curl * 0.35
+ flow(last, self.breathe, self.seed as u32) .x * 0.06;
let step = 9.0 + 26.0 * drive + 4.0 * t.vigor;
let mut np = last + vec2(ang.cos(), ang.sin()) * step;
let rl = np.length();
if rl > R_MAX {
np *= R_MAX / rl; // curl back along the boundary
t.curl = -t.curl;
}
t.nodes.push(np);
}
// Wither: sustained quiet retracts the strand tip-first.
if t.quiet > 0.5 && t.nodes.len() > 2 && self.rng.chance(0.20) {
t.nodes.pop();
}
// Branch on a strong onset in this band's group.
if t.depth < 2
&& t.nodes.len() > 6
&& group_on(t.band) > 0.6
&& self.rng.chance(0.05 + 0.10 * t.vigor)
{
spawn_children.push((ti, 4 + self.rng.idx(t.nodes.len() - 5)));
}
}
// Spawn queued children from a mid-node of the parent.
for (pi, ni) in spawn_children {
let (origin, band, hue, depth, width) = {
let p = &self.tendrils[pi];
(
p.nodes[ni.min(p.nodes.len() - 1)],
(p.band + 1).min(SPEC_N - 1),
p.hue_off,
p.depth + 1,
p.width * 0.7,
)
};
let a = self.rng.range(0.0, TAU);
self.tendrils.push(Tendril {
nodes: vec![origin, origin + vec2(a.cos(), a.sin()) * 6.0],
band,
hue_off: hue + self.rng.range(-0.4, 0.4),
curl: self.rng.range(-0.8, 0.8),
width,
vigor: 0.6,
budget: 0.0,
quiet: 0.0,
depth,
});
}
// Cull dead strands (fully withered).
self.tendrils
.retain(|t| !(t.vigor < 0.05 && t.nodes.len() <= 2));
// Restructure: a broadband transient erupts new growth + a shockwave,
// and prunes the weakest if the field is overgrown.
self.restruct_cd = (self.restruct_cd - dt).max(0.0);
let rising = b.flux > 0.62 && self.prev_flux <= 0.62;
if rising && self.restruct_cd <= 0.0 {
self.restruct_cd = 0.45;
let burst = 5 + self.rng.idx(7);
for _ in 0..burst {
self.spawn_from_anchor(0.7 + 0.3 * b.loud);
}
self.rings.push(Ring {
r: FIELD * 0.06,
speed: 220.0 + 360.0 * b.loud,
life: 1.0,
hue_off: self.rng.range(0.0, 1.0),
});
if self.tendrils.len() > SOFT_CAP {
self.tendrils
.sort_by(|a, c| a.vigor.partial_cmp(&c.vigor).unwrap());
let drop = self.tendrils.len() - SOFT_CAP;
self.tendrils.drain(0..drop);
}
}
self.prev_flux = b.flux;
// Keep a living minimum so quiet passages still shimmer faintly.
while self.tendrils.len() < 14 {
self.spawn_from_anchor(0.3);
}
for r in &mut self.rings {
r.r += r.speed * dt;
r.life -= dt * 0.9;
}
self.rings.retain(|r| r.life > 0.0 && r.r < FIELD);
}
// --- rendering ---------------------------------------------------------
fn xf(&self, p: Vec2, scale: f32, fit: f32, warp: f32) -> Vec2 {
let s = p * scale;
let (sn, cs) = self.rot.sin_cos();
let r = vec2(s.x * cs - s.y * sn, s.x * sn + s.y * cs);
let w = flow(r, self.breathe, self.seed as u32 ^ 0x51A6) * warp;
(r + w) * fit
}
/// Draw the whole sigil into `draw` (design space is `±FIELD/2`, scaled by
/// `fit` to the target). `scale` is the audio breathing scale, `warp` the
/// organic displacement amplitude in design px, `glow` toggles haloing,
/// `tint` is a per-channel RGB multiplier (used for the chromatic-
/// aberration channel passes; pass `[1.0; 3]` normally).
#[allow(clippy::too_many_arguments)]
pub fn draw(
&self,
draw: &Draw,
pal: &Palette,
fit: f32,
scale: f32,
warp: f32,
glow: bool,
seg: usize,
tint: [f32; 3],
) {
// Skeleton — bright, structural, slight breathing only.
for bone in &self.bones {
let sm = catmull_rom(&bone.ctrl, seg);
let pts: Vec<Vec2> = sm
.iter()
.map(|&p| self.xf(p, scale, fit, warp * 0.45))
.collect();
colored_path(draw, &pts, bone.weight, glow, tint, |t| pal.bone(t));
if bone.glyph {
for (i, &c) in bone.ctrl.iter().enumerate().skip(1) {
if i % 2 == 0 {
continue;
}
self.glyph(draw, c, scale, fit, warp, pal, tint);
}
}
}
// Expanding shockwave rings.
for ring in &self.rings {
let mut pts = Vec::with_capacity(49);
for i in 0..=48 {
let th = TAU * i as f32 / 48.0;
pts.push(self.xf(
vec2(ring.r * th.cos(), ring.r * th.sin()),
1.0,
fit,
warp * 0.3,
));
}
let mut c = pal.bone(ring.hue_off.fract());
c[3] = ring.life * ring.life * 0.5;
stroke(draw, &pts, 2.0 * ring.life + 0.5, c, glow, tint);
}
// Organic overgrowth — colour travels root->tip, alpha by vigor.
for t in &self.tendrils {
if t.nodes.len() < 2 {
continue;
}
let sm = catmull_rom(&t.nodes, seg);
let pts: Vec<Vec2> = sm
.iter()
.map(|&p| self.xf(p, scale, fit, warp))
.collect();
let w = t.width * (0.4 + 0.6 * t.vigor);
let v = t.vigor;
let ho = t.hue_off;
colored_path(draw, &pts, w, glow && v > 0.4, tint, |tt| {
pal.stroke(tt, v, ho)
});
// Tip spark on lively strands.
if v > 0.55 {
if let Some(&tip) = pts.last() {
let mut c = pal.stroke(1.0, v, ho);
c[3] = (v - 0.55) * 1.6;
draw.ellipse()
.xy(tip)
.radius(1.5 + 2.5 * v)
.color(srgba(c[0] * tint[0], c[1] * tint[1], c[2] * tint[2], c[3]));
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn glyph(
&self,
draw: &Draw,
c: Vec2,
scale: f32,
fit: f32,
warp: f32,
pal: &Palette,
tint: [f32; 3],
) {
let s = 5.0;
let dia = [
c + vec2(0.0, s),
c + vec2(s, 0.0),
c + vec2(0.0, -s),
c + vec2(-s, 0.0),
c + vec2(0.0, s),
];
let pts: Vec<Vec2> = dia
.iter()
.map(|&p| self.xf(p, scale, fit, warp * 0.4))
.collect();
stroke(draw, &pts, 1.0, pal.bone(0.5), false, tint);
}
}
// --- low-level stroke helpers ---------------------------------------------
#[inline]
fn tint4(c: [f32; 4], t: [f32; 3]) -> [f32; 4] {
[c[0] * t[0], c[1] * t[1], c[2] * t[2], c[3]]
}
#[inline]
fn col4(c: [f32; 4]) -> nannou::color::Srgba<f32> {
srgba(c[0], c[1], c[2], c[3])
}
#[inline]
fn tinted(c: [f32; 4], t: [f32; 3]) -> nannou::color::Srgba<f32> {
col4(tint4(c, t))
}
/// One polyline with optional faux-glow halo (wide low-alpha passes).
fn stroke(draw: &Draw, pts: &[Vec2], w: f32, c: [f32; 4], glow: bool, tn: [f32; 3]) {
if pts.len() < 2 {
return;
}
if glow {
draw.polyline()
.weight(w * 3.4)
.points(pts.iter().cloned())
.color(tinted([c[0], c[1], c[2], c[3] * 0.05], tn));
draw.polyline()
.weight(w * 1.9)
.points(pts.iter().cloned())
.color(tinted([c[0], c[1], c[2], c[3] * 0.10], tn));
}
draw.polyline()
.weight(w)
.points(pts.iter().cloned())
.color(tinted(c, tn));
}
/// Polyline whose colour varies along its length (per-segment), tapering the
/// weight root->tip. `col(t)` returns gamma-sRGB rgba for arc-fraction `t`.
fn colored_path(
draw: &Draw,
pts: &[Vec2],
w: f32,
glow: bool,
tn: [f32; 3],
col: impl Fn(f32) -> [f32; 4],
) {
let n = pts.len();
if n < 2 {
return;
}
if glow {
// Cheap halo: a couple of wide low-alpha passes at mid colour.
let c = col(0.5);
draw.polyline()
.weight(w * 3.2)
.points(pts.iter().cloned())
.color(tinted([c[0], c[1], c[2], c[3] * 0.045], tn));
draw.polyline()
.weight(w * 1.8)
.points(pts.iter().cloned())
.color(tinted([c[0], c[1], c[2], c[3] * 0.09], tn));
}
for i in 0..n - 1 {
let t = i as f32 / (n - 1) as f32;
let c = col(t);
let ww = (w * (1.0 - 0.55 * t)).max(0.4);
draw.polyline()
.weight(ww)
.points([pts[i], pts[i + 1]])
.color(tinted(c, tn));
}
}