//! 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::geometry::Figure; use crate::viz::math::smoothstep; use crate::viz::palette::Palette; use nannou::prelude::*; const FIELD: f32 = 960.0; // design-space extent (matches sigil/post) const N: usize = 1600; // beam samples per figure const 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 } // `Figure` (torus knot / Gielis supershape / 3D Lissajous / harmonograph / // rose-helix) is shared geometry — see `crate::viz::geometry` (imported // above). The scope only seeds (`Figure::random`) and samples (`.at`) it. pub struct Scope { pub seed: u64, 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 = 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, )); } } }