From d4fa3cd28f7ea6effa8734eb1f679c4c450ebdbf Mon Sep 17 00:00:00 2001 From: vicrodh Date: Thu, 12 Mar 2026 12:27:40 -0600 Subject: [PATCH] feat(linebed): 4096-point FFT, 512 bands, linear scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - FFT size 1024 → 4096 (10.7 Hz bin resolution vs 43 Hz before) - Spectral bands 190 → 512 (closer to musicvid's 1024) - Remove pow(0.55) compression, use linear scaling (rms * 25) so frontend exponential transform has full dynamic range - Reduce temporal smoothing 0.80 → 0.30 for responsive peaks Frontend: - Remove power-law redistribution (backend bands already log-scaled, was applying double perceptual weighting causing uniform plateaus) - Restore 9-point 3-pass smoothing (now proportional: 9/512 = 1.76% vs musicvid's 9/1024 = 0.88%) - NUM_BANDS matches new backend count (512) --- .../qbz-audio/src/analysis/spectral_ribbon.rs | 11 +++-- crates/qbz-audio/src/visualizer/mod.rs | 5 +- src-tauri/src/visualizer/fft_processor.rs | 4 +- .../immersive/panels/LinebedPanel.svelte | 47 ++++++------------- 4 files changed, 26 insertions(+), 41 deletions(-) diff --git a/crates/qbz-audio/src/analysis/spectral_ribbon.rs b/crates/qbz-audio/src/analysis/spectral_ribbon.rs index d708757f4..86185f715 100644 --- a/crates/qbz-audio/src/analysis/spectral_ribbon.rs +++ b/crates/qbz-audio/src/analysis/spectral_ribbon.rs @@ -44,10 +44,10 @@ impl SpectralAnalyzer { smoothing_factor: f32, ) -> Self { let clamped_fft = match fft_size { - 512 | 1024 | 2048 => fft_size, + 512 | 1024 | 2048 | 4096 | 8192 => fft_size, _ => 1024, }; - let clamped_bands = num_bands.clamp(48, 192); + let clamped_bands = num_bands.clamp(48, 1024); let clamped_rate = update_rate_hz.clamp(20, 60); let clamped_smoothing = smoothing_factor.clamp(0.0, 0.98); @@ -122,14 +122,15 @@ impl SpectralAnalyzer { count += 1; } - // RMS energy + soft compression for a stable visual dynamic range. + // RMS energy with linear scaling — no power compression so the + // frontend's exponential transform has full dynamic range to work with. let rms = if count > 0 { (sum / count as f32).sqrt() } else { 0.0 }; - let compressed = (rms * 18.0).powf(0.55).clamp(0.0, 1.0); - self.bands_raw[band_idx] = compressed; + let scaled = (rms * 25.0).clamp(0.0, 1.0); + self.bands_raw[band_idx] = scaled; } // Exponential smoothing with fast attack / slower release. diff --git a/crates/qbz-audio/src/visualizer/mod.rs b/crates/qbz-audio/src/visualizer/mod.rs index 669c9d1bc..7a00cf17d 100644 --- a/crates/qbz-audio/src/visualizer/mod.rs +++ b/crates/qbz-audio/src/visualizer/mod.rs @@ -22,8 +22,9 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; /// Number of frequency bins to send to frontend pub const NUM_BARS: usize = 16; -/// FFT size (must be power of 2) -pub const FFT_SIZE: usize = 1024; +/// FFT size (must be power of 2). 4096 gives ~10.7 Hz bin resolution at 44.1 kHz, +/// enabling detailed spectral analysis for visualizers like Linebed. +pub const FFT_SIZE: usize = 4096; /// Target frames per second for visualization updates pub const TARGET_FPS: u64 = 30; diff --git a/src-tauri/src/visualizer/fft_processor.rs b/src-tauri/src/visualizer/fft_processor.rs index d9dd66d5c..7e6477528 100644 --- a/src-tauri/src/visualizer/fft_processor.rs +++ b/src-tauri/src/visualizer/fft_processor.rs @@ -36,9 +36,9 @@ pub fn start_visualizer_thread(state: VisualizerState, app_handle: AppHandle) { /// Number of energy bands for the Energy Bands visualizer const NUM_ENERGY_BANDS: usize = 5; -const NUM_SPECTRAL_BANDS: usize = 190; +const NUM_SPECTRAL_BANDS: usize = 512; const SPECTRAL_UPDATE_RATE_HZ: u32 = 58; -const SPECTRAL_SMOOTHING: f32 = 0.80; +const SPECTRAL_SMOOTHING: f32 = 0.30; /// Energy band frequency ranges (Hz): /// Sub-bass (20-60), Bass (60-250), Mids (250-2k), Presence (2k-6k), Air (6k-20k) diff --git a/src/lib/components/immersive/panels/LinebedPanel.svelte b/src/lib/components/immersive/panels/LinebedPanel.svelte index 6d24d139d..34b3b2b95 100644 --- a/src/lib/components/immersive/panels/LinebedPanel.svelte +++ b/src/lib/components/immersive/panels/LinebedPanel.svelte @@ -42,19 +42,15 @@ let isInitialized = false; // Linebed parameters - const NUM_BANDS = 190; // viz:spectral sends 190 bands + const NUM_BANDS = 512; // Backend sends 512 log-scaled bands (4096-point FFT) const NUM_LINES = 120; // Dense terrain (musicvid.org uses 200 with WebGL, 120 is Canvas 2D safe) const SMOOTHING = 0.03; // Temporal smoothing (musicvid: smoothingTimeConstant 0.03) - // Spectrum processing params — scaled for 190 bins (musicvid uses 1024 visual bins) + // Spectrum processing params — scaled for 512 bins // musicvid: 9 points / 1024 bins = 0.88% width per pass - // ours: 5 points / 190 bins = 2.6% width per pass (comparable ratio) - const SMOOTHING_PASSES = 2; - const SMOOTHING_POINTS = 5; - // Power-law reduced: musicvid maps 647→1024 bins (upsampling) at 2.5. - // We map 190→190 (no upsampling) — 2.5 stretches 33 bass bins to 50% width - // creating a uniform plateau. 1.5 keeps more frequency diversity visible. - const SPECTRUM_POWER = 1.5; + // ours: 9 points / 512 bins = 1.76% width per pass (close match) + const SMOOTHING_PASSES = 3; + const SMOOTHING_POINTS = 9; // Variable exponential transform (musicvid: Exponential settings) // Bass gets higher exponent = sharper peaks; treble gets lower = softer @@ -105,21 +101,8 @@ return result; } - // Power-law frequency redistribution (from musicvid.org transformToVisualBins) - // Remaps linear FFT bins so bass frequencies get more visual width. - // Without this, all energy clusters in the first ~30 bins (left side). - function transformToVisualBins(data: Float32Array): Float32Array { - const result = new Float32Array(NUM_BANDS); - for (let i = 0; i < NUM_BANDS; i++) { - const bin = Math.pow(i / NUM_BANDS, SPECTRUM_POWER) * (NUM_BANDS - 1); - const binFloor = Math.floor(bin); - const binCeil = Math.min(binFloor + 1, NUM_BANDS - 1); - const frac = bin - binFloor; - // Linear interpolation between neighboring bins for smoothness - result[i] = data[binFloor] * (1 - frac) + data[binCeil] * frac; - } - return result; - } + // NOTE: No power-law redistribution needed — backend already outputs + // log-scaled bands (20 Hz → 20 kHz, logarithmic spacing). // Normalize spectrum to [0, 1] range — required before exponential transform. // Without normalization, exponents 3-6 produce wrong results. @@ -223,28 +206,28 @@ const bytes = new Uint8Array(payload); const floats = new Float32Array(bytes.buffer); if (floats.length === NUM_BANDS) { - // Pipeline matches musicvid.org's SpectrumAnalyser transform chain: - // temporal → combineBins → normalize → tail → exponential → smoothing + // Pipeline: temporal → normalize → tail → exponential → smoothing + // (No power-law needed — backend bands are already log-scaled) // 1. Temporal smoothing (smoothingTimeConstant: 0.03) for (let i = 0; i < NUM_BANDS; i++) { smoothedData[i] = smoothedData[i] * SMOOTHING + floats[i] * (1 - SMOOTHING); } - // 2. Power-law frequency redistribution (combineBins / spectrumScale: 2.5) - const visualBins = transformToVisualBins(smoothedData); + // 2. Copy for processing (don't mutate smoothedData) + const processed = new Float32Array(smoothedData); // 3. Normalize to [0,1] (enableNormalizeTransform) - normalizeSpectrum(visualBins); + normalizeSpectrum(processed); // 4. Head margin (enableTailTransform — headMargin: 7) - applyTailTransform(visualBins); + applyTailTransform(processed); // 5. Variable exponential (enableExponentialTransform — exponents 3→6) - applyExponentialTransform(visualBins); + applyExponentialTransform(processed); // 6. Spatial smoothing (enableSmoothingTransform — 3 passes, 9 points) - const finalSpectrum = smoothSpectrum(visualBins); + const finalSpectrum = smoothSpectrum(processed); // Push processed snapshot into history every frame for fluid scrolling history[historyIndex].set(finalSpectrum);