init
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
*.mp4
|
||||||
|
*.flac
|
||||||
Generated
+3463
File diff suppressed because it is too large
Load Diff
+17
@@ -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
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 { " " }
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user