YrXtals/web/src/visualizer/state.rs

446 lines
14 KiB
Rust

use std::collections::VecDeque;
use crate::palette;
use crate::visualizer::pipeline::BinGpu;
use crate::visualizer::{FrameData, VizParams};
const HUE_HISTORY_LEN: usize = 40;
const HISTORY_LEN: usize = 30;
/// per-bin smoothed magnitude, modulation offsets, palette color, and recent visual history.
#[derive(Debug, Clone, Default)]
pub struct BinState {
pub visual_db: f32,
pub primary_visual_db: f32,
pub last_raw_db: f32,
pub bright_mod: f32,
pub alpha_mod: f32,
pub cached_color: [f32; 3],
pub history: VecDeque<f32>,
}
/// row of bins for one audio channel.
#[derive(Debug, Default, Clone)]
pub struct ChannelState {
pub bins: Vec<BinState>,
}
/// cpu-side smoothing state for the visualizer.
#[derive(Debug, Default)]
pub struct VisState {
pub channels: Vec<ChannelState>,
pub hue_history: VecDeque<(f32, f32)>,
pub hue_sum_cos: f32,
pub hue_sum_sin: f32,
pub unified_color: [f32; 3],
pub smoothed_cepstrum: Vec<f32>,
pub last_frames_id: usize,
}
impl VisState {
/// folds a fresh analyzer frame into the smoothed bin, hue, and cepstrum tracks.
pub fn ingest(
&mut self,
frames: &[FrameData],
frames_id: usize,
params: &VizParams,
palette: Option<&[[f32; 3]]>,
) {
self.last_frames_id = frames_id;
if self.channels.len() != frames.len() {
self.channels.resize(frames.len(), ChannelState::default());
}
if params.glass {
if let Some(f0) = frames.first() {
self.unified_color = self.update_glass_color(f0, params);
}
} else {
self.unified_color = [0.0, 0.0, 1.0];
}
for (ch_idx, frame) in frames.iter().enumerate() {
ingest_channel(&mut self.channels[ch_idx], frame, params, palette);
}
if params.mirrored {
if let Some(f0) = frames.first() {
let raw = &f0.cepstrum;
if self.smoothed_cepstrum.len() != raw.len() {
self.smoothed_cepstrum = vec![0.0; raw.len()];
}
for (i, r) in raw.iter().enumerate() {
self.smoothed_cepstrum[i] = 0.15 * r + 0.85 * self.smoothed_cepstrum[i];
}
}
}
}
/// flattens every channel's bins into the gpu-bound vector and applies a small x-shift to the right channel.
pub fn pack_bins(&self, frames: &[FrameData], stereo: bool, out: &mut Vec<BinGpu>) {
out.clear();
let n_bins = self.channels.first().map(|c| c.bins.len()).unwrap_or(0);
if n_bins == 0 {
return;
}
for (ch_idx, channel) in self.channels.iter().enumerate() {
let freqs = frames
.get(ch_idx)
.map(|f| f.freqs.as_slice())
.unwrap_or(&[]);
let x_offset = if ch_idx == 1 && stereo { 1.005 } else { 1.0 };
for (i, b) in channel.bins.iter().enumerate() {
let freq = freqs.get(i).copied().unwrap_or(0.0);
let visual_norm = ((b.visual_db + 80.0) / 80.0).clamp(0.0, 1.0);
let primary_norm = ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0);
out.push(BinGpu {
log_x: log_x(freq * x_offset),
visual_norm,
primary_norm,
bright_mod: b.bright_mod,
alpha_mod: b.alpha_mod,
hue: b.cached_color[0],
sat: b.cached_color[1],
val: b.cached_color[2],
});
}
}
// flushes the rightmost bin to the viewport edge.
let max_x = out.iter().map(|b| b.log_x).fold(f32::NEG_INFINITY, f32::max);
if max_x.is_finite() && max_x > 0.0 {
let inv = 1.0 / max_x;
for b in out.iter_mut() {
b.log_x *= inv;
}
}
}
/// derives a hue from spectral midpoint and mean amplitude, smoothed by a circular running mean.
fn update_glass_color(&mut self, f0: &FrameData, params: &VizParams) -> [f32; 3] {
let mid_freq = f0
.freqs
.get(f0.freqs.len() / 2)
.copied()
.unwrap_or(1000.0);
let mean_db = if f0.db.is_empty() {
-80.0
} else {
f0.db.iter().sum::<f32>() / f0.db.len() as f32
};
let log_min = 20.0_f32.log10();
let log_max = 20_000.0_f32.log10();
let freq_norm =
(mid_freq.max(1e-9).log10() - log_min) / (log_max - log_min);
let amp_norm = ((mean_db + 80.0) / 80.0).clamp(0.0, 1.0);
let amp_weight = (1.0 / (freq_norm + 1e-4).powf(5.0) * 2.0).clamp(0.5, 6.0);
let mut hue = (freq_norm + amp_norm * amp_weight * params.hue).rem_euclid(1.0);
if params.mirrored {
hue = 1.0 - hue;
}
if hue < 0.0 {
hue += 1.0;
}
let angle = hue * std::f32::consts::TAU;
let cos_v = angle.cos();
let sin_v = angle.sin();
self.hue_history.push_back((cos_v, sin_v));
self.hue_sum_cos += cos_v;
self.hue_sum_sin += sin_v;
if self.hue_history.len() > HUE_HISTORY_LEN {
if let Some((c, s)) = self.hue_history.pop_front() {
self.hue_sum_cos -= c;
self.hue_sum_sin -= s;
}
}
let smoothed_angle = self.hue_sum_sin.atan2(self.hue_sum_cos);
let mut smoothed_hue = smoothed_angle / std::f32::consts::TAU;
if smoothed_hue < 0.0 {
smoothed_hue += 1.0;
}
[smoothed_hue, 1.0, 1.0]
}
}
/// maps a frequency in hertz to a 0..1 horizontal position on the 20 hz to 20 khz log axis.
fn log_x(freq: f32) -> f32 {
let log_min = 20.0_f32.log10();
let log_max = 20_000.0_f32.log10();
if freq <= 0.0 {
return 0.0;
}
(freq.max(1e-9).log10() - log_min) / (log_max - log_min)
}
/// updates one channel's bin smoothing, peak modulations, treble compensation, and palette colors.
fn ingest_channel(
channel: &mut ChannelState,
frame: &FrameData,
params: &VizParams,
palette: Option<&[[f32; 3]]>,
) {
let n = frame.db.len();
if channel.bins.len() != n {
channel.bins.resize(n, BinState::default());
}
if n == 0 {
return;
}
let use_entropy = params.entropy_on;
let mut bin_entropy = vec![0.0_f32; n];
if use_entropy {
for (i, b) in channel.bins.iter().enumerate() {
bin_entropy[i] = calculate_entropy(&b.history);
}
}
let median_entropy = if use_entropy {
median_of(&bin_entropy)
} else {
0.0
};
for (i, b) in channel.bins.iter_mut().enumerate() {
let raw = frame.db[i];
let primary = frame.primary_db.get(i).copied().unwrap_or(raw);
let change = raw - b.visual_db;
if use_entropy {
let relative = median_entropy - bin_entropy[i];
let base = 1.5_f32;
let reward_gain = base + params.entropy_strength;
let penalty_gain = base - params.entropy_strength;
let gain = if relative >= 0.0 { reward_gain } else { penalty_gain };
let multiplier = (1.0 + relative * gain * 2.0).clamp(0.05, 4.0);
b.visual_db += change * multiplier;
b.history.push_back(b.visual_db);
while b.history.len() > HISTORY_LEN {
b.history.pop_front();
}
} else {
let resp = 0.2_f32;
b.visual_db = b.visual_db * (1.0 - resp) + raw * resp;
b.history.push_back(b.visual_db);
while b.history.len() > HISTORY_LEN {
b.history.pop_front();
}
}
let pattern_resp = 0.1_f32;
b.primary_visual_db = b.primary_visual_db * (1.0 - pattern_resp) + primary * pattern_resp;
b.last_raw_db = raw;
}
let mut vertex_energy: Vec<f32> = channel
.bins
.iter()
.map(|b| ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0))
.collect();
let split = n / 2;
let mut max_low = 0.01_f32;
let mut max_high = 0.01_f32;
for v in vertex_energy.iter().take(split) {
max_low = max_low.max(*v);
}
for v in vertex_energy.iter().skip(split) {
max_high = max_high.max(*v);
}
let treble_boost = (max_low / max_high).clamp(1.0, 40.0);
let mut global_max = 0.001_f32;
for (j, v) in vertex_energy.iter_mut().enumerate() {
if j >= split {
let t = (j - split) as f32 / (n - split) as f32;
*v *= 1.0 + (treble_boost - 1.0) * t;
}
let compressed = v.tanh();
*v = compressed;
if compressed > global_max {
global_max = compressed;
}
}
for v in vertex_energy.iter_mut() {
*v = (*v / global_max).clamp(0.0, 1.0);
}
for b in channel.bins.iter_mut() {
b.bright_mod = 0.0;
b.alpha_mod = 0.0;
}
let entropy_factor = if use_entropy {
params.entropy_strength.abs().max(0.1)
} else {
1.0
};
if n >= 3 {
for i in 1..n - 1 {
let curr = vertex_energy[i];
let prev = vertex_energy[i - 1];
let next = vertex_energy[i + 1];
if curr > prev && curr > next {
let left_dominant = prev > next;
let sharpness = (curr - prev).min(curr - next);
let peak_intensity =
(sharpness * 10.0 * entropy_factor).powf(0.3).clamp(0.0, 1.0);
let decay_base = 0.65 - (sharpness * 3.0).clamp(0.0, 0.35);
for d in 1..=12_i32 {
apply_pattern(&mut channel.bins, i, d, left_dominant, -1, peak_intensity, decay_base);
apply_pattern(&mut channel.bins, i, d, !left_dominant, 1, peak_intensity, decay_base);
}
}
}
}
let use_palette = params.album_colors
&& palette
.map(|p| !p.is_empty())
.unwrap_or(false);
let denom = (n as f32 - 1.0).max(1.0);
for (i, b) in channel.bins.iter_mut().enumerate() {
if use_palette {
let pal = palette.unwrap();
let plen = pal.len();
let raw_idx = if plen >= n {
i * (plen - 1) / (n - 1).max(1)
} else {
i * plen / n.max(1)
};
let pal_idx = if params.mirrored {
plen.saturating_sub(1).saturating_sub(raw_idx)
} else {
raw_idx
}
.min(plen - 1);
let rgb = pal[pal_idx];
let (h, s, v) = palette::rgb_to_hsv(rgb[0], rgb[1], rgb[2]);
b.cached_color = [h, s, v];
} else {
let mut hue = i as f32 / denom;
if params.mirrored {
hue = 1.0 - hue;
}
b.cached_color = [hue, 1.0, 1.0];
}
}
}
/// stamps a three-step bright/alpha modulation pattern outward from a peak bin, decaying with distance.
fn apply_pattern(
bins: &mut [BinState],
centre: usize,
dist: i32,
is_bright_side: bool,
direction: i32,
peak_intensity: f32,
decay_base: f32,
) {
let target = if direction == -1 {
centre as isize - dist as isize
} else {
centre as isize + dist as isize - 1
};
if target < 0 || target as usize >= bins.len() {
return;
}
let cycle = (dist - 1) / 3;
let step = (dist - 1) % 3;
let decay = decay_base.powi(cycle);
let intensity = peak_intensity * decay;
if intensity < 0.01 {
return;
}
let mut ty = step;
if is_bright_side {
ty = (ty + 2) % 3;
}
let bin = &mut bins[target as usize];
match ty {
0 => {
bin.bright_mod += 0.8 * intensity;
bin.alpha_mod -= 0.8 * intensity;
}
1 => {
bin.bright_mod -= 0.8 * intensity;
bin.alpha_mod += 0.2 * intensity;
}
_ => {
bin.bright_mod += 0.8 * intensity;
bin.alpha_mod += 0.2 * intensity;
}
}
}
/// scores deviation of a bin's recent history from a low-frequency reconstruction as the entropy proxy.
fn calculate_entropy(history: &VecDeque<f32>) -> f32 {
let buf: Vec<f32> = history.iter().copied().collect();
let n = buf.len();
if n < 4 {
return 0.0;
}
let nf = n as f64;
let mut x_re = vec![0.0_f64; n];
let mut x_im = vec![0.0_f64; n];
for k in 0..n {
let mut re = 0.0;
let mut im = 0.0;
for (idx, &h) in buf.iter().enumerate() {
let angle = -2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf;
re += h as f64 * angle.cos();
im += h as f64 * angle.sin();
}
x_re[k] = re;
x_im[k] = im;
}
for k in (n / 2 + 1)..n {
x_re[k] = 0.0;
x_im[k] = 0.0;
}
for k in 1..n.div_ceil(2) {
x_re[k] *= 2.0;
x_im[k] *= 2.0;
}
let mut sq_sum = 0.0_f64;
for idx in 0..n {
let mut im = 0.0;
for k in 0..n {
let angle = 2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf;
im += x_re[k] * angle.sin() + x_im[k] * angle.cos();
}
im /= nf;
sq_sum += im * im;
}
((sq_sum / nf).sqrt() as f32 / 10.0).clamp(0.0, 1.0)
}
/// returns the median element via a partial selection sort over a copy.
fn median_of(values: &[f32]) -> f32 {
if values.is_empty() {
return 0.0;
}
let mut v = values.to_vec();
let mid = v.len() / 2;
v.select_nth_unstable_by(mid, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v[mid]
}