Files
SoundFlix/FractalVisualizer/Audio/AudioAnalyzer.cs
T
Michal Vrba 6a1bdfb935 Add real-time note detection and NOTEDROP visualization
Implemented musical note detection in AudioAnalyzer, exposing dominant note and strength in AudioFeatures. Updated HUD to display detected note. Added "NOTEDROP" fractal preset that animates detected notes as colored glyphs. Extended bitmap font to support sharps. Increased fractal preset count and adjusted HUD layout.
2026-02-13 20:43:29 +01:00

393 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
using FractalVisualizer.Models;
using MathNet.Numerics.IntegralTransforms;
namespace FractalVisualizer.Audio;
public sealed class AudioAnalyzer : IDisposable
{
private readonly AudioCapture _capture;
private readonly int _fftSize;
private readonly int _spectrumBins;
private readonly float[] _hannWindow;
private readonly float[] _timeSamples;
private readonly Complex[] _fftBuffer;
private readonly float[] _spectrumAccumulator;
private readonly int[] _spectrumAccumulatorCounts;
private readonly Stopwatch _clock = new();
private readonly object _lifecycleSync = new();
private CancellationTokenSource? _cts;
private Task? _analysisTask;
private int _sampleRate;
private float _smoothedLevel;
private float _smoothedBass;
private float _smoothedMid;
private float _smoothedHigh;
private float _smoothedBeat;
private float _energyBaseline;
private float _smoothedBpm;
private float _lastBeatTimeSeconds = -10f;
private bool _beatGateOpen = true;
private readonly Queue<float> _beatIntervals = new();
private const int MaxBeatIntervals = 8;
private Snapshot _latestSnapshot;
private bool _disposed;
public AudioAnalyzer(AudioCapture capture, int fftSize = 2048, int spectrumBins = 64)
{
if (fftSize <= 0 || (fftSize & (fftSize - 1)) != 0)
{
throw new ArgumentOutOfRangeException(nameof(fftSize), "FFT size must be a power of 2.");
}
if (spectrumBins <= 0)
{
throw new ArgumentOutOfRangeException(nameof(spectrumBins));
}
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
_fftSize = fftSize;
_spectrumBins = spectrumBins;
_sampleRate = capture.SampleRate;
_hannWindow = BuildHannWindow(fftSize);
_timeSamples = new float[fftSize];
_fftBuffer = new Complex[fftSize];
_spectrumAccumulator = new float[spectrumBins];
_spectrumAccumulatorCounts = new int[spectrumBins];
_latestSnapshot = new Snapshot(AudioFeatures.CreateEmpty(spectrumBins));
_capture.SampleRateChanged += OnSampleRateChanged;
}
public int FftSize => _fftSize;
public int SpectrumBins => _spectrumBins;
public void Start()
{
ThrowIfDisposed();
lock (_lifecycleSync)
{
if (_analysisTask is not null)
{
return;
}
_clock.Restart();
_cts = new CancellationTokenSource();
_analysisTask = Task.Run(() => AnalysisLoop(_cts.Token), _cts.Token);
}
}
public void Stop()
{
Task? taskToWait;
CancellationTokenSource? ctsToDispose;
lock (_lifecycleSync)
{
if (_analysisTask is null || _cts is null)
{
return;
}
taskToWait = _analysisTask;
ctsToDispose = _cts;
_analysisTask = null;
_cts = null;
}
ctsToDispose.Cancel();
try
{
taskToWait.Wait();
}
catch (AggregateException ex) when (ex.InnerException is OperationCanceledException)
{
}
finally
{
ctsToDispose.Dispose();
}
}
public AudioFeatures GetLatestFeatures()
{
return Volatile.Read(ref _latestSnapshot).Features;
}
private void AnalysisLoop(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
if (!_capture.RingBuffer.TryRead(_timeSamples))
{
Thread.Sleep(2);
continue;
}
ProcessWindow();
}
}
private void ProcessWindow()
{
int sampleRate = Volatile.Read(ref _sampleRate);
int half = _fftSize / 2;
for (int i = 0; i < _fftSize; i++)
{
_fftBuffer[i] = new Complex(_timeSamples[i] * _hannWindow[i], 0.0);
}
Fourier.Forward(_fftBuffer, FourierOptions.Matlab);
Array.Clear(_spectrumAccumulator);
Array.Clear(_spectrumAccumulatorCounts);
float rmsAccumulator = 0f;
for (int i = 0; i < _fftSize; i++)
{
float sample = _timeSamples[i];
rmsAccumulator += sample * sample;
}
float rms = MathF.Sqrt(rmsAccumulator / _fftSize);
float bassEnergy = 0f;
float midEnergy = 0f;
float highEnergy = 0f;
float dominantMagnitude = 0f;
float dominantFrequency = 0f;
for (int bin = 1; bin < half; bin++)
{
float frequency = (sampleRate * bin) / (float)_fftSize;
float magnitude = (float)_fftBuffer[bin].Magnitude;
int spectrumIndex = (int)((bin / (float)half) * _spectrumBins);
if (spectrumIndex >= _spectrumBins)
{
spectrumIndex = _spectrumBins - 1;
}
_spectrumAccumulator[spectrumIndex] += magnitude;
_spectrumAccumulatorCounts[spectrumIndex]++;
if (frequency >= 20f && frequency < 200f)
{
bassEnergy += magnitude;
}
else if (frequency >= 200f && frequency < 2000f)
{
midEnergy += magnitude;
}
else if (frequency >= 2000f && frequency <= 16000f)
{
highEnergy += magnitude;
}
if (frequency >= 80f && frequency <= 2000f && magnitude > dominantMagnitude)
{
dominantMagnitude = magnitude;
dominantFrequency = frequency;
}
}
float[] spectrum = new float[_spectrumBins];
for (int i = 0; i < _spectrumBins; i++)
{
if (_spectrumAccumulatorCounts[i] == 0)
{
spectrum[i] = 0f;
continue;
}
spectrum[i] = _spectrumAccumulator[i] / _spectrumAccumulatorCounts[i];
}
float instantEnergy = rms * rms;
_energyBaseline = Lerp(_energyBaseline, instantEnergy, 0.02f);
float beat = 0f;
if (_energyBaseline > 1e-7f && instantEnergy > _energyBaseline * 1.5f)
{
beat = Math.Clamp((instantEnergy / _energyBaseline) - 1f, 0f, 1f);
}
_smoothedLevel = Smooth(_smoothedLevel, rms, 0.45f, 0.08f);
_smoothedBass = Smooth(_smoothedBass, bassEnergy, 0.40f, 0.10f);
_smoothedMid = Smooth(_smoothedMid, midEnergy, 0.30f, 0.08f);
_smoothedHigh = Smooth(_smoothedHigh, highEnergy, 0.30f, 0.08f);
_smoothedBeat = MathF.Max(beat, _smoothedBeat * 0.85f);
float now = (float)_clock.Elapsed.TotalSeconds;
bool beatHit = beat > 0.62f && _beatGateOpen;
if (beatHit)
{
float delta = now - _lastBeatTimeSeconds;
if (delta > 0.22f && delta < 2.0f)
{
_beatIntervals.Enqueue(delta);
while (_beatIntervals.Count > MaxBeatIntervals)
{
_beatIntervals.Dequeue();
}
}
_lastBeatTimeSeconds = now;
_beatGateOpen = false;
}
else if (beat < 0.20f)
{
_beatGateOpen = true;
}
float bpmInstant = CalculateBpm();
_smoothedBpm = Smooth(_smoothedBpm, bpmInstant, 0.18f, 0.04f);
int noteIndex = GetNoteIndexFromFrequency(dominantFrequency);
float dominantStrength = Clamp01(dominantMagnitude * 0.02f);
AudioFeatures features = new()
{
Level = _smoothedLevel,
Bass = _smoothedBass,
Mid = _smoothedMid,
High = _smoothedHigh,
Beat = _smoothedBeat,
Bpm = _smoothedBpm,
DominantFrequencyHz = dominantFrequency,
DetectedNoteIndex = noteIndex,
DetectedNoteStrength = dominantStrength,
Spectrum = spectrum,
TimeSeconds = now
};
Volatile.Write(ref _latestSnapshot, new Snapshot(features));
}
private void OnSampleRateChanged(int sampleRate)
{
Volatile.Write(ref _sampleRate, sampleRate);
}
private static float[] BuildHannWindow(int length)
{
float[] window = new float[length];
if (length == 1)
{
window[0] = 1f;
return window;
}
for (int i = 0; i < length; i++)
{
window[i] = 0.5f * (1f - MathF.Cos((2f * MathF.PI * i) / (length - 1)));
}
return window;
}
private static float Smooth(float current, float target, float attack, float release)
{
float factor = target > current ? attack : release;
return current + ((target - current) * factor);
}
private static float Lerp(float a, float b, float t)
{
return a + ((b - a) * t);
}
private float CalculateBpm()
{
if (_beatIntervals.Count == 0)
{
return 0f;
}
float sum = 0f;
foreach (float interval in _beatIntervals)
{
sum += interval;
}
float avg = sum / _beatIntervals.Count;
if (avg <= 1e-5f)
{
return 0f;
}
float bpm = 60f / avg;
return Math.Clamp(bpm, 40f, 220f);
}
private static int GetNoteIndexFromFrequency(float frequencyHz)
{
if (frequencyHz <= 0f)
{
return -1;
}
float midi = 69f + (12f * MathF.Log2(frequencyHz / 440f));
int note = ((int)MathF.Round(midi)) % 12;
if (note < 0)
{
note += 12;
}
return note;
}
private static float Clamp01(float value)
{
if (value < 0f)
{
return 0f;
}
if (value > 1f)
{
return 1f;
}
return value;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(AudioAnalyzer));
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
Stop();
_capture.SampleRateChanged -= OnSampleRateChanged;
_disposed = true;
}
private sealed class Snapshot(AudioFeatures features)
{
public AudioFeatures Features { get; } = features;
}
}