Lots of bugfixes; New Smoothing function for softer dynamics which remain responsive but are less prone to jitter.

This commit is contained in:
jess 2026-05-30 15:01:02 -07:00
parent 43d65d751a
commit 66ec445fdd
12 changed files with 226 additions and 71 deletions

View File

@ -194,6 +194,30 @@ impl Analyzer {
} }
} }
/// updates the per-bin smoothing frequency-tilt on every processor.
pub fn set_smoothing_tilt(&mut self, tilt: f64) {
for p in self
.main
.iter_mut()
.chain(self.transient.iter_mut())
.chain(self.deep.iter_mut())
{
p.set_smoothing_tilt(tilt);
}
}
/// updates the per-bin smoothing strength on every processor.
pub fn set_smoothing_strength(&mut self, strength: f64) {
for p in self
.main
.iter_mut()
.chain(self.transient.iter_mut())
.chain(self.deep.iter_mut())
{
p.set_smoothing_strength(strength);
}
}
/// advances one hop of analytic-signal data at the requested normalised playhead and publishes a stereo frame pair. /// 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]> { pub fn step(&mut self, position: f64) -> Option<&[FrameData]> {
let total = self.total_samples(); let total = self.total_samples();
@ -250,7 +274,7 @@ impl Analyzer {
} }
} }
/// appends interleaved PCM into the live-mode buffer with stereo conversion. propagates sample-rate changes and caps the buffer length. /// appends stereo-converted PCM into the bounded live-mode buffer, tracking the incoming sample rate.
pub fn push_live_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) { pub fn push_live_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) {
if samples.is_empty() || channels == 0 { if samples.is_empty() || channels == 0 {
return; return;

View File

@ -25,6 +25,8 @@ enum Cmd {
SetNumBins(usize), SetNumBins(usize),
SetSmoothing { granularity: i32, detail: i32, strength: f32 }, SetSmoothing { granularity: i32, detail: i32, strength: f32 },
SetGpuBlend(f32), SetGpuBlend(f32),
SetSmoothingTilt(f32),
SetSmoothingStrength(f32),
SetNoiseGate(f32), SetNoiseGate(f32),
SetMode(AnalyzerMode), SetMode(AnalyzerMode),
PushLivePcm { samples: Vec<f32>, sample_rate: u32, channels: u32 }, PushLivePcm { samples: Vec<f32>, sample_rate: u32, channels: u32 },
@ -110,6 +112,16 @@ impl AnalyzerWorker {
let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend)); let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend));
} }
/// queues a smoothing-tilt change.
pub fn set_smoothing_tilt(&self, tilt: f32) {
let _ = self.cmd_tx.send(Cmd::SetSmoothingTilt(tilt));
}
/// queues a smoothing-strength change.
pub fn set_smoothing_strength(&self, strength: f32) {
let _ = self.cmd_tx.send(Cmd::SetSmoothingStrength(strength));
}
/// queues a noise-gate threshold change in dB. /// queues a noise-gate threshold change in dB.
pub fn set_noise_gate(&self, db: f32) { pub fn set_noise_gate(&self, db: f32) {
let _ = self.cmd_tx.send(Cmd::SetNoiseGate(db)); let _ = self.cmd_tx.send(Cmd::SetNoiseGate(db));
@ -253,6 +265,8 @@ fn apply(analyzer: &mut Analyzer, cmd: Cmd) {
strength, strength,
} => analyzer.set_smoothing_params(granularity, detail, strength), } => analyzer.set_smoothing_params(granularity, detail, strength),
Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b), 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::SetNoiseGate(db) => analyzer.set_noise_gate(db), Cmd::SetNoiseGate(db) => analyzer.set_noise_gate(db),
Cmd::SetMode(_) | Cmd::PushLivePcm { .. } => {} Cmd::SetMode(_) | Cmd::PushLivePcm { .. } => {}
Cmd::Shutdown => {} Cmd::Shutdown => {}

View File

@ -537,7 +537,7 @@ fn redirect_stdio_to_logcat() {
.ok(); .ok();
} }
/// opens the AAudio mic input stream and stores the handle on the AndroidViewport. no-op if a stream is already running. /// opens the AAudio mic input stream and stores the handle on the AndroidViewport, idempotent against an already-running stream.
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapture<'a>( pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapture<'a>(
_env: JNIEnv<'a>, _env: JNIEnv<'a>,

View File

@ -66,7 +66,7 @@ impl AudioEngine {
Self::with_output_device(None) Self::with_output_device(None)
} }
/// opens an output device by name (system default when name is None), builds the f32 stream, and starts the audio thread paused. /// opens a named output device or the system default, building a paused f32 stream against a started audio thread.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub fn with_output_device(name: Option<&str>) -> Result<Self, String> { pub fn with_output_device(name: Option<&str>) -> Result<Self, String> {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]

View File

@ -18,7 +18,7 @@ struct Args {
const ARGS_BYTES: u64 = std::mem::size_of::<Args>() as u64; const ARGS_BYTES: u64 = std::mem::size_of::<Args>() as u64;
/// pipelined slot count. two slots sustain the one-frame lag pattern without barrier-serialization on a shared scratch buffer. /// pipelined slot count sustaining the one-frame lag pattern without scratch-buffer barrier-serialization.
const NUM_SLOTS: usize = 2; const NUM_SLOTS: usize = 2;
/// per-slot gpu resources owned exclusively by one pending submission. /// per-slot gpu resources owned exclusively by one pending submission.
@ -298,7 +298,7 @@ impl GpuFft1D {
self.cached_inverse.set(Some(inverse)); self.cached_inverse.set(Some(inverse));
} }
/// uploads input to the next slot, dispatches bit-reversal and log2(N) butterfly passes, queues the readback copy, and records the submission index in the pending FIFO. /// dispatches one bit-reversal and log2(N) butterfly transform of the input into the next pipelined slot.
fn submit(&mut self, input: &[Complex64], inverse: bool) { fn submit(&mut self, input: &[Complex64], inverse: bool) {
debug_assert_eq!(input.len(), self.n as usize); debug_assert_eq!(input.len(), self.n as usize);
self.ensure_all_args(inverse); self.ensure_all_args(inverse);

View File

@ -18,6 +18,7 @@ pub struct Spectrum {
pub cepstrum: Vec<f32>, pub cepstrum: Vec<f32>,
} }
/// single-channel windowed-fft pipeline with optional gpu offload, log-spaced binning, and cepstral envelope blending. /// single-channel windowed-fft pipeline with optional gpu offload, log-spaced binning, and cepstral envelope blending.
pub struct Processor { pub struct Processor {
frame_size: usize, frame_size: usize,
@ -41,6 +42,12 @@ pub struct Processor {
freqs_const: Vec<f64>, freqs_const: Vec<f64>,
num_bins: usize, num_bins: usize,
bin_alphas: Vec<f64>,
bin_smoothed: Vec<f64>,
bin_initialized: bool,
smoothing_tilt: f64,
smoothing_strength: f64,
history: VecDeque<Vec<f64>>, history: VecDeque<Vec<f64>>,
smoothing_length: usize, smoothing_length: usize,
@ -53,6 +60,7 @@ pub struct Processor {
detail: i32, detail: i32,
cepstral_strength: f32, cepstral_strength: f32,
#[allow(dead_code)]
weave_caps: Option<weave::Caps>, weave_caps: Option<weave::Caps>,
} }
@ -73,6 +81,11 @@ impl Processor {
buffer: Vec::new(), buffer: Vec::new(),
sample_freqs: Vec::new(), sample_freqs: Vec::new(),
freqs_const: Vec::new(), freqs_const: Vec::new(),
bin_alphas: Vec::new(),
bin_smoothed: Vec::new(),
bin_initialized: false,
smoothing_tilt: DEFAULT_SMOOTHING_TILT,
smoothing_strength: DEFAULT_SMOOTHING_STRENGTH,
num_bins: 26, num_bins: 26,
history: VecDeque::new(), history: VecDeque::new(),
smoothing_length: 3, smoothing_length: 3,
@ -94,6 +107,18 @@ impl Processor {
self.sample_rate = rate; self.sample_rate = rate;
} }
/// updates the per-bin smoothing alpha curve's frequency-tilt.
pub fn set_smoothing_tilt(&mut self, tilt: f64) {
self.smoothing_tilt = tilt.clamp(0.0, 1.0);
self.rebuild_smoothing_alphas();
}
/// updates the per-bin smoothing mix between raw FFT and smoothed values.
pub fn set_smoothing_strength(&mut self, strength: f64) {
self.smoothing_strength = strength.clamp(0.0, 1.0);
self.rebuild_smoothing_alphas();
}
/// caps the rolling history depth of the per-bin db time-average. /// caps the rolling history depth of the per-bin db time-average.
pub fn set_smoothing(&mut self, history_length: usize) { pub fn set_smoothing(&mut self, history_length: usize) {
self.smoothing_length = history_length.max(1); self.smoothing_length = history_length.max(1);
@ -131,7 +156,7 @@ impl Processor {
self.rebuild_bins(); self.rebuild_bins();
} }
/// rebuilds the FFT sample frequencies and the log-spaced display centers. /// rebuilds the log-spaced display bin centers from 40Hz to 11kHz with a 10Hz sentinel prepended.
fn rebuild_bins(&mut self) { fn rebuild_bins(&mut self) {
self.sample_freqs.clear(); self.sample_freqs.clear();
self.freqs_const.clear(); self.freqs_const.clear();
@ -140,25 +165,41 @@ impl Processor {
let n = self.num_bins.max(1); let n = self.num_bins.max(1);
let min_freq = 40.0_f64; let min_freq = 40.0_f64;
let max_freq = 11_000.0_f64; let max_freq = 11_000.0_f64;
let linear = self.frame_size > 0 && self.frame_size <= SMALL_FFT_THRESHOLD;
let mut sample_edges: Vec<f64> = Vec::with_capacity(n + 1); let mut edges: Vec<f64> = Vec::with_capacity(n + 1);
let mut display_edges: Vec<f64> = Vec::with_capacity(n + 1);
for i in 0..=n { for i in 0..=n {
let t = i as f64 / n as f64; let t = i as f64 / n as f64;
sample_edges.push(if linear { edges.push(min_freq * (max_freq / min_freq).powf(t));
min_freq + (max_freq - min_freq) * t
} else {
min_freq * (max_freq / min_freq).powf(t)
});
display_edges.push(min_freq * (max_freq / min_freq).powf(t));
} }
self.sample_freqs.push(10.0); self.sample_freqs.push(10.0);
self.freqs_const.push(10.0); self.freqs_const.push(10.0);
for i in 0..n { for i in 0..n {
self.sample_freqs.push((sample_edges[i] + sample_edges[i + 1]) / 2.0); let center = (edges[i] + edges[i + 1]) / 2.0;
self.freqs_const.push((display_edges[i] + display_edges[i + 1]) / 2.0); self.sample_freqs.push(center);
self.freqs_const.push(center);
}
self.rebuild_smoothing_alphas();
self.bin_smoothed = vec![-100.0; self.freqs_const.len()];
self.bin_initialized = false;
}
/// rebuilds per-bin one-pole smoothing alpha values from tilt and strength.
fn rebuild_smoothing_alphas(&mut self) {
let tilt = self.smoothing_tilt.clamp(0.0, 1.0);
let strength = self.smoothing_strength.clamp(0.0, 1.0);
let f_low = 40.0_f64.ln();
let f_high = 11_000.0_f64.ln();
let span = f_high - f_low;
self.bin_alphas.clear();
self.bin_alphas.reserve(self.freqs_const.len());
for &fc in self.freqs_const.iter() {
let f_norm = ((fc.max(1.0).ln() - f_low) / span).clamp(0.0, 1.0);
let curve = 0.5 + 0.45 * tilt * (2.0 * f_norm - 1.0);
let smooth_alpha = curve.clamp(0.05, 0.95);
let effective = 1.0 - strength * (1.0 - smooth_alpha);
self.bin_alphas.push(effective);
} }
} }
@ -277,8 +318,8 @@ impl Processor {
} else if freq == 0.0 { } else if freq == 0.0 {
mag = 0.0; mag = 0.0;
} }
mag_full[i] = mag;
freqs_full[i] = freq; freqs_full[i] = freq;
mag_full[i] = mag;
} }
let mut cep_buf: Vec<Complex64> = Vec::with_capacity(n); let mut cep_buf: Vec<Complex64> = Vec::with_capacity(n);
@ -298,23 +339,22 @@ impl Processor {
.map(|i| (cep_buf[i].re * cep_scale) as f32) .map(|i| (cep_buf[i].re * cep_scale) as f32)
.collect(); .collect();
if self.cepstral_strength > 0.0 { if self.bin_smoothed.len() != self.freqs_const.len() {
let envelope = weave::idealize_curve( self.bin_smoothed = vec![-100.0; self.freqs_const.len()];
&mag_full, self.bin_initialized = false;
self.granularity,
self.detail,
self.weave_caps,
);
let s = self.cepstral_strength as f64;
for (m, e) in mag_full.iter_mut().zip(envelope.iter()) {
*m = *m * (1.0 - s) + e * s;
} }
}
let mut current_db = vec![0.0_f64; self.freqs_const.len()]; let mut current_db = vec![0.0_f64; self.freqs_const.len()];
for (i, &target) in self.sample_freqs.iter().enumerate() { for (i, &target) in self.sample_freqs.iter().enumerate() {
let mag = lerp_at(&freqs_full, &mag_full, target); let mag = lerp_at(&freqs_full, &mag_full, target);
let mut val = 20.0 * mag.max(1e-12).log10(); let raw = 20.0 * mag.max(1e-12).log10();
let smoothed = if !self.bin_initialized {
raw
} else {
let a = self.bin_alphas.get(i).copied().unwrap_or(1.0);
self.bin_smoothed[i] + a * (raw - self.bin_smoothed[i])
};
self.bin_smoothed[i] = smoothed;
let mut val = smoothed;
if (self.expand_ratio - 1.0).abs() > f32::EPSILON { if (self.expand_ratio - 1.0).abs() > f32::EPSILON {
let t = self.expand_threshold as f64; let t = self.expand_threshold as f64;
let r = self.expand_ratio as f64; let r = self.expand_ratio as f64;
@ -325,6 +365,7 @@ impl Processor {
} }
current_db[i] = val; current_db[i] = val;
} }
self.bin_initialized = true;
self.history.push_back(current_db); self.history.push_back(current_db);
while self.history.len() > self.smoothing_length { while self.history.len() > self.smoothing_length {
@ -359,10 +400,37 @@ impl Processor {
} }
} }
/// fft-size cutoff below which the processor switches to linear binning and a Hamming window. /// linearly interpolates a value from the freqs/values table at the given target frequency with endpoint clamping.
fn lerp_at(freqs: &[f64], values: &[f64], target: f64) -> f64 {
if freqs.is_empty() {
return 0.0;
}
if target <= freqs[0] {
return values[0];
}
if target >= freqs[freqs.len() - 1] {
return values[values.len() - 1];
}
let upper = freqs.partition_point(|&f| f < target);
let lower = upper - 1;
let f0 = freqs[lower];
let f1 = freqs[upper];
let v0 = values[lower];
let v1 = values[upper];
let t = (target - f0) / (f1 - f0);
v0 + t * (v1 - v0)
}
/// Hamming/Blackman-Harris window cutoff in fft samples.
const SMALL_FFT_THRESHOLD: usize = 4096; const SMALL_FFT_THRESHOLD: usize = 4096;
/// builds the analysis window: Hamming below the small-fft threshold for a narrower main lobe, Blackman-Harris above for cleaner sidelobes. /// default per-bin smoothing alpha-curve frequency-tilt.
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;
/// builds a Hamming or Blackman-Harris analysis window at the requested size.
fn build_window(size: usize) -> Vec<f64> { fn build_window(size: usize) -> Vec<f64> {
if size == 0 { if size == 0 {
return Vec::new(); return Vec::new();
@ -387,27 +455,6 @@ fn build_window(size: usize) -> Vec<f64> {
} }
} }
/// linearly interpolates a value from the freqs/values table at the given target frequency with endpoint clamping.
fn lerp_at(freqs: &[f64], values: &[f64], target: f64) -> f64 {
if freqs.is_empty() {
return 0.0;
}
if target <= freqs[0] {
return values[0];
}
if target >= freqs[freqs.len() - 1] {
return values[values.len() - 1];
}
let upper = freqs.partition_point(|&f| f < target);
let lower = upper - 1;
let f0 = freqs[lower];
let f1 = freqs[upper];
let v0 = values[lower];
let v1 = values[upper];
let t = (target - f0) / (f1 - f0);
v0 + t * (v1 - v0)
}
/// chooses lighter idealisation caps on mobile targets and unlimited caps on desktop. /// chooses lighter idealisation caps on mobile targets and unlimited caps on desktop.
#[cfg(any(target_os = "ios", target_os = "android"))] #[cfg(any(target_os = "ios", target_os = "android"))]
fn default_caps() -> Option<weave::Caps> { fn default_caps() -> Option<weave::Caps> {

View File

@ -40,7 +40,7 @@ pub struct CaptureState {
/// foreign-session playback flag pushed by the host MediaController callback. /// foreign-session playback flag pushed by the host MediaController callback.
pub playing: bool, pub playing: bool,
/// flips true once any media session has reported metadata. drives the transport-enable state. /// transport-enable latch flipped true the moment any media session reports metadata.
pub has_session: bool, pub has_session: bool,
/// host-pushed flag tracking notification-listener access for now-playing metadata. /// host-pushed flag tracking notification-listener access for now-playing metadata.
@ -165,6 +165,14 @@ pub struct Settings {
/// fraction of FFT work routed to the GPU pipeline, blended against the CPU result. /// fraction of FFT work routed to the GPU pipeline, blended against the CPU result.
pub gpu_blend: f32, pub gpu_blend: f32,
/// per-bin smoothing alpha-curve frequency-tilt.
#[serde(default = "default_smoothing_tilt")]
pub smoothing_tilt: f32,
/// per-bin smoothing mix between raw FFT and smoothed values.
#[serde(default = "default_smoothing_strength")]
pub smoothing_strength: f32,
/// dB cutoff for the live-mode noise gate. /// dB cutoff for the live-mode noise gate.
#[serde(default = "default_noise_gate_db")] #[serde(default = "default_noise_gate_db")]
pub noise_gate_db: f32, pub noise_gate_db: f32,
@ -175,6 +183,16 @@ fn default_noise_gate_db() -> f32 {
-60.0 -60.0
} }
/// default per-bin smoothing tilt.
fn default_smoothing_tilt() -> f32 {
0.6
}
/// default per-bin smoothing strength.
fn default_smoothing_strength() -> f32 {
0.7
}
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -194,6 +212,8 @@ impl Default for Settings {
detail: 50, detail: 50,
strength: 0.0, strength: 0.0,
gpu_blend: 0.7, gpu_blend: 0.7,
smoothing_tilt: default_smoothing_tilt(),
smoothing_strength: default_smoothing_strength(),
noise_gate_db: default_noise_gate_db(), noise_gate_db: default_noise_gate_db(),
} }
} }
@ -245,6 +265,8 @@ pub enum Message {
SetDetail(i32), SetDetail(i32),
SetStrength(f32), SetStrength(f32),
SetGpuBlend(f32), SetGpuBlend(f32),
SetSmoothingTilt(f32),
SetSmoothingStrength(f32),
SetNoiseGate(f32), SetNoiseGate(f32),
SetOutputDevice(Option<String>), SetOutputDevice(Option<String>),
SetInputDevice(Option<String>), SetInputDevice(Option<String>),
@ -302,6 +324,8 @@ impl App {
worker.set_dsp_params(settings.fft as usize, settings.hop as usize); worker.set_dsp_params(settings.fft as usize, settings.hop as usize);
worker.set_smoothing(settings.granularity, settings.detail, settings.strength); worker.set_smoothing(settings.granularity, settings.detail, settings.strength);
worker.set_gpu_blend(settings.gpu_blend); worker.set_gpu_blend(settings.gpu_blend);
worker.set_smoothing_tilt(settings.smoothing_tilt);
worker.set_smoothing_strength(settings.smoothing_strength);
worker.set_noise_gate(settings.noise_gate_db); worker.set_noise_gate(settings.noise_gate_db);
let library_worker = LibraryWorker::spawn(); let library_worker = LibraryWorker::spawn();
@ -354,7 +378,7 @@ impl App {
} }
} }
/// sets the top-level playback mode. swaps the active/inactive settings slots and re-applies the worker config when the mode actually changes. /// sets the top-level playback mode, swapping the per-mode settings slots and re-applying worker config on an actual change.
pub fn set_playback_mode(&mut self, mode: PlaybackMode) { pub fn set_playback_mode(&mut self, mode: PlaybackMode) {
let prev = self.playback_mode; let prev = self.playback_mode;
self.playback_mode = mode; self.playback_mode = mode;
@ -383,6 +407,8 @@ impl App {
self.settings.strength, self.settings.strength,
); );
self.worker.set_gpu_blend(self.settings.gpu_blend); 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_noise_gate(self.settings.noise_gate_db); self.worker.set_noise_gate(self.settings.noise_gate_db);
} }
@ -565,7 +591,7 @@ impl App {
self.show_settings && x >= viewport_width - player::SETTINGS_W && x <= viewport_width self.show_settings && x >= viewport_width - player::SETTINGS_W && x <= viewport_width
} }
/// scans a folder, replaces the library, queues art, and starts decoding the first track. /// replaces the library from a folder scan and begins decoding the leading track.
fn apply_picked_folder(&mut self, folder: PathBuf) { fn apply_picked_folder(&mut self, folder: PathBuf) {
#[cfg(all(target_os = "ios", debug_assertions))] #[cfg(all(target_os = "ios", debug_assertions))]
eprintln!("[Rust.dbg] apply_picked_folder enter path={}", folder.display()); eprintln!("[Rust.dbg] apply_picked_folder enter path={}", folder.display());
@ -880,6 +906,8 @@ impl App {
| Message::SetDetail(_) | Message::SetDetail(_)
| Message::SetStrength(_) | Message::SetStrength(_)
| Message::SetGpuBlend(_) | Message::SetGpuBlend(_)
| Message::SetSmoothingTilt(_)
| Message::SetSmoothingStrength(_)
| Message::SetNoiseGate(_) | Message::SetNoiseGate(_)
| Message::ResetSettings, | Message::ResetSettings,
); );
@ -1066,6 +1094,14 @@ impl App {
self.settings.gpu_blend = v.clamp(0.0, 1.0); self.settings.gpu_blend = v.clamp(0.0, 1.0);
self.worker.set_gpu_blend(self.settings.gpu_blend); self.worker.set_gpu_blend(self.settings.gpu_blend);
} }
Message::SetSmoothingTilt(v) => {
self.settings.smoothing_tilt = v.clamp(0.0, 1.0);
self.worker.set_smoothing_tilt(self.settings.smoothing_tilt);
}
Message::SetSmoothingStrength(v) => {
self.settings.smoothing_strength = v.clamp(0.0, 1.0);
self.worker.set_smoothing_strength(self.settings.smoothing_strength);
}
Message::SetNoiseGate(v) => { Message::SetNoiseGate(v) => {
self.settings.noise_gate_db = v.clamp(-100.0, 0.0); self.settings.noise_gate_db = v.clamp(-100.0, 0.0);
self.worker.set_noise_gate(self.settings.noise_gate_db); self.worker.set_noise_gate(self.settings.noise_gate_db);

View File

@ -493,6 +493,22 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere
(s.fft / 2).max(64).trailing_zeros(), (s.fft / 2).max(64).trailing_zeros(),
Message::SetHop, Message::SetHop,
), ),
slider_row(
"tilt",
s.smoothing_tilt,
0.0..=1.0,
0.01,
format!("{:.2}", s.smoothing_tilt),
Message::SetSmoothingTilt,
),
slider_row(
"strength",
s.smoothing_strength,
0.0..=1.0,
0.01,
format!("{:.2}", s.smoothing_strength),
Message::SetSmoothingStrength,
),
Space::new().height(Length::Fixed(8.0)), Space::new().height(Length::Fixed(8.0)),
section_label("cepstral smoothing"), section_label("cepstral smoothing"),
slider_row( slider_row(
@ -747,7 +763,7 @@ fn settings_panel_style(_theme: &Theme) -> container::Style {
} }
} }
/// bottom transport bar. file-mode renders the local skip/play/scrub set; capture mode renders the metadata strip with a PiP toggle. /// bottom transport bar, branched between the file-mode skip/play/scrub set and the capture-mode metadata strip with PiP toggle.
fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let bar: Element<'_, Message, Theme, iced_wgpu::Renderer> = match app.playback_mode { let bar: Element<'_, Message, Theme, iced_wgpu::Renderer> = match app.playback_mode {
PlaybackMode::Local => local_transport_bar(app), PlaybackMode::Local => local_transport_bar(app),

View File

@ -45,7 +45,7 @@ pub struct ViewportHandle {
/// marks an active touch originating over the sidebar, routing vertical drags to wheel scrolls. /// marks an active touch originating over the sidebar, routing vertical drags to wheel scrolls.
touch_in_sidebar: bool, touch_in_sidebar: bool,
/// classification of an active touch over the settings panel: pending until SLOP, then tap/drag/scroll. /// classification of an active touch over the settings panel into pending, tap, drag, or scroll.
settings_touch: SettingsTouch, settings_touch: SettingsTouch,
pub state: App, pub state: App,
@ -401,7 +401,7 @@ impl ViewportHandle {
self.state.take_pending_pip_request() self.state.take_pending_pip_request()
} }
/// returns a cloned handle to the most recent analyzer frame pair for read-only host inspection (PiP mirror, debug). /// returns a cloned handle to the most recent analyzer frame pair.
pub fn frame_data_snapshot(&self) -> std::sync::Arc<Vec<crate::analyzer::FrameData>> { pub fn frame_data_snapshot(&self) -> std::sync::Arc<Vec<crate::analyzer::FrameData>> {
self.state.frame_data.clone() self.state.frame_data.clone()
} }

View File

@ -1,5 +1,5 @@
// drops empty placeholders into dist/ if the wasm-pack output is missing, so the server bin compiles standalone via cargo check. // drops empty dist/ placeholders, keeping the server bin standalone-compilable.
// the real bytes get written by scripts/web/build.sh phase 1 (wasm-pack) right before phase 2 (cargo build) embeds them. // real bytes populated by the scripts/web/build.sh wasm-pack stage.
fn main() { fn main() {
let dist = std::path::Path::new("dist"); let dist = std::path::Path::new("dist");

View File

@ -49,7 +49,7 @@ impl App {
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
} }
/// renders one frame: refreshes synthetic spectrum frames, ingests them into the pipeline state, uploads globals and bin packs, and dispatches the render pass. /// renders one frame of the visualizer at the surface's current size.
fn render(&mut self) { fn render(&mut self) {
let now = web_sys::window() let now = web_sys::window()
.and_then(|w| w.performance()) .and_then(|w| w.performance())
@ -268,7 +268,7 @@ pub fn start_on_canvas(canvas: HtmlCanvasElement) -> Result<(), JsValue> {
Ok(()) Ok(())
} }
/// constructs the wgpu instance, requests an adapter against the canvas, configures the surface, and builds the visualizer pipeline. /// builds the App against the canvas, including the wgpu instance, adapter, surface configuration, and visualizer pipeline.
async fn build_app( async fn build_app(
canvas: HtmlCanvasElement, canvas: HtmlCanvasElement,
width: u32, width: u32,
@ -333,7 +333,7 @@ async fn build_app(
}) })
} }
/// rAF-driven loop: the closure schedules its own re-fire after each render. publishes the App into APP_HANDLE so the resize export can reach it. /// rAF-driven render loop publishing the App into APP_HANDLE for the resize export.
fn run_loop(app: App) { fn run_loop(app: App) {
let app = Rc::new(RefCell::new(app)); let app = Rc::new(RefCell::new(app));
APP_HANDLE.with(|cell| { APP_HANDLE.with(|cell| {

View File

@ -29,7 +29,7 @@
'gourded': { fft: 16384, hop: 2048 }, 'gourded': { fft: 16384, hop: 2048 },
'moron': { fft: 16384, hop: 2048 }, 'moron': { fft: 16384, hop: 2048 },
'never give an angel a front': { fft: 8192, hop: 4096 }, 'never give an angel a front': { fft: 8192, hop: 4096 },
"now you're speaking my language":{ fft: 16384, hop: 2048 }, 'now youre speaking my language': { fft: 16384, hop: 2048 },
'ornery': { fft: 16384, hop: 2048 }, 'ornery': { fft: 16384, hop: 2048 },
'quicksand': { fft: 16384, hop: 2048 }, 'quicksand': { fft: 16384, hop: 2048 },
'stolen art': { fft: 8192, hop: 2048 }, 'stolen art': { fft: 8192, hop: 2048 },
@ -185,6 +185,7 @@
const outBins = new Float32Array(NUM_BINS); const outBins = new Float32Array(NUM_BINS);
let audioCtx = null; let audioCtx = null;
let srcNode = null;
let analyser = null; let analyser = null;
let gainNode = null; let gainNode = null;
let fftBuf = null; let fftBuf = null;
@ -199,13 +200,13 @@
const Ctor = window.AudioContext || window.webkitAudioContext; const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return; if (!Ctor) return;
audioCtx = new Ctor(); audioCtx = new Ctor();
const src = audioCtx.createMediaElementSource(audio); srcNode = audioCtx.createMediaElementSource(audio);
analyser = audioCtx.createAnalyser(); analyser = audioCtx.createAnalyser();
analyser.fftSize = DEFAULT_FFT; analyser.fftSize = DEFAULT_FFT;
analyser.smoothingTimeConstant = 0.2; analyser.smoothingTimeConstant = 0.2;
gainNode = audioCtx.createGain(); gainNode = audioCtx.createGain();
gainNode.gain.value = isMuted ? 0 : 1; gainNode.gain.value = isMuted ? 0 : 1;
src.connect(analyser); srcNode.connect(analyser);
analyser.connect(gainNode); analyser.connect(gainNode);
gainNode.connect(audioCtx.destination); gainNode.connect(audioCtx.destination);
audio.muted = false; audio.muted = false;
@ -254,13 +255,30 @@
}); });
// applies the per-track FFT and hop override, falling back to defaults on no match. // applies the per-track FFT and hop override, falling back to defaults on no match.
function normalizeTrackName(s) {
return (s || '').toLowerCase().replace(/['"]/g, '').replace(/[_\-\s]+/g, ' ').trim();
}
const TRACK_PARAMS_NORM = {};
for (const k of Object.keys(TRACK_PARAMS)) {
TRACK_PARAMS_NORM[normalizeTrackName(k)] = TRACK_PARAMS[k];
}
function applyTrackParams(name) { function applyTrackParams(name) {
const key = (name || '').trim().toLowerCase(); const key = normalizeTrackName(name);
const params = TRACK_PARAMS[key] || { fft: DEFAULT_FFT, hop: DEFAULT_HOP }; const hit = TRACK_PARAMS_NORM[key];
const params = hit || { fft: DEFAULT_FFT, hop: DEFAULT_HOP };
console.log('[yrxtls] track', JSON.stringify(name), 'key', JSON.stringify(key), 'params', params, hit ? '(override)' : '(default)');
currentHop = params.hop; currentHop = params.hop;
if (analyser && analyser.fftSize !== params.fft) { if (analyser && srcNode && gainNode && analyser.fftSize !== params.fft) {
const smoothing = analyser.smoothingTimeConstant;
try { srcNode.disconnect(analyser); } catch (e) {}
try { analyser.disconnect(gainNode); } catch (e) {}
analyser = audioCtx.createAnalyser();
analyser.fftSize = params.fft; analyser.fftSize = params.fft;
analyser.smoothingTimeConstant = smoothing;
srcNode.connect(analyser);
analyser.connect(gainNode);
fftBuf = new Float32Array(analyser.frequencyBinCount); fftBuf = new Float32Array(analyser.frequencyBinCount);
console.log('[yrxtls] analyser recreated at fftSize', analyser.fftSize, 'bins', analyser.frequencyBinCount);
} }
} }