feat(linebed): 4096-point FFT, 512 bands, linear scaling

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)
This commit is contained in:
vicrodh committed 2026-03-12 12:27:40 -06:00
1 parent f7521e7fd1
commit d4fa3cd28f
4 files changed
+26 -41

No files matched your search

@@ -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.
+3 -2
View File
@@ -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;
+2 -2
View File
@@ -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)
@@ -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);