SpectrumBars.cs
using System;
using Sandbox.UI;
namespace Bimp;
/// <summary>
/// A row of bars that bounce to the audio of a <see cref="MediaPlayer"/>.
/// Uses the real spectrum (in dB, auto-ranged) when playing audio only, otherwise a gentle fake animation
/// (the engine doesn't expose the spectrum of video playback).
/// </summary>
public sealed class SpectrumBars : Panel
{
public MediaPlayer Player { get; set; }
public int BarCount { get; }
readonly Panel[] bars;
readonly float[] smoothed;
float[] levels;
/// <summary>
/// The engine's spectrum is linear FFT magnitudes: 0.001 to ~66, median ~0.2, the bass tens of dB above the treble
/// (measured). Bars show each band in dB within this window below the loudest band.
/// </summary>
const float RangeDb = 30;
/// <summary> Treble has far less energy than bass - lift it so the right side of the display moves too. </summary>
const float TiltDbPerOctave = 3;
/// <summary> The top of the window follows the loudest band at once and falls back this fast, so quiet and loud tracks (and any volume) fill the bars. </summary>
const float CeilingFallDbPerSecond = 6;
/// <summary> The lowest the window's top goes, so silence and near silence stay at the bottom. </summary>
const float MinCeilingDb = -6;
float ceilingDb = MinCeilingDb;
/// <summary> Each bar's shown height, 0-1 (for probes). </summary>
internal IReadOnlyList<float> Shown => smoothed;
public SpectrumBars() : this( 32 ) { }
public SpectrumBars( int count )
{
BarCount = count;
AddClass( "spectrumbars" );
bars = new Panel[count];
smoothed = new float[count];
for ( int i = 0; i < count; i++ )
bars[i] = Add.Panel( "bar" );
}
public override void Tick()
{
base.Tick();
var backend = Player?.Backend;
var spectrum = backend is not null ? backend.Spectrum : ReadOnlySpan<float>.Empty;
var amp = backend?.Amplitude ?? 0;
var playing = backend is not null && backend.Loaded && !(Player?.Paused ?? true);
var real = playing && spectrum.Length > 2;
if ( real ) Levels( spectrum );
for ( int i = 0; i < BarCount; i++ )
{
float v = 0;
if ( real )
{
v = Math.Clamp( (levels[i] - (ceilingDb - RangeDb)) / RangeDb, 0, 1 );
}
else if ( playing )
{
v = 0.35f + 0.25f * MathF.Sin( RealTime.Now * 3.1f + i * 0.7f ) * MathF.Sin( RealTime.Now * 1.7f + i * 0.31f ) + amp;
}
// fast attack, slow release
smoothed[i] = v > smoothed[i] ? v : MathX.Lerp( smoothed[i], v, RealTime.Delta * 8.0f );
bars[i].Style.Height = Length.Percent( (smoothed[i] * 100.0f).Clamp( 3, 100 ) );
}
}
/// <summary>
/// Each bar's band level in dB (tilted, see <see cref="TiltDbPerOctave"/>) into <see cref="levels"/>, and the window's
/// top moved to the loudest. Bands widen towards the treble (squared), skipping the DC bin and the top third (above
/// ~16 kHz at 48 kHz, where there's next to nothing).
/// </summary>
void Levels( ReadOnlySpan<float> spectrum )
{
levels ??= new float[BarCount];
var usable = Math.Max( BarCount, spectrum.Length * 2 / 3 );
var peak = float.MinValue;
for ( int i = 0; i < BarCount; i++ )
{
// at least one bin of its own each, so the bass bars don't all show the same one
int Edge( int k ) => 1 + k + (int)(MathF.Pow( k / (float)BarCount, 2 ) * (usable - BarCount));
var start = Edge( i );
var end = Edge( i + 1 );
float m = 0;
for ( int s = start; s < end && s < spectrum.Length; s++ )
m = MathF.Max( m, spectrum[s] );
var center = (start + end) * 0.5f;
levels[i] = 20 * MathF.Log10( m + 1e-5f ) + TiltDbPerOctave * MathF.Log2( center );
peak = MathF.Max( peak, levels[i] );
}
ceilingDb = peak > ceilingDb ? peak : MathF.Max( MinCeilingDb, ceilingDb - CeilingFallDbPerSecond * RealTime.Delta );
}
}