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:
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user