From 66ec445fdd3e05308ab6a96a3a5913c00b934f3b Mon Sep 17 00:00:00 2001 From: jess Date: Sat, 30 May 2026 15:01:02 -0700 Subject: [PATCH] Lots of bugfixes; New Smoothing function for softer dynamics which remain responsive but are less prone to jitter. --- src/analyzer.rs | 26 ++++++- src/analyzer_worker.rs | 14 ++++ src/android.rs | 2 +- src/engine.rs | 2 +- src/gpu_dsp.rs | 4 +- src/processor.rs | 145 ++++++++++++++++++++++++++------------- src/ui/app.rs | 42 +++++++++++- src/ui/player.rs | 18 ++++- src/viewport.rs | 4 +- web/build.rs | 4 +- web/src/lib.rs | 6 +- web/yr_crystals_embed.js | 30 ++++++-- 12 files changed, 226 insertions(+), 71 deletions(-) diff --git a/src/analyzer.rs b/src/analyzer.rs index 5bfedfc..28397e7 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -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. pub fn step(&mut self, position: f64) -> Option<&[FrameData]> { 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) { if samples.is_empty() || channels == 0 { return; diff --git a/src/analyzer_worker.rs b/src/analyzer_worker.rs index 5883691..e8ed252 100644 --- a/src/analyzer_worker.rs +++ b/src/analyzer_worker.rs @@ -25,6 +25,8 @@ enum Cmd { SetNumBins(usize), SetSmoothing { granularity: i32, detail: i32, strength: f32 }, SetGpuBlend(f32), + SetSmoothingTilt(f32), + SetSmoothingStrength(f32), SetNoiseGate(f32), SetMode(AnalyzerMode), PushLivePcm { samples: Vec, sample_rate: u32, channels: u32 }, @@ -110,6 +112,16 @@ impl AnalyzerWorker { 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. pub fn set_noise_gate(&self, db: f32) { let _ = self.cmd_tx.send(Cmd::SetNoiseGate(db)); @@ -253,6 +265,8 @@ fn apply(analyzer: &mut Analyzer, cmd: Cmd) { strength, } => analyzer.set_smoothing_params(granularity, detail, strength), 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::SetMode(_) | Cmd::PushLivePcm { .. } => {} Cmd::Shutdown => {} diff --git a/src/android.rs b/src/android.rs index 9ece954..9642257 100644 --- a/src/android.rs +++ b/src/android.rs @@ -537,7 +537,7 @@ fn redirect_stdio_to_logcat() { .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)] pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapture<'a>( _env: JNIEnv<'a>, diff --git a/src/engine.rs b/src/engine.rs index c08f1bd..d10ba45 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -66,7 +66,7 @@ impl AudioEngine { 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"))] pub fn with_output_device(name: Option<&str>) -> Result { #[cfg(debug_assertions)] diff --git a/src/gpu_dsp.rs b/src/gpu_dsp.rs index 61a734c..ec05bdb 100644 --- a/src/gpu_dsp.rs +++ b/src/gpu_dsp.rs @@ -18,7 +18,7 @@ struct Args { const ARGS_BYTES: u64 = std::mem::size_of::() 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; /// per-slot gpu resources owned exclusively by one pending submission. @@ -298,7 +298,7 @@ impl GpuFft1D { 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) { debug_assert_eq!(input.len(), self.n as usize); self.ensure_all_args(inverse); diff --git a/src/processor.rs b/src/processor.rs index c9013ed..e956c64 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -18,6 +18,7 @@ pub struct Spectrum { pub cepstrum: Vec, } + /// single-channel windowed-fft pipeline with optional gpu offload, log-spaced binning, and cepstral envelope blending. pub struct Processor { frame_size: usize, @@ -41,6 +42,12 @@ pub struct Processor { freqs_const: Vec, num_bins: usize, + bin_alphas: Vec, + bin_smoothed: Vec, + bin_initialized: bool, + smoothing_tilt: f64, + smoothing_strength: f64, + history: VecDeque>, smoothing_length: usize, @@ -53,6 +60,7 @@ pub struct Processor { detail: i32, cepstral_strength: f32, + #[allow(dead_code)] weave_caps: Option, } @@ -73,6 +81,11 @@ impl Processor { buffer: Vec::new(), sample_freqs: 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, history: VecDeque::new(), smoothing_length: 3, @@ -94,6 +107,18 @@ impl Processor { 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. pub fn set_smoothing(&mut self, history_length: usize) { self.smoothing_length = history_length.max(1); @@ -131,7 +156,7 @@ impl Processor { 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) { self.sample_freqs.clear(); self.freqs_const.clear(); @@ -140,25 +165,41 @@ impl Processor { let n = self.num_bins.max(1); let min_freq = 40.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 = Vec::with_capacity(n + 1); - let mut display_edges: Vec = Vec::with_capacity(n + 1); + let mut edges: Vec = Vec::with_capacity(n + 1); for i in 0..=n { let t = i as f64 / n as f64; - sample_edges.push(if linear { - 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)); + edges.push(min_freq * (max_freq / min_freq).powf(t)); } self.sample_freqs.push(10.0); self.freqs_const.push(10.0); for i in 0..n { - self.sample_freqs.push((sample_edges[i] + sample_edges[i + 1]) / 2.0); - self.freqs_const.push((display_edges[i] + display_edges[i + 1]) / 2.0); + let center = (edges[i] + 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 { mag = 0.0; } - mag_full[i] = mag; freqs_full[i] = freq; + mag_full[i] = mag; } let mut cep_buf: Vec = Vec::with_capacity(n); @@ -298,23 +339,22 @@ impl Processor { .map(|i| (cep_buf[i].re * cep_scale) as f32) .collect(); - if self.cepstral_strength > 0.0 { - let envelope = weave::idealize_curve( - &mag_full, - 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; - } + if self.bin_smoothed.len() != self.freqs_const.len() { + self.bin_smoothed = vec![-100.0; self.freqs_const.len()]; + self.bin_initialized = false; } - let mut current_db = vec![0.0_f64; self.freqs_const.len()]; for (i, &target) in self.sample_freqs.iter().enumerate() { 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 { let t = self.expand_threshold as f64; let r = self.expand_ratio as f64; @@ -325,6 +365,7 @@ impl Processor { } current_db[i] = val; } + self.bin_initialized = true; self.history.push_back(current_db); 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; -/// 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 { if size == 0 { return Vec::new(); @@ -387,27 +455,6 @@ fn build_window(size: usize) -> Vec { } } -/// 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. #[cfg(any(target_os = "ios", target_os = "android"))] fn default_caps() -> Option { diff --git a/src/ui/app.rs b/src/ui/app.rs index 086db01..23ce2e9 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -40,7 +40,7 @@ pub struct CaptureState { /// foreign-session playback flag pushed by the host MediaController callback. 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, /// 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. 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. #[serde(default = "default_noise_gate_db")] pub noise_gate_db: f32, @@ -175,6 +183,16 @@ fn default_noise_gate_db() -> f32 { -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 { fn default() -> Self { Self { @@ -194,6 +212,8 @@ impl Default for Settings { detail: 50, strength: 0.0, gpu_blend: 0.7, + smoothing_tilt: default_smoothing_tilt(), + smoothing_strength: default_smoothing_strength(), noise_gate_db: default_noise_gate_db(), } } @@ -245,6 +265,8 @@ pub enum Message { SetDetail(i32), SetStrength(f32), SetGpuBlend(f32), + SetSmoothingTilt(f32), + SetSmoothingStrength(f32), SetNoiseGate(f32), SetOutputDevice(Option), SetInputDevice(Option), @@ -302,6 +324,8 @@ impl App { worker.set_dsp_params(settings.fft as usize, settings.hop as usize); worker.set_smoothing(settings.granularity, settings.detail, settings.strength); 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); 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) { let prev = self.playback_mode; self.playback_mode = mode; @@ -383,6 +407,8 @@ impl App { self.settings.strength, ); 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); } @@ -565,7 +591,7 @@ impl App { 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) { #[cfg(all(target_os = "ios", debug_assertions))] eprintln!("[Rust.dbg] apply_picked_folder enter path={}", folder.display()); @@ -880,6 +906,8 @@ impl App { | Message::SetDetail(_) | Message::SetStrength(_) | Message::SetGpuBlend(_) + | Message::SetSmoothingTilt(_) + | Message::SetSmoothingStrength(_) | Message::SetNoiseGate(_) | Message::ResetSettings, ); @@ -1066,6 +1094,14 @@ impl App { self.settings.gpu_blend = v.clamp(0.0, 1.0); 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) => { 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 14fc042..ddbb90c 100644 --- a/src/ui/player.rs +++ b/src/ui/player.rs @@ -493,6 +493,22 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere (s.fft / 2).max(64).trailing_zeros(), 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)), section_label("cepstral smoothing"), 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> { let bar: Element<'_, Message, Theme, iced_wgpu::Renderer> = match app.playback_mode { PlaybackMode::Local => local_transport_bar(app), diff --git a/src/viewport.rs b/src/viewport.rs index 714e53b..198e213 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -45,7 +45,7 @@ pub struct ViewportHandle { /// marks an active touch originating over the sidebar, routing vertical drags to wheel scrolls. 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, pub state: App, @@ -401,7 +401,7 @@ impl ViewportHandle { 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> { self.state.frame_data.clone() } diff --git a/web/build.rs b/web/build.rs index 7432f49..8637d87 100644 --- a/web/build.rs +++ b/web/build.rs @@ -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. -// the real bytes get written by scripts/web/build.sh phase 1 (wasm-pack) right before phase 2 (cargo build) embeds them. +// drops empty dist/ placeholders, keeping the server bin standalone-compilable. +// real bytes populated by the scripts/web/build.sh wasm-pack stage. fn main() { let dist = std::path::Path::new("dist"); diff --git a/web/src/lib.rs b/web/src/lib.rs index 17dd173..a994eee 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -49,7 +49,7 @@ impl App { 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) { let now = web_sys::window() .and_then(|w| w.performance()) @@ -268,7 +268,7 @@ pub fn start_on_canvas(canvas: HtmlCanvasElement) -> Result<(), JsValue> { 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( canvas: HtmlCanvasElement, 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) { let app = Rc::new(RefCell::new(app)); APP_HANDLE.with(|cell| { diff --git a/web/yr_crystals_embed.js b/web/yr_crystals_embed.js index 22e6223..d8748b2 100644 --- a/web/yr_crystals_embed.js +++ b/web/yr_crystals_embed.js @@ -29,7 +29,7 @@ 'gourded': { fft: 16384, hop: 2048 }, 'moron': { fft: 16384, hop: 2048 }, '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 }, 'quicksand': { fft: 16384, hop: 2048 }, 'stolen art': { fft: 8192, hop: 2048 }, @@ -185,6 +185,7 @@ const outBins = new Float32Array(NUM_BINS); let audioCtx = null; + let srcNode = null; let analyser = null; let gainNode = null; let fftBuf = null; @@ -199,13 +200,13 @@ const Ctor = window.AudioContext || window.webkitAudioContext; if (!Ctor) return; audioCtx = new Ctor(); - const src = audioCtx.createMediaElementSource(audio); + srcNode = audioCtx.createMediaElementSource(audio); analyser = audioCtx.createAnalyser(); analyser.fftSize = DEFAULT_FFT; analyser.smoothingTimeConstant = 0.2; gainNode = audioCtx.createGain(); gainNode.gain.value = isMuted ? 0 : 1; - src.connect(analyser); + srcNode.connect(analyser); analyser.connect(gainNode); gainNode.connect(audioCtx.destination); audio.muted = false; @@ -254,13 +255,30 @@ }); // 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) { - const key = (name || '').trim().toLowerCase(); - const params = TRACK_PARAMS[key] || { fft: DEFAULT_FFT, hop: DEFAULT_HOP }; + const key = normalizeTrackName(name); + 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; - 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.smoothingTimeConstant = smoothing; + srcNode.connect(analyser); + analyser.connect(gainNode); fftBuf = new Float32Array(analyser.frequencyBinCount); + console.log('[yrxtls] analyser recreated at fftSize', analyser.fftSize, 'bins', analyser.frequencyBinCount); } }