diff --git a/shaders/visualizer.wgsl b/shaders/visualizer.wgsl index 539faf8..4fed2f2 100644 --- a/shaders/visualizer.wgsl +++ b/shaders/visualizer.wgsl @@ -26,8 +26,12 @@ struct Bin { hue: f32, sat: f32, val: f32, + splash: f32, // 0..1 suppressed-energy color splash }; +// 5/12 hue-wheel rotation per full splash, cycling all 12 wheel segments. +const SPLASH_HUE_OFFSET: f32 = 0.4166667; + @group(0) @binding(0) var globals: Globals; @group(0) @binding(1) var bins: array; @@ -84,16 +88,19 @@ fn alpha_for(b: Bin, fade: f32) -> f32 { fn dyn_rgb(b: Bin) -> vec3 { let fb = final_brightness(b); - let s = clamp(b.sat * globals.hue_param, 0.0, 1.0); - let v = clamp(b.val * fb, 0.0, 1.0); - return hsv_to_rgb(b.hue, s, v); + let hue = fract(b.hue + SPLASH_HUE_OFFSET * b.splash); + let s = clamp((b.sat + 0.4 * b.splash) * globals.hue_param, 0.0, 1.0); + let v = clamp((b.val + 0.4 * b.splash) * fb, 0.0, 1.0); + return hsv_to_rgb(hue, s, v); } fn fill_rgb(b: Bin) -> vec3 { if (flag(1u)) { let fb = final_brightness(b); - let v = clamp(globals.unified_val * fb, 0.0, 1.0); - return hsv_to_rgb(globals.unified_hue, globals.unified_sat, v); + let hue = fract(globals.unified_hue + SPLASH_HUE_OFFSET * b.splash); + let s = clamp(globals.unified_sat + 0.4 * b.splash, 0.0, 1.0); + let v = clamp((globals.unified_val + 0.4 * b.splash) * fb, 0.0, 1.0); + return hsv_to_rgb(hue, s, v); } return dyn_rgb(b); } diff --git a/src/analyzer.rs b/src/analyzer.rs index 28397e7..bff9a59 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -17,6 +17,8 @@ pub struct FrameData { pub primary_db: Vec, pub cepstrum: Vec, + + pub activity: Vec, } /// stereo three-band processor pool driven by a streaming hilbert source and surfacing one frame per call to step. @@ -218,6 +220,18 @@ impl Analyzer { } } + /// toggles smoothed-cadence splash injection on every processor. + pub fn set_smooth_splashes(&mut self, on: bool) { + for p in self + .main + .iter_mut() + .chain(self.transient.iter_mut()) + .chain(self.deep.iter_mut()) + { + p.set_smooth_splashes(on); + } + } + /// advances one hop of analytic-signal data at the requested normalised playhead and publishes a stereo frame pair. pub fn step(&mut self, position: f64) -> Option<&[FrameData]> { let total = self.total_samples(); @@ -508,6 +522,7 @@ impl Analyzer { db: spec_main.db, primary_db, cepstrum, + activity: std::mem::take(&mut spec_main.activity), }); } diff --git a/src/analyzer_worker.rs b/src/analyzer_worker.rs index e8ed252..23769f7 100644 --- a/src/analyzer_worker.rs +++ b/src/analyzer_worker.rs @@ -27,6 +27,7 @@ enum Cmd { SetGpuBlend(f32), SetSmoothingTilt(f32), SetSmoothingStrength(f32), + SetSmoothSplashes(bool), SetNoiseGate(f32), SetMode(AnalyzerMode), PushLivePcm { samples: Vec, sample_rate: u32, channels: u32 }, @@ -122,6 +123,11 @@ impl AnalyzerWorker { let _ = self.cmd_tx.send(Cmd::SetSmoothingStrength(strength)); } + /// queues a smoothed-splash toggle. + pub fn set_smooth_splashes(&self, on: bool) { + let _ = self.cmd_tx.send(Cmd::SetSmoothSplashes(on)); + } + /// queues a noise-gate threshold change in dB. pub fn set_noise_gate(&self, db: f32) { let _ = self.cmd_tx.send(Cmd::SetNoiseGate(db)); @@ -267,6 +273,7 @@ fn apply(analyzer: &mut Analyzer, cmd: Cmd) { Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b), Cmd::SetSmoothingTilt(t) => analyzer.set_smoothing_tilt(t as f64), Cmd::SetSmoothingStrength(s) => analyzer.set_smoothing_strength(s as f64), + Cmd::SetSmoothSplashes(on) => analyzer.set_smooth_splashes(on), Cmd::SetNoiseGate(db) => analyzer.set_noise_gate(db), Cmd::SetMode(_) | Cmd::PushLivePcm { .. } => {} Cmd::Shutdown => {} diff --git a/src/processor.rs b/src/processor.rs index e956c64..e8c51aa 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -16,6 +16,8 @@ pub struct Spectrum { pub db: Vec, pub cepstrum: Vec, + + pub activity: Vec, } @@ -44,9 +46,13 @@ pub struct Processor { bin_alphas: Vec, bin_smoothed: Vec, + bin_activity: Vec, + bin_reservoir: Vec, + bin_prev_delta: Vec, bin_initialized: bool, smoothing_tilt: f64, smoothing_strength: f64, + smooth_splashes: bool, history: VecDeque>, smoothing_length: usize, @@ -83,9 +89,13 @@ impl Processor { freqs_const: Vec::new(), bin_alphas: Vec::new(), bin_smoothed: Vec::new(), + bin_activity: Vec::new(), + bin_reservoir: Vec::new(), + bin_prev_delta: Vec::new(), bin_initialized: false, smoothing_tilt: DEFAULT_SMOOTHING_TILT, smoothing_strength: DEFAULT_SMOOTHING_STRENGTH, + smooth_splashes: true, num_bins: 26, history: VecDeque::new(), smoothing_length: 3, @@ -119,6 +129,11 @@ impl Processor { self.rebuild_smoothing_alphas(); } + /// toggles splash injection between the smoothed-envelope-peak cadence and the raw transient cadence. + pub fn set_smooth_splashes(&mut self, on: bool) { + self.smooth_splashes = on; + } + /// caps the rolling history depth of the per-bin db time-average. pub fn set_smoothing(&mut self, history_length: usize) { self.smoothing_length = history_length.max(1); @@ -181,7 +196,11 @@ impl Processor { } self.rebuild_smoothing_alphas(); - self.bin_smoothed = vec![-100.0; self.freqs_const.len()]; + let cols = self.freqs_const.len(); + self.bin_smoothed = vec![-100.0; cols]; + self.bin_activity = vec![0.0; cols]; + self.bin_reservoir = vec![0.0; cols]; + self.bin_prev_delta = vec![0.0; cols]; self.bin_initialized = false; } @@ -339,11 +358,15 @@ impl Processor { .map(|i| (cep_buf[i].re * cep_scale) as f32) .collect(); - if self.bin_smoothed.len() != self.freqs_const.len() { - self.bin_smoothed = vec![-100.0; self.freqs_const.len()]; + let cols = self.freqs_const.len(); + if self.bin_smoothed.len() != cols { + self.bin_smoothed = vec![-100.0; cols]; + self.bin_activity = vec![0.0; cols]; + self.bin_reservoir = vec![0.0; cols]; + self.bin_prev_delta = vec![0.0; cols]; self.bin_initialized = false; } - let mut current_db = vec![0.0_f64; self.freqs_const.len()]; + let mut current_db = vec![0.0_f64; cols]; for (i, &target) in self.sample_freqs.iter().enumerate() { let mag = lerp_at(&freqs_full, &mag_full, target); let raw = 20.0 * mag.max(1e-12).log10(); @@ -353,6 +376,24 @@ impl Processor { let a = self.bin_alphas.get(i).copied().unwrap_or(1.0); self.bin_smoothed[i] + a * (raw - self.bin_smoothed[i]) }; + // suppressed-transient energy as the positive residual between raw and the smoothed display. + let residual = (raw - smoothed).max(0.0); + let norm = (residual / ACTIVITY_REF_DB).clamp(0.0, 1.0); + let delta = smoothed - self.bin_smoothed[i]; + if self.smooth_splashes { + self.bin_reservoir[i] += norm; + let peaked = self.bin_prev_delta[i] > 0.0 && delta <= 0.0; + if peaked { + let dump = (self.bin_reservoir[i] * ACTIVITY_RESERVOIR_SCALE).clamp(0.0, 1.0); + self.bin_activity[i] = dump.max(self.bin_activity[i]); + self.bin_reservoir[i] = 0.0; + } else { + self.bin_activity[i] *= ACTIVITY_DECAY; + } + } else { + self.bin_activity[i] = norm.max(self.bin_activity[i] * ACTIVITY_DECAY); + } + self.bin_prev_delta[i] = delta; self.bin_smoothed[i] = smoothed; let mut val = smoothed; if (self.expand_ratio - 1.0).abs() > f32::EPSILON { @@ -366,6 +407,7 @@ impl Processor { current_db[i] = val; } self.bin_initialized = true; + let activity_ret: Vec = self.bin_activity.iter().map(|&a| a as f32).collect(); self.history.push_back(current_db); while self.history.len() > self.smoothing_length { @@ -391,6 +433,7 @@ impl Processor { freqs: freqs_ret, db: averaged, cepstrum, + activity: activity_ret, } } @@ -430,6 +473,15 @@ const DEFAULT_SMOOTHING_TILT: f64 = 0.6; /// default per-bin smoothing mix between raw FFT and smoothed values. const DEFAULT_SMOOTHING_STRENGTH: f64 = 0.7; +/// residual dB mapping to full splash activity. +const ACTIVITY_REF_DB: f64 = 12.0; + +/// per-frame peak-hold decay for splash activity. +const ACTIVITY_DECAY: f64 = 0.85; + +/// reservoir-to-activity scale at a smoothed-envelope peak. +const ACTIVITY_RESERVOIR_SCALE: f64 = 0.25; + /// builds a Hamming or Blackman-Harris analysis window at the requested size. fn build_window(size: usize) -> Vec { if size == 0 { diff --git a/src/ui/app.rs b/src/ui/app.rs index 23ce2e9..51c7c3a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -79,6 +79,9 @@ pub struct App { pub show_settings: bool, + /// expands the advanced settings group at the bottom of the panel. + pub show_advanced: bool, + /// shell-side picker request flag drained by the iOS host once per tick. pub pending_pick: u8, @@ -173,6 +176,14 @@ pub struct Settings { #[serde(default = "default_smoothing_strength")] pub smoothing_strength: f32, + /// fires color splashes at the smoothed-envelope cadence rather than the raw transient cadence. + #[serde(default = "default_smooth_splashes")] + pub smooth_splashes: bool, + + /// intensity multiplier on the suppressed-energy color splash. + #[serde(default = "default_splash")] + pub splash: f32, + /// dB cutoff for the live-mode noise gate. #[serde(default = "default_noise_gate_db")] pub noise_gate_db: f32, @@ -193,6 +204,16 @@ fn default_smoothing_strength() -> f32 { 0.7 } +/// default smoothed-splash toggle. +fn default_smooth_splashes() -> bool { + true +} + +/// default splash intensity. +fn default_splash() -> f32 { + 1.0 +} + impl Default for Settings { fn default() -> Self { Self { @@ -214,6 +235,8 @@ impl Default for Settings { gpu_blend: 0.7, smoothing_tilt: default_smoothing_tilt(), smoothing_strength: default_smoothing_strength(), + smooth_splashes: default_smooth_splashes(), + splash: default_splash(), noise_gate_db: default_noise_gate_db(), } } @@ -240,6 +263,7 @@ pub enum Message { ToggleImmersive, ToggleChrome, ToggleSettings, + ToggleAdvanced, SetPlaybackMode(PlaybackMode), OpenLocalMode, OpenCaptureMode, @@ -267,6 +291,8 @@ pub enum Message { SetGpuBlend(f32), SetSmoothingTilt(f32), SetSmoothingStrength(f32), + SetSmoothSplashes(bool), + SetSplash(f32), SetNoiseGate(f32), SetOutputDevice(Option), SetInputDevice(Option), @@ -326,6 +352,7 @@ impl App { worker.set_gpu_blend(settings.gpu_blend); worker.set_smoothing_tilt(settings.smoothing_tilt); worker.set_smoothing_strength(settings.smoothing_strength); + worker.set_smooth_splashes(settings.smooth_splashes); worker.set_noise_gate(settings.noise_gate_db); let library_worker = LibraryWorker::spawn(); @@ -353,6 +380,7 @@ impl App { settings, settings_inactive, show_settings: false, + show_advanced: false, pending_pick: 0, pending_capture_action: 0, pending_pip_request: false, @@ -409,6 +437,7 @@ impl App { self.worker.set_gpu_blend(self.settings.gpu_blend); self.worker.set_smoothing_tilt(self.settings.smoothing_tilt); self.worker.set_smoothing_strength(self.settings.smoothing_strength); + self.worker.set_smooth_splashes(self.settings.smooth_splashes); self.worker.set_noise_gate(self.settings.noise_gate_db); } @@ -908,6 +937,8 @@ impl App { | Message::SetGpuBlend(_) | Message::SetSmoothingTilt(_) | Message::SetSmoothingStrength(_) + | Message::SetSmoothSplashes(_) + | Message::SetSplash(_) | Message::SetNoiseGate(_) | Message::ResetSettings, ); @@ -1024,6 +1055,9 @@ impl App { self.restore_settings_scroll = true; } } + Message::ToggleAdvanced => { + self.show_advanced = !self.show_advanced; + } Message::SetPlaybackMode(mode) => self.set_playback_mode(mode), Message::OpenLocalMode => { self.set_playback_mode(PlaybackMode::Local); @@ -1102,6 +1136,13 @@ impl App { self.settings.smoothing_strength = v.clamp(0.0, 1.0); self.worker.set_smoothing_strength(self.settings.smoothing_strength); } + Message::SetSmoothSplashes(on) => { + self.settings.smooth_splashes = on; + self.worker.set_smooth_splashes(on); + } + Message::SetSplash(v) => { + self.settings.splash = v.clamp(0.0, 8.0); + } Message::SetNoiseGate(v) => { self.settings.noise_gate_db = v.clamp(-100.0, 0.0); self.worker.set_noise_gate(self.settings.noise_gate_db); diff --git a/src/ui/player.rs b/src/ui/player.rs index ddbb90c..973c695 100644 --- a/src/ui/player.rs +++ b/src/ui/player.rs @@ -406,6 +406,7 @@ fn params_from(s: &super::app::Settings) -> VizParams { hue: s.hue, contrast: s.contrast, brightness: s.brightness, + splash: s.splash, } } @@ -431,13 +432,13 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere let body = column![ Space::new().height(Length::Fixed(TOP_BAR_H + 4.0)), header, - Space::new().height(Length::Fixed(10.0)), + Space::new().height(Length::Fixed(12.0)), section_label("style"), toggle_row("glass", s.glass, Message::SetGlass), toggle_row("album colors", s.album_colors, Message::SetAlbumColors), toggle_row("mirrored", s.mirrored, Message::SetMirrored), toggle_row("inverted", s.inverted, Message::SetInverted), - Space::new().height(Length::Fixed(8.0)), + Space::new().height(Length::Fixed(12.0)), section_label("color"), slider_row( "hue", @@ -463,18 +464,16 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere format!("{:.2}", s.brightness), Message::SetBrightness, ), - Space::new().height(Length::Fixed(8.0)), - section_label("entropy filter"), - toggle_row("enabled", s.entropy_on, Message::SetEntropy), slider_row( - "strength", - s.entropy_strength, - -1.5..=1.5, - 0.05, - format!("{:+.2}", s.entropy_strength), - Message::SetEntropyStrength, + "splash", + s.splash, + 0.0..=8.0, + 0.1, + format!("{:.1}", s.splash), + Message::SetSplash, ), - Space::new().height(Length::Fixed(8.0)), + toggle_row("smooth splashes", s.smooth_splashes, Message::SetSmoothSplashes), + Space::new().height(Length::Fixed(12.0)), section_label("dsp"), slider_row( "bins", @@ -484,7 +483,6 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere format!("{}", s.num_bins), |v| Message::SetNumBins(v as u32), ), - pow2_slider_row("fft", s.fft, 9, 16, Message::SetFft), pow2_slider_row( "hop", @@ -509,43 +507,7 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere format!("{:.2}", s.smoothing_strength), Message::SetSmoothingStrength, ), - Space::new().height(Length::Fixed(8.0)), - section_label("cepstral smoothing"), - slider_row( - "granularity", - s.granularity as f32, - 1.0..=100.0, - 1.0, - format!("{}", s.granularity), - |v| Message::SetGranularity(v as i32), - ), - slider_row( - "detail", - s.detail as f32, - 1.0..=100.0, - 1.0, - format!("{}", s.detail), - |v| Message::SetDetail(v as i32), - ), - slider_row( - "strength", - s.strength, - 0.0..=1.0, - 0.01, - format!("{:.2}", s.strength), - Message::SetStrength, - ), - Space::new().height(Length::Fixed(8.0)), - section_label("fft engine blend"), - slider_row( - "cpu ↔ gpu", - s.gpu_blend, - 0.0..=1.0, - 0.01, - format!("{:.2}", s.gpu_blend), - Message::SetGpuBlend, - ), - Space::new().height(Length::Fixed(8.0)), + Space::new().height(Length::Fixed(12.0)), section_label("capture noise gate"), slider_row( "threshold", @@ -556,13 +518,13 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere Message::SetNoiseGate, ), ] - .spacing(8) + .spacing(10) .padding(Padding::from(16)) .width(Length::Fixed(SETTINGS_W)); #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] let body = body - .push(Space::new().height(Length::Fixed(8.0))) + .push(Space::new().height(Length::Fixed(12.0))) .push(section_label("audio devices")) .push(device_row("output", &app.output_devices, app.output_device.as_deref(), Message::SetOutputDevice)) .push(device_row("input", &app.input_devices, app.input_device.as_deref(), Message::SetInputDevice)) @@ -571,6 +533,59 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere chip_button("Refresh devices", Message::RefreshDeviceList), ].padding(Padding::from([4, 0]))); + let mut body = body + .push(Space::new().height(Length::Fixed(14.0))) + .push(advanced_header(app.show_advanced)); + if app.show_advanced { + body = body + .push(section_label("entropy filter")) + .push(toggle_row("enabled", s.entropy_on, Message::SetEntropy)) + .push(slider_row( + "strength", + s.entropy_strength, + -1.5..=1.5, + 0.05, + format!("{:+.2}", s.entropy_strength), + Message::SetEntropyStrength, + )) + .push(Space::new().height(Length::Fixed(10.0))) + .push(section_label("cepstral smoothing")) + .push(slider_row( + "granularity", + s.granularity as f32, + 1.0..=100.0, + 1.0, + format!("{}", s.granularity), + |v| Message::SetGranularity(v as i32), + )) + .push(slider_row( + "detail", + s.detail as f32, + 1.0..=100.0, + 1.0, + format!("{}", s.detail), + |v| Message::SetDetail(v as i32), + )) + .push(slider_row( + "strength", + s.strength, + 0.0..=1.0, + 0.01, + format!("{:.2}", s.strength), + Message::SetStrength, + )) + .push(Space::new().height(Length::Fixed(10.0))) + .push(section_label("fft engine blend")) + .push(slider_row( + "cpu / gpu", + s.gpu_blend, + 0.0..=1.0, + 0.01, + format!("{:.2}", s.gpu_blend), + Message::SetGpuBlend, + )); + } + let scroll = scrollable(body) .id(settings_scroll_id()) .on_scroll(|vp| Message::SettingsScrolled(vp.absolute_offset())) @@ -610,16 +625,16 @@ fn slider_row<'a, F>( where F: 'a + Fn(f32) -> Message, { - let label_w = 110.0; - let value_w = 60.0; + let label_w = 120.0; + let value_w = 64.0; container( row![ - container(text(label).size(13).color(palette::text_dim())) + container(text(label).size(16).color(palette::text_dim())) .width(Length::Fixed(label_w)), - slider(range, value, on_change).step(step).width(Length::Fill).height(28.0), + slider(range, value, on_change).step(step).width(Length::Fill).height(38.0), container( text(value_text) - .size(13) + .size(16) .color(palette::text()) .align_x(iced_wgpu::core::alignment::Horizontal::Right) ) @@ -629,7 +644,7 @@ where .spacing(12) .align_y(iced_wgpu::core::Alignment::Center) ) - .height(Length::Fixed(44.0)) + .height(Length::Fixed(54.0)) .into() } @@ -729,24 +744,55 @@ where { container( row![ - container(text(label).size(13).color(palette::text_dim())) - .width(Length::Fixed(110.0)), - checkbox(value).on_toggle(on_change).size(26), + container(text(label).size(16).color(palette::text_dim())) + .width(Length::Fixed(120.0)), + checkbox(value).on_toggle(on_change).size(32), ] .spacing(12) .align_y(iced_wgpu::core::Alignment::Center) ) - .height(Length::Fixed(40.0)) + .height(Length::Fixed(50.0)) .into() } /// dim small-caps heading separating groups of settings rows. fn section_label<'a>(label: &'a str) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { - container(text(label).size(10).color(palette::text_dim())) + container(text(label).size(12).color(palette::text_dim())) .padding(Padding::from([0, 0])) .into() } +/// full-width toggle button heading the collapsible advanced settings group. +fn advanced_header<'a>(expanded: bool) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let caret = if expanded { "v" } else { ">" }; + let inner = row![ + text("advanced").size(15).color(palette::text()), + Space::new().width(Length::Fill), + text(caret).size(13).color(palette::text_dim()), + ] + .align_y(iced_wgpu::core::Alignment::Center) + .padding(Padding::from([10, 12])); + + iced_widget::button(inner) + .padding(0) + .width(Length::Fill) + .on_press(Message::ToggleAdvanced) + .style(|_t: &Theme, status: ButtonStatus| { + let a = if matches!(status, ButtonStatus::Hovered) { 0.10 } else { 0.05 }; + button::Style { + background: Some(Background::Color(Color { a, ..palette::text() })), + text_color: palette::text(), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + ..Default::default() + } + }) + .into() +} + /// translucent backdrop styling for the settings overlay. fn settings_panel_style(_theme: &Theme) -> container::Style { container::Style { diff --git a/src/viewport.rs b/src/viewport.rs index 198e213..0d8fca5 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -657,6 +657,7 @@ fn pip_viz_params(s: &crate::ui::app::Settings) -> crate::visualizer::VizParams hue: s.hue, contrast: s.contrast, brightness: s.brightness, + splash: s.splash, } } diff --git a/src/visualizer/mod.rs b/src/visualizer/mod.rs index 4375017..2fca4bb 100644 --- a/src/visualizer/mod.rs +++ b/src/visualizer/mod.rs @@ -27,6 +27,7 @@ pub struct VizParams { pub hue: f32, pub contrast: f32, pub brightness: f32, + pub splash: f32, } impl Default for VizParams { @@ -41,6 +42,7 @@ impl Default for VizParams { hue: 0.9, contrast: 1.0, brightness: 1.0, + splash: 1.0, } } } diff --git a/src/visualizer/pipeline.rs b/src/visualizer/pipeline.rs index b9caa5b..4e743de 100644 --- a/src/visualizer/pipeline.rs +++ b/src/visualizer/pipeline.rs @@ -17,6 +17,7 @@ pub struct BinGpu { pub hue: f32, pub sat: f32, pub val: f32, + pub splash: f32, } /// uniform block holding viewport size, layout counts, render flags, and the unified glass color. diff --git a/src/visualizer/state.rs b/src/visualizer/state.rs index d2cd4a6..d48cbe2 100644 --- a/src/visualizer/state.rs +++ b/src/visualizer/state.rs @@ -10,6 +10,9 @@ use crate::visualizer::VizParams; const HUE_HISTORY_LEN: usize = 40; const HISTORY_LEN: usize = 30; +/// splash-to-jump response gain, reaching the full offset on a moderate splash. +const SPLASH_GAIN: f32 = 4.0; + /// per-bin smoothed magnitude, modulation offsets, palette color, and recent visual history. #[derive(Debug, Clone, Default)] pub struct BinState { @@ -19,6 +22,7 @@ pub struct BinState { pub bright_mod: f32, pub alpha_mod: f32, pub cached_color: [f32; 3], + pub splash: f32, pub history: VecDeque, } @@ -108,6 +112,7 @@ impl VisState { hue: b.cached_color[0], sat: b.cached_color[1], val: b.cached_color[2], + splash: b.splash, }); } } @@ -309,6 +314,24 @@ fn ingest_channel( } } + // spreads each bin's suppressed-energy activity into a 2-bin-wide paint splash via a 0.5/1.0/0.5 kernel. + let mut splash = vec![0.0_f32; n]; + if frame.activity.len() == n { + for i in 0..n { + let a = frame.activity[i]; + if a <= 0.0 { + continue; + } + splash[i] += a; + if i > 0 { + splash[i - 1] += a * 0.5; + } + if i + 1 < n { + splash[i + 1] += a * 0.5; + } + } + } + let use_palette = params.album_colors && palette .map(|p| !p.is_empty()) @@ -339,6 +362,8 @@ fn ingest_channel( } b.cached_color = [hue, 1.0, 1.0]; } + // per-bin splash amount, applied as a hue/sat/value shift on the gpu for both fills and lines. + b.splash = (splash[i] * SPLASH_GAIN * params.splash).min(1.0); } }