impl
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-mgcb": {
|
||||||
|
"version": "3.8.4.1",
|
||||||
|
"commands": [
|
||||||
|
"mgcb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dotnet-mgcb-editor": {
|
||||||
|
"version": "3.8.4.1",
|
||||||
|
"commands": [
|
||||||
|
"mgcb-editor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dotnet-mgcb-editor-linux": {
|
||||||
|
"version": "3.8.4.1",
|
||||||
|
"commands": [
|
||||||
|
"mgcb-editor-linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dotnet-mgcb-editor-windows": {
|
||||||
|
"version": "3.8.4.1",
|
||||||
|
"commands": [
|
||||||
|
"mgcb-editor-windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dotnet-mgcb-editor-mac": {
|
||||||
|
"version": "3.8.4.1",
|
||||||
|
"commands": [
|
||||||
|
"mgcb-editor-mac"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "C#: FractalVisualizer Debug",
|
||||||
|
"type": "dotnet",
|
||||||
|
"request": "launch",
|
||||||
|
"projectPath": "${workspaceFolder}/FractalVisualizer.csproj"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
using System;
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
AudioFeatures features = new()
|
||||||
|
{
|
||||||
|
Level = _smoothedLevel,
|
||||||
|
Bass = _smoothedBass,
|
||||||
|
Mid = _smoothedMid,
|
||||||
|
High = _smoothedHigh,
|
||||||
|
Beat = _smoothedBeat,
|
||||||
|
Spectrum = spectrum,
|
||||||
|
TimeSeconds = (float)_clock.Elapsed.TotalSeconds
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FractalVisualizer.Models;
|
||||||
|
using NAudio.CoreAudioApi;
|
||||||
|
using NAudio.CoreAudioApi.Interfaces;
|
||||||
|
using NAudio.Wave;
|
||||||
|
|
||||||
|
namespace FractalVisualizer.Audio;
|
||||||
|
|
||||||
|
public sealed class AudioCapture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _stateSync = new();
|
||||||
|
|
||||||
|
private readonly MMDeviceEnumerator _deviceEnumerator;
|
||||||
|
private readonly EndpointNotificationClient _notificationClient;
|
||||||
|
|
||||||
|
private IWaveIn? _capture;
|
||||||
|
private bool _isRunning;
|
||||||
|
private bool _isRestarting;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public AudioCapture(int ringBufferCapacity = 48000 * 8)
|
||||||
|
{
|
||||||
|
RingBuffer = new AudioSampleRingBuffer(ringBufferCapacity);
|
||||||
|
CurrentMode = AudioInputMode.Microphone;
|
||||||
|
SampleRate = 48000;
|
||||||
|
|
||||||
|
_deviceEnumerator = new MMDeviceEnumerator();
|
||||||
|
_notificationClient = new EndpointNotificationClient(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AudioSampleRingBuffer RingBuffer { get; }
|
||||||
|
|
||||||
|
public AudioInputMode CurrentMode { get; private set; }
|
||||||
|
|
||||||
|
public int SampleRate { get; private set; }
|
||||||
|
|
||||||
|
public event Action<AudioInputMode>? ModeChanged;
|
||||||
|
|
||||||
|
public event Action<int>? SampleRateChanged;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
lock (_stateSync)
|
||||||
|
{
|
||||||
|
if (_isRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_deviceEnumerator.RegisterEndpointNotificationCallback(_notificationClient);
|
||||||
|
_isRunning = true;
|
||||||
|
StartCaptureForCurrentModeNoLock(clearBuffer: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
lock (_stateSync)
|
||||||
|
{
|
||||||
|
if (!_isRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRunning = false;
|
||||||
|
StopAndDisposeCaptureNoLock();
|
||||||
|
_deviceEnumerator.UnregisterEndpointNotificationCallback(_notificationClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SwitchMode(AudioInputMode newMode)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
lock (_stateSync)
|
||||||
|
{
|
||||||
|
if (CurrentMode == newMode)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrentMode = newMode;
|
||||||
|
if (_isRunning)
|
||||||
|
{
|
||||||
|
RestartCaptureNoLock(clearBuffer: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RingBuffer.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModeChanged?.Invoke(CurrentMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleDefaultDeviceChanged(DataFlow flow, Role role)
|
||||||
|
{
|
||||||
|
if (role != Role.Multimedia)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_stateSync)
|
||||||
|
{
|
||||||
|
if (!_isRunning || _isRestarting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CurrentMode == AudioInputMode.Microphone && flow == DataFlow.Capture)
|
||||||
|
{
|
||||||
|
RestartCaptureNoLock(clearBuffer: false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CurrentMode == AudioInputMode.SystemOutput && flow == DataFlow.Render)
|
||||||
|
{
|
||||||
|
RestartCaptureNoLock(clearBuffer: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestartCaptureNoLock(bool clearBuffer)
|
||||||
|
{
|
||||||
|
_isRestarting = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StopAndDisposeCaptureNoLock();
|
||||||
|
StartCaptureForCurrentModeNoLock(clearBuffer);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isRestarting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartCaptureForCurrentModeNoLock(bool clearBuffer)
|
||||||
|
{
|
||||||
|
if (clearBuffer)
|
||||||
|
{
|
||||||
|
RingBuffer.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_capture = CurrentMode switch
|
||||||
|
{
|
||||||
|
AudioInputMode.Microphone => CreateMicrophoneCapture(),
|
||||||
|
AudioInputMode.SystemOutput => CreateLoopbackCapture(),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(CurrentMode), CurrentMode, "Unknown audio input mode.")
|
||||||
|
};
|
||||||
|
|
||||||
|
_capture.DataAvailable += OnDataAvailable;
|
||||||
|
_capture.RecordingStopped += OnRecordingStopped;
|
||||||
|
_capture.StartRecording();
|
||||||
|
|
||||||
|
SampleRate = _capture.WaveFormat.SampleRate;
|
||||||
|
SampleRateChanged?.Invoke(SampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IWaveIn CreateMicrophoneCapture()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MMDevice defaultInput = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Multimedia);
|
||||||
|
return new WasapiCapture(defaultInput);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new WaveInEvent
|
||||||
|
{
|
||||||
|
WaveFormat = new WaveFormat(44100, 16, 1),
|
||||||
|
BufferMilliseconds = 25
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IWaveIn CreateLoopbackCapture()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MMDevice defaultOutput = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
||||||
|
return new WasapiLoopbackCapture(defaultOutput);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new WasapiLoopbackCapture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||||
|
{
|
||||||
|
IWaveIn? activeCapture = _capture;
|
||||||
|
if (activeCapture is null || e.BytesRecorded <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float[] samples = ConvertToMonoFloatSamples(e.Buffer, e.BytesRecorded, activeCapture.WaveFormat);
|
||||||
|
if (samples.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RingBuffer.Write(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] ConvertToMonoFloatSamples(byte[] buffer, int bytesRecorded, WaveFormat format)
|
||||||
|
{
|
||||||
|
int channels = Math.Max(1, format.Channels);
|
||||||
|
if (format.Encoding == WaveFormatEncoding.IeeeFloat && format.BitsPerSample == 32)
|
||||||
|
{
|
||||||
|
int totalSamples = bytesRecorded / sizeof(float);
|
||||||
|
int frames = totalSamples / channels;
|
||||||
|
if (frames <= 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<float>();
|
||||||
|
}
|
||||||
|
|
||||||
|
float[] mono = new float[frames];
|
||||||
|
WaveBuffer waveBuffer = new(buffer);
|
||||||
|
for (int frame = 0; frame < frames; frame++)
|
||||||
|
{
|
||||||
|
float sum = 0f;
|
||||||
|
int offset = frame * channels;
|
||||||
|
for (int ch = 0; ch < channels; ch++)
|
||||||
|
{
|
||||||
|
sum += waveBuffer.FloatBuffer[offset + ch];
|
||||||
|
}
|
||||||
|
|
||||||
|
mono[frame] = sum / channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format.Encoding == WaveFormatEncoding.Pcm && format.BitsPerSample == 16)
|
||||||
|
{
|
||||||
|
int totalSamples = bytesRecorded / sizeof(short);
|
||||||
|
int frames = totalSamples / channels;
|
||||||
|
if (frames <= 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<float>();
|
||||||
|
}
|
||||||
|
|
||||||
|
float[] mono = new float[frames];
|
||||||
|
WaveBuffer waveBuffer = new(buffer);
|
||||||
|
for (int frame = 0; frame < frames; frame++)
|
||||||
|
{
|
||||||
|
float sum = 0f;
|
||||||
|
int offset = frame * channels;
|
||||||
|
for (int ch = 0; ch < channels; ch++)
|
||||||
|
{
|
||||||
|
sum += waveBuffer.ShortBuffer[offset + ch] / 32768f;
|
||||||
|
}
|
||||||
|
|
||||||
|
mono[frame] = sum / channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<float>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Exception is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
lock (_stateSync)
|
||||||
|
{
|
||||||
|
if (!_isRunning || _isRestarting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RestartCaptureNoLock(clearBuffer: false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopAndDisposeCaptureNoLock()
|
||||||
|
{
|
||||||
|
if (_capture is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IWaveIn toDispose = _capture;
|
||||||
|
_capture = null;
|
||||||
|
|
||||||
|
toDispose.DataAvailable -= OnDataAvailable;
|
||||||
|
toDispose.RecordingStopped -= OnRecordingStopped;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
toDispose.StopRecording();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Device may already be removed/disposed.
|
||||||
|
}
|
||||||
|
|
||||||
|
toDispose.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(AudioCapture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stop();
|
||||||
|
_deviceEnumerator.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EndpointNotificationClient(AudioCapture owner) : IMMNotificationClient
|
||||||
|
{
|
||||||
|
private readonly AudioCapture _owner = owner;
|
||||||
|
|
||||||
|
public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDeviceId)
|
||||||
|
{
|
||||||
|
_owner.HandleDefaultDeviceChanged(flow, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnDeviceAdded(string pwstrDeviceId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnDeviceRemoved(string deviceId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnDeviceStateChanged(string deviceId, DeviceState newState)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FractalVisualizer.Audio;
|
||||||
|
|
||||||
|
public sealed class AudioSampleRingBuffer
|
||||||
|
{
|
||||||
|
private readonly float[] _buffer;
|
||||||
|
private readonly object _sync = new();
|
||||||
|
|
||||||
|
private int _writeIndex;
|
||||||
|
private int _readIndex;
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
public AudioSampleRingBuffer(int capacity)
|
||||||
|
{
|
||||||
|
if (capacity <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
_buffer = new float[capacity];
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Capacity => _buffer.Length;
|
||||||
|
|
||||||
|
public int Count
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
return _count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_writeIndex = 0;
|
||||||
|
_readIndex = 0;
|
||||||
|
_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write(ReadOnlySpan<float> samples)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < samples.Length; i++)
|
||||||
|
{
|
||||||
|
_buffer[_writeIndex] = samples[i];
|
||||||
|
_writeIndex = (_writeIndex + 1) % _buffer.Length;
|
||||||
|
|
||||||
|
if (_count == _buffer.Length)
|
||||||
|
{
|
||||||
|
_readIndex = (_readIndex + 1) % _buffer.Length;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRead(Span<float> destination)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_count < destination.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < destination.Length; i++)
|
||||||
|
{
|
||||||
|
destination[i] = _buffer[_readIndex];
|
||||||
|
_readIndex = (_readIndex + 1) % _buffer.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
_count -= destination.Length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
#----------------------------- Global Properties ----------------------------#
|
||||||
|
|
||||||
|
/outputDir:bin/$(Platform)
|
||||||
|
/intermediateDir:obj/$(Platform)
|
||||||
|
/platform:Windows
|
||||||
|
/config:
|
||||||
|
/profile:Reach
|
||||||
|
/compress:False
|
||||||
|
|
||||||
|
#-------------------------------- References --------------------------------#
|
||||||
|
|
||||||
|
|
||||||
|
#---------------------------------- Content ---------------------------------#
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RollForward>Major</RollForward>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<TieredCompilation>false</TieredCompilation>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>Icon.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MathNet.Numerics" Version="5.0.0" />
|
||||||
|
<PackageReference Include="MonoGame.Framework.WindowsDX" Version="3.8.*" />
|
||||||
|
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.*" />
|
||||||
|
<PackageReference Include="NAudio" Version="2.2.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System;
|
||||||
|
using FractalVisualizer.Audio;
|
||||||
|
using FractalVisualizer.Models;
|
||||||
|
using FractalVisualizer.Rendering;
|
||||||
|
using FractalVisualizer.UI;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
using Microsoft.Xna.Framework.Input;
|
||||||
|
|
||||||
|
namespace FractalVisualizer;
|
||||||
|
|
||||||
|
public class Game1 : Game
|
||||||
|
{
|
||||||
|
private readonly GraphicsDeviceManager _graphics;
|
||||||
|
|
||||||
|
private SpriteBatch? _spriteBatch;
|
||||||
|
private Texture2D? _pixel;
|
||||||
|
private HudBitmapFont? _hudFont;
|
||||||
|
|
||||||
|
private readonly AudioCapture _audioCapture;
|
||||||
|
private readonly AudioAnalyzer _audioAnalyzer;
|
||||||
|
private FractalRenderer? _fractalRenderer;
|
||||||
|
|
||||||
|
private KeyboardState _previousKeyboardState;
|
||||||
|
private AudioInputMode _currentInputMode;
|
||||||
|
private AudioFeatures _latestFeatures;
|
||||||
|
private bool _isPaused;
|
||||||
|
private bool _showHud = true;
|
||||||
|
private int _fractalPresetIndex;
|
||||||
|
private int _fps;
|
||||||
|
private int _frameCounter;
|
||||||
|
private TimeSpan _fpsElapsed;
|
||||||
|
private readonly (int Width, int Height)[] _windowedResolutions =
|
||||||
|
[
|
||||||
|
(1280, 720),
|
||||||
|
(1600, 900),
|
||||||
|
(1920, 1080)
|
||||||
|
];
|
||||||
|
private int _resolutionIndex = 0;
|
||||||
|
|
||||||
|
public Game1()
|
||||||
|
{
|
||||||
|
_graphics = new GraphicsDeviceManager(this);
|
||||||
|
_graphics.PreferredBackBufferWidth = 1280;
|
||||||
|
_graphics.PreferredBackBufferHeight = 720;
|
||||||
|
Content.RootDirectory = "Content";
|
||||||
|
IsMouseVisible = true;
|
||||||
|
Window.AllowUserResizing = true;
|
||||||
|
|
||||||
|
_audioCapture = new AudioCapture();
|
||||||
|
_audioAnalyzer = new AudioAnalyzer(_audioCapture, fftSize: 2048, spectrumBins: 64);
|
||||||
|
_currentInputMode = AudioInputMode.Microphone;
|
||||||
|
_latestFeatures = AudioFeatures.CreateEmpty(64);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Initialize()
|
||||||
|
{
|
||||||
|
_audioCapture.Start();
|
||||||
|
_audioAnalyzer.Start();
|
||||||
|
Window.Title = "Fractal Visualizer";
|
||||||
|
base.Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void LoadContent()
|
||||||
|
{
|
||||||
|
_spriteBatch = new SpriteBatch(GraphicsDevice);
|
||||||
|
_fractalRenderer = new FractalRenderer(GraphicsDevice);
|
||||||
|
_pixel = new Texture2D(GraphicsDevice, 1, 1, false, SurfaceFormat.Color);
|
||||||
|
_pixel.SetData([Color.White]);
|
||||||
|
_hudFont = new HudBitmapFont();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Update(GameTime gameTime)
|
||||||
|
{
|
||||||
|
KeyboardState keyboard = Keyboard.GetState();
|
||||||
|
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
|
||||||
|
{
|
||||||
|
Exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.Escape))
|
||||||
|
{
|
||||||
|
if (_graphics.IsFullScreen)
|
||||||
|
{
|
||||||
|
_graphics.ToggleFullScreen();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.F11))
|
||||||
|
{
|
||||||
|
ToggleFullScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.F))
|
||||||
|
{
|
||||||
|
ToggleFullScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.R))
|
||||||
|
{
|
||||||
|
CycleWindowResolution();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.Space))
|
||||||
|
{
|
||||||
|
_isPaused = !_isPaused;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.Tab))
|
||||||
|
{
|
||||||
|
_showHud = !_showHud;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.Left))
|
||||||
|
{
|
||||||
|
_fractalPresetIndex = Math.Max(0, _fractalPresetIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.Right))
|
||||||
|
{
|
||||||
|
_fractalPresetIndex = Math.Min(8, _fractalPresetIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPressed(keyboard, Keys.M))
|
||||||
|
{
|
||||||
|
_currentInputMode = _currentInputMode == AudioInputMode.Microphone
|
||||||
|
? AudioInputMode.SystemOutput
|
||||||
|
: AudioInputMode.Microphone;
|
||||||
|
_audioCapture.SwitchMode(_currentInputMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
_latestFeatures = _audioAnalyzer.GetLatestFeatures();
|
||||||
|
_fpsElapsed += gameTime.ElapsedGameTime;
|
||||||
|
_frameCounter++;
|
||||||
|
if (_fpsElapsed >= TimeSpan.FromSeconds(1))
|
||||||
|
{
|
||||||
|
_fps = _frameCounter;
|
||||||
|
_frameCounter = 0;
|
||||||
|
_fpsElapsed -= TimeSpan.FromSeconds(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Window.Title = $"Fractal Visualizer | {GetPresetName(_fractalPresetIndex)} | Mode: {_currentInputMode} | L:{_latestFeatures.Level:F3} B:{_latestFeatures.Bass:F2}";
|
||||||
|
_previousKeyboardState = keyboard;
|
||||||
|
base.Update(gameTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Draw(GameTime gameTime)
|
||||||
|
{
|
||||||
|
GraphicsDevice.Clear(Color.Black);
|
||||||
|
|
||||||
|
if (_fractalRenderer is not null)
|
||||||
|
{
|
||||||
|
float renderTime = _isPaused ? _latestFeatures.TimeSeconds : (float)gameTime.TotalGameTime.TotalSeconds;
|
||||||
|
_fractalRenderer.Draw(_latestFeatures, renderTime, _fractalPresetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_showHud && _spriteBatch is not null && _hudFont is not null && _pixel is not null)
|
||||||
|
{
|
||||||
|
_spriteBatch.Begin(samplerState: SamplerState.PointClamp, blendState: BlendState.AlphaBlend);
|
||||||
|
string modeText = _currentInputMode == AudioInputMode.Microphone ? "MIC" : "SYS";
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "MODE " + modeText + " M SWITCH", 12, 10, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "PRESET " + GetPresetName(_fractalPresetIndex) + " LEFT RIGHT", 12, 30, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "LEVEL " + _latestFeatures.Level.ToString("0.000"), 12, 50, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "BASS " + _latestFeatures.Bass.ToString("0.000"), 12, 70, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "MID " + _latestFeatures.Mid.ToString("0.000"), 12, 90, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "HIGH " + _latestFeatures.High.ToString("0.000"), 12, 110, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "BEAT " + _latestFeatures.Beat.ToString("0.000"), 12, 130, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "PAUSED " + (_isPaused ? "YES" : "NO"), 12, 150, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 170, 2, Color.White);
|
||||||
|
_hudFont.DrawText(_spriteBatch, _pixel, "F OR F11 FULLSCREEN R RESIZE", 12, 190, 2, Color.White);
|
||||||
|
_spriteBatch.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Draw(gameTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_audioAnalyzer.Dispose();
|
||||||
|
_audioCapture.Dispose();
|
||||||
|
_fractalRenderer?.Dispose();
|
||||||
|
_pixel?.Dispose();
|
||||||
|
_spriteBatch?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPressed(KeyboardState keyboardState, Keys key)
|
||||||
|
{
|
||||||
|
return keyboardState.IsKeyDown(key) && _previousKeyboardState.IsKeyUp(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetPresetName(int presetIndex)
|
||||||
|
{
|
||||||
|
return presetIndex switch
|
||||||
|
{
|
||||||
|
0 => "JULIA",
|
||||||
|
1 => "MANDELBULB",
|
||||||
|
2 => "KALEIDO",
|
||||||
|
3 => "FLAME",
|
||||||
|
4 => "PLASMA",
|
||||||
|
5 => "NEBULA",
|
||||||
|
6 => "FLAMEGRILL",
|
||||||
|
7 => "NOTES",
|
||||||
|
8 => "FIREWORKS",
|
||||||
|
_ => "JULIA"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleFullScreen()
|
||||||
|
{
|
||||||
|
_graphics.ToggleFullScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CycleWindowResolution()
|
||||||
|
{
|
||||||
|
if (_graphics.IsFullScreen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolutionIndex = (_resolutionIndex + 1) % _windowedResolutions.Length;
|
||||||
|
(int width, int height) = _windowedResolutions[_resolutionIndex];
|
||||||
|
_graphics.PreferredBackBufferWidth = width;
|
||||||
|
_graphics.PreferredBackBufferHeight = height;
|
||||||
|
_graphics.ApplyChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
namespace FractalVisualizer.Models;
|
||||||
|
|
||||||
|
public struct AudioFeatures
|
||||||
|
{
|
||||||
|
public float Level;
|
||||||
|
public float Bass;
|
||||||
|
public float Mid;
|
||||||
|
public float High;
|
||||||
|
public float Beat;
|
||||||
|
public float[] Spectrum;
|
||||||
|
public float TimeSeconds;
|
||||||
|
|
||||||
|
public static AudioFeatures CreateEmpty(int spectrumBins)
|
||||||
|
{
|
||||||
|
return new AudioFeatures
|
||||||
|
{
|
||||||
|
Spectrum = new float[spectrumBins]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace FractalVisualizer.Models;
|
||||||
|
|
||||||
|
public enum AudioInputMode
|
||||||
|
{
|
||||||
|
Microphone = 0,
|
||||||
|
SystemOutput = 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
using var game = new FractalVisualizer.Game1();
|
||||||
|
game.Run();
|
||||||
@@ -0,0 +1,924 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using FractalVisualizer.Models;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
|
||||||
|
namespace FractalVisualizer.Rendering;
|
||||||
|
|
||||||
|
public sealed class FractalRenderer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GraphicsDevice _graphicsDevice;
|
||||||
|
private readonly SpriteBatch _spriteBatch;
|
||||||
|
|
||||||
|
private readonly int _internalWidth;
|
||||||
|
private readonly int _internalHeight;
|
||||||
|
private readonly Texture2D _fractalTexture;
|
||||||
|
private readonly Color[] _pixelBuffer;
|
||||||
|
private readonly Color[] _postBuffer;
|
||||||
|
private readonly Color[] _previousFrame;
|
||||||
|
private readonly List<NoteParticle> _notes = [];
|
||||||
|
private readonly List<FireworkRocket> _fireworkRockets = [];
|
||||||
|
private readonly List<FireworkSpark> _fireworkSparks = [];
|
||||||
|
private readonly Random _rng = new();
|
||||||
|
|
||||||
|
private float _noteSpawnAccumulator;
|
||||||
|
private float _fireworkSpawnAccumulator;
|
||||||
|
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public FractalRenderer(GraphicsDevice graphicsDevice, int internalWidth = 320, int internalHeight = 180)
|
||||||
|
{
|
||||||
|
_graphicsDevice = graphicsDevice ?? throw new ArgumentNullException(nameof(graphicsDevice));
|
||||||
|
_spriteBatch = new SpriteBatch(_graphicsDevice);
|
||||||
|
_internalWidth = internalWidth;
|
||||||
|
_internalHeight = internalHeight;
|
||||||
|
_fractalTexture = new Texture2D(_graphicsDevice, _internalWidth, _internalHeight, false, SurfaceFormat.Color);
|
||||||
|
_pixelBuffer = new Color[_internalWidth * _internalHeight];
|
||||||
|
_postBuffer = new Color[_internalWidth * _internalHeight];
|
||||||
|
_previousFrame = new Color[_internalWidth * _internalHeight];
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(AudioFeatures features, float timeSeconds, int presetIndex)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
switch (presetIndex)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
RenderJulia(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
RenderMandelbulbStyle(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
RenderKaleidoscopeTunnel(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
RenderFlame(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
RenderPlasma(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
RenderNebula(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
RenderFlameGrill(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
RenderFloatingNotes(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
case 8:
|
||||||
|
RenderFireworks(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
RenderJulia(features, timeSeconds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyFeedbackAndCrt(features, timeSeconds);
|
||||||
|
_fractalTexture.SetData(_postBuffer);
|
||||||
|
|
||||||
|
Viewport viewport = _graphicsDevice.Viewport;
|
||||||
|
Rectangle destination = new(0, 0, viewport.Width, viewport.Height);
|
||||||
|
_spriteBatch.Begin(samplerState: SamplerState.LinearClamp, blendState: BlendState.Opaque);
|
||||||
|
_spriteBatch.Draw(_fractalTexture, destination, Color.White);
|
||||||
|
_spriteBatch.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFeedbackAndCrt(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float bass = Clamp01(features.Bass * 0.012f);
|
||||||
|
float high = Clamp01(features.High * 0.003f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
|
||||||
|
int shiftPixels = 1 + (int)MathF.Round((high * 2f) + (beat * 2f));
|
||||||
|
float trailStrength = Clamp01(0.16f + (bass * 0.28f) + (beat * 0.34f));
|
||||||
|
float glowBoost = (high * 0.08f) + (beat * 0.06f);
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float ny = (y / (_internalHeight - 1f)) * 2f - 1f;
|
||||||
|
float scanline = 0.90f + (0.10f * MathF.Sin(((y * 0.42f) + (timeSeconds * 22f)) * MathF.PI));
|
||||||
|
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
int index = (y * _internalWidth) + x;
|
||||||
|
Vector3 raw = _pixelBuffer[index].ToVector3();
|
||||||
|
Vector3 previous = _previousFrame[index].ToVector3();
|
||||||
|
|
||||||
|
Vector3 trailed = Vector3.Lerp(raw, previous, trailStrength);
|
||||||
|
|
||||||
|
int xr = Math.Min(_internalWidth - 1, x + shiftPixels);
|
||||||
|
int xb = Math.Max(0, x - shiftPixels);
|
||||||
|
int rightIndex = (y * _internalWidth) + xr;
|
||||||
|
int leftIndex = (y * _internalWidth) + xb;
|
||||||
|
|
||||||
|
float r = _pixelBuffer[rightIndex].R / 255f;
|
||||||
|
float g = trailed.Y;
|
||||||
|
float b = _pixelBuffer[leftIndex].B / 255f;
|
||||||
|
Vector3 crt = new(r, g, b);
|
||||||
|
|
||||||
|
float nx = (x / (_internalWidth - 1f)) * 2f - 1f;
|
||||||
|
float vignette = Clamp01(1f - ((nx * nx + ny * ny) * 0.42f));
|
||||||
|
crt *= scanline;
|
||||||
|
crt *= 0.5f + (vignette * 0.5f);
|
||||||
|
crt += raw * glowBoost;
|
||||||
|
|
||||||
|
crt.X = Clamp01(crt.X);
|
||||||
|
crt.Y = Clamp01(crt.Y);
|
||||||
|
crt.Z = Clamp01(crt.Z);
|
||||||
|
|
||||||
|
Color finalColor = new(crt.X, crt.Y, crt.Z, 1f);
|
||||||
|
_postBuffer[index] = finalColor;
|
||||||
|
_previousFrame[index] = finalColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderMandelbulbStyle(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float bass = Clamp01(features.Bass * 0.012f);
|
||||||
|
float mid = Clamp01(features.Mid * 0.003f);
|
||||||
|
float high = Clamp01(features.High * 0.0025f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
|
||||||
|
float power = 6f + (bass * 4f);
|
||||||
|
float detail = 28f + (high * 22f);
|
||||||
|
float rotation = timeSeconds * (0.25f + mid * 0.8f);
|
||||||
|
float aspect = _internalWidth / (float)_internalHeight;
|
||||||
|
|
||||||
|
float cosR = MathF.Cos(rotation);
|
||||||
|
float sinR = MathF.Sin(rotation);
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float sy = (y / (float)_internalHeight) * 2f - 1f;
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
float sx = ((x / (float)_internalWidth) * 2f - 1f) * aspect;
|
||||||
|
|
||||||
|
float rayX = sx;
|
||||||
|
float rayY = sy;
|
||||||
|
float rayZ = 1.65f - beat * 0.25f;
|
||||||
|
|
||||||
|
float march = 0f;
|
||||||
|
float glow = 0f;
|
||||||
|
float minDist = 10f;
|
||||||
|
|
||||||
|
int steps = (int)detail;
|
||||||
|
for (int step = 0; step < steps; step++)
|
||||||
|
{
|
||||||
|
float px = rayX * march;
|
||||||
|
float py = rayY * march;
|
||||||
|
float pz = rayZ - march;
|
||||||
|
|
||||||
|
float rpx = (px * cosR) - (pz * sinR);
|
||||||
|
float rpz = (px * sinR) + (pz * cosR);
|
||||||
|
|
||||||
|
float d = MandelbulbDistance(rpx, py, rpz, power);
|
||||||
|
minDist = MathF.Min(minDist, d);
|
||||||
|
glow += MathF.Exp(-20f * d) * (0.015f + high * 0.03f);
|
||||||
|
march += d * 0.95f;
|
||||||
|
|
||||||
|
if (d < 0.002f || march > 3.2f)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float hit = Clamp01(1f - (minDist * 110f));
|
||||||
|
float fog = Clamp01(1f - (march / 3.2f));
|
||||||
|
float pulse = beat * 0.45f;
|
||||||
|
|
||||||
|
float r = Clamp01(hit * (0.35f + bass * 1.2f + pulse) + glow * 1.3f);
|
||||||
|
float g = Clamp01(hit * (0.22f + mid * 1.1f) + glow * 0.6f);
|
||||||
|
float b = Clamp01(hit * (0.45f + high * 1.5f) + glow * 1.5f + fog * 0.08f);
|
||||||
|
|
||||||
|
_pixelBuffer[(y * _internalWidth) + x] = new Color(r, g, b, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderKaleidoscopeTunnel(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float bass = Clamp01(features.Bass * 0.01f);
|
||||||
|
float mid = Clamp01(features.Mid * 0.0035f);
|
||||||
|
float high = Clamp01(features.High * 0.0035f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
float level = Clamp01(features.Level * 3f);
|
||||||
|
|
||||||
|
float aspect = _internalWidth / (float)_internalHeight;
|
||||||
|
float tunnelSpeed = 0.45f + bass * 1.35f;
|
||||||
|
float spin = timeSeconds * (0.25f + mid * 1.1f);
|
||||||
|
float slices = 6f + MathF.Floor(high * 8f);
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float py = (y / (float)_internalHeight) * 2f - 1f;
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
float px = ((x / (float)_internalWidth) * 2f - 1f) * aspect;
|
||||||
|
float radius = MathF.Sqrt((px * px) + (py * py)) + 1e-5f;
|
||||||
|
float angle = MathF.Atan2(py, px) + spin;
|
||||||
|
|
||||||
|
float wedge = MathF.PI / slices;
|
||||||
|
angle = MathF.Abs((angle % (2f * wedge)) - wedge);
|
||||||
|
|
||||||
|
float spectrumDrive = GetSpectrumSample(features, radius * 8f + beat * 10f);
|
||||||
|
float distortion = spectrumDrive * (0.55f + high * 0.7f);
|
||||||
|
float tunnel = MathF.Sin((1f / radius) * 9f - timeSeconds * tunnelSpeed * 7f + distortion * 8f);
|
||||||
|
float rings = MathF.Sin(radius * 35f - timeSeconds * (2f + mid * 4f));
|
||||||
|
float edge = MathF.Exp(-10f * MathF.Abs(angle - wedge * 0.5f));
|
||||||
|
|
||||||
|
float pulse = beat * 0.6f;
|
||||||
|
float brightness = Clamp01((tunnel * 0.5f + 0.5f) * (0.4f + level) + edge * 0.5f + pulse * 0.3f);
|
||||||
|
float hueShift = (timeSeconds * 0.15f) + (beat * 0.25f);
|
||||||
|
|
||||||
|
float r = Clamp01(brightness * (0.5f + 0.5f * MathF.Sin(hueShift + 0.0f)) + rings * 0.08f + bass * 0.25f);
|
||||||
|
float g = Clamp01(brightness * (0.5f + 0.5f * MathF.Sin(hueShift + 2.09f)) + rings * 0.06f + mid * 0.25f);
|
||||||
|
float b = Clamp01(brightness * (0.5f + 0.5f * MathF.Sin(hueShift + 4.18f)) + rings * 0.10f + high * 0.35f);
|
||||||
|
|
||||||
|
_pixelBuffer[(y * _internalWidth) + x] = new Color(r, g, b, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float GetSpectrumSample(AudioFeatures features, float virtualIndex)
|
||||||
|
{
|
||||||
|
if (features.Spectrum is null || features.Spectrum.Length == 0)
|
||||||
|
{
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
int i0 = ((int)virtualIndex) % features.Spectrum.Length;
|
||||||
|
if (i0 < 0)
|
||||||
|
{
|
||||||
|
i0 += features.Spectrum.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
int i1 = (i0 + 1) % features.Spectrum.Length;
|
||||||
|
float t = virtualIndex - MathF.Floor(virtualIndex);
|
||||||
|
float sample = features.Spectrum[i0] + ((features.Spectrum[i1] - features.Spectrum[i0]) * t);
|
||||||
|
return Clamp01(sample * 0.06f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float MandelbulbDistance(float x, float y, float z, float power)
|
||||||
|
{
|
||||||
|
float zx = x;
|
||||||
|
float zy = y;
|
||||||
|
float zz = z;
|
||||||
|
float dr = 1f;
|
||||||
|
float r = 0f;
|
||||||
|
|
||||||
|
for (int i = 0; i < 7; i++)
|
||||||
|
{
|
||||||
|
r = MathF.Sqrt((zx * zx) + (zy * zy) + (zz * zz));
|
||||||
|
if (r > 2f)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
float theta = MathF.Acos(zz / (r + 1e-6f));
|
||||||
|
float phi = MathF.Atan2(zy, zx);
|
||||||
|
float zr = MathF.Pow(r, power);
|
||||||
|
|
||||||
|
dr = MathF.Pow(r, power - 1f) * power * dr + 1f;
|
||||||
|
|
||||||
|
float sinTheta = MathF.Sin(theta * power);
|
||||||
|
zx = zr * sinTheta * MathF.Cos(phi * power) + x;
|
||||||
|
zy = zr * sinTheta * MathF.Sin(phi * power) + y;
|
||||||
|
zz = zr * MathF.Cos(theta * power) + z;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.5f * MathF.Log(r + 1e-6f) * r / dr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderFlame(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float bass = Clamp01(features.Bass * 0.012f);
|
||||||
|
float high = Clamp01(features.High * 0.003f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
float level = Clamp01(features.Level * 3f);
|
||||||
|
|
||||||
|
float aspect = _internalWidth / (float)_internalHeight;
|
||||||
|
float flowSpeed = 0.65f + (bass * 1.8f);
|
||||||
|
float turbulence = 1.2f + (high * 3.0f);
|
||||||
|
float pulse = 0.2f + (beat * 0.8f);
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float y01 = y / (float)(_internalHeight - 1);
|
||||||
|
float fy = 1f - y01;
|
||||||
|
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
float x01 = x / (float)(_internalWidth - 1);
|
||||||
|
float fx = (x01 * 2f - 1f) * aspect;
|
||||||
|
|
||||||
|
float t = timeSeconds * flowSpeed;
|
||||||
|
float n1 = MathF.Sin((fx * 6.0f) + (fy * 7.5f) - t * 5.0f);
|
||||||
|
float n2 = MathF.Sin((fx * 12.0f - t * 2.1f) + MathF.Cos(fy * 10.0f + t * 3.3f));
|
||||||
|
float n3 = MathF.Sin((fx * 22.0f + t * 1.4f) + MathF.Sin(fy * 18.0f - t * 2.7f));
|
||||||
|
float noise = ((n1 * 0.55f) + (n2 * 0.30f) + (n3 * 0.15f)) * turbulence;
|
||||||
|
noise = noise * 0.5f + 0.5f;
|
||||||
|
|
||||||
|
float cone = 1f - MathF.Abs(fx) * (0.55f + fy * 0.65f);
|
||||||
|
cone = Clamp01(cone);
|
||||||
|
|
||||||
|
float baseIntensity = MathF.Pow(fy, 1.8f);
|
||||||
|
float flame = Clamp01(baseIntensity * cone * (0.6f + noise * 0.9f + pulse * 0.25f));
|
||||||
|
flame *= 0.65f + level * 0.75f;
|
||||||
|
|
||||||
|
float ember = MathF.Sin((fx * 45f) + (fy * 14f) - t * 18f);
|
||||||
|
float sparks = ember > 0.985f ? 0.35f + beat * 0.45f : 0f;
|
||||||
|
|
||||||
|
float r = Clamp01(flame * 2.1f + sparks);
|
||||||
|
float g = Clamp01(MathF.Pow(flame, 1.35f) * (0.95f + high * 0.45f) + sparks * 0.7f);
|
||||||
|
float b = Clamp01(MathF.Pow(flame, 3.2f) * (0.25f + beat * 0.25f));
|
||||||
|
|
||||||
|
_pixelBuffer[(y * _internalWidth) + x] = new Color(r, g, b, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderPlasma(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float bass = Clamp01(features.Bass * 0.01f);
|
||||||
|
float mid = Clamp01(features.Mid * 0.003f);
|
||||||
|
float high = Clamp01(features.High * 0.003f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
float level = Clamp01(features.Level * 3f);
|
||||||
|
|
||||||
|
float aspect = _internalWidth / (float)_internalHeight;
|
||||||
|
float speed = 0.8f + bass * 1.6f;
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float py = (y / (float)_internalHeight) * 2f - 1f;
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
float px = ((x / (float)_internalWidth) * 2f - 1f) * aspect;
|
||||||
|
float t = timeSeconds * speed;
|
||||||
|
|
||||||
|
float v1 = MathF.Sin((px * 4.5f) + t * 1.9f);
|
||||||
|
float v2 = MathF.Sin((py * 5.0f) - t * 1.3f);
|
||||||
|
float v3 = MathF.Sin((px + py) * 4.0f + t * (2.2f + mid * 2f));
|
||||||
|
float v4 = MathF.Sin(MathF.Sqrt(px * px + py * py) * 10f - t * (3f + high * 3f));
|
||||||
|
float plasma = (v1 + v2 + v3 + v4) * 0.25f;
|
||||||
|
float p = plasma * 0.5f + 0.5f;
|
||||||
|
|
||||||
|
float pulse = beat * 0.35f;
|
||||||
|
float spec = GetSpectrumSample(features, (x * 0.08f) + (y * 0.03f));
|
||||||
|
|
||||||
|
float r = Clamp01((0.35f + 0.65f * MathF.Sin((p + pulse) * 6.28f)) * (0.6f + level * 0.6f));
|
||||||
|
float g = Clamp01((0.35f + 0.65f * MathF.Sin((p + 0.33f) * 6.28f + spec * 2f)) * (0.55f + mid * 0.8f));
|
||||||
|
float b = Clamp01((0.35f + 0.65f * MathF.Sin((p + 0.66f) * 6.28f + spec * 3f)) * (0.55f + high * 0.9f + pulse));
|
||||||
|
|
||||||
|
_pixelBuffer[(y * _internalWidth) + x] = new Color(r, g, b, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderNebula(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float bass = Clamp01(features.Bass * 0.01f);
|
||||||
|
float mid = Clamp01(features.Mid * 0.003f);
|
||||||
|
float high = Clamp01(features.High * 0.003f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
float level = Clamp01(features.Level * 3f);
|
||||||
|
|
||||||
|
float aspect = _internalWidth / (float)_internalHeight;
|
||||||
|
float zoom = 1.6f - bass * 0.5f;
|
||||||
|
float driftX = MathF.Sin(timeSeconds * (0.16f + mid * 0.4f)) * 0.45f;
|
||||||
|
float driftY = MathF.Cos(timeSeconds * (0.12f + high * 0.5f)) * 0.45f;
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float py = ((y / (float)_internalHeight) * 2f - 1f) * zoom;
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
float px = (((x / (float)_internalWidth) * 2f - 1f) * aspect) * zoom;
|
||||||
|
float fx = px + driftX;
|
||||||
|
float fy = py + driftY;
|
||||||
|
|
||||||
|
float density = 0f;
|
||||||
|
float amp = 0.55f;
|
||||||
|
float freq = 1.1f;
|
||||||
|
for (int o = 0; o < 5; o++)
|
||||||
|
{
|
||||||
|
float n = MathF.Sin((fx * freq + timeSeconds * 0.15f) * 2.1f)
|
||||||
|
* MathF.Cos((fy * freq - timeSeconds * 0.19f) * 2.3f);
|
||||||
|
density += (n * 0.5f + 0.5f) * amp;
|
||||||
|
freq *= 1.9f;
|
||||||
|
amp *= 0.55f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float rad = MathF.Sqrt(fx * fx + fy * fy);
|
||||||
|
float core = MathF.Exp(-rad * (1.6f + bass * 1.2f));
|
||||||
|
float stars = GetSpectrumSample(features, rad * 24f + timeSeconds * 8f);
|
||||||
|
float pulse = beat * 0.45f;
|
||||||
|
|
||||||
|
float cloud = Clamp01(density * (0.6f + level * 0.5f));
|
||||||
|
float r = Clamp01(cloud * (0.25f + mid * 0.8f) + core * (0.8f + pulse));
|
||||||
|
float g = Clamp01(cloud * (0.35f + high * 0.9f) + core * 0.55f + stars * 0.35f);
|
||||||
|
float b = Clamp01(cloud * (0.55f + bass * 0.8f) + core * 0.75f + stars * 0.5f + pulse * 0.2f);
|
||||||
|
|
||||||
|
_pixelBuffer[(y * _internalWidth) + x] = new Color(r, g, b, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderFlameGrill(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float level = Clamp01(features.Level * 3f);
|
||||||
|
float bass = Clamp01(features.Bass * 0.012f);
|
||||||
|
float high = Clamp01(features.High * 0.003f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
|
||||||
|
float intensity = Clamp01(0.25f + level * 0.45f + bass * 0.35f + beat * 0.35f);
|
||||||
|
float flameHeight = 0.22f + intensity * 0.68f;
|
||||||
|
float flickerSpeed = 2.2f + bass * 4.0f;
|
||||||
|
int grillTop = (int)(_internalHeight * 0.86f);
|
||||||
|
float barSpacing = 14f;
|
||||||
|
float barThickness = 3.5f;
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float y01 = y / (float)(_internalHeight - 1);
|
||||||
|
float fromBottom = 1f - y01;
|
||||||
|
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
int idx = (y * _internalWidth) + x;
|
||||||
|
|
||||||
|
float x01 = x / (float)(_internalWidth - 1);
|
||||||
|
float wave1 = MathF.Sin((x01 * 18f) + timeSeconds * flickerSpeed);
|
||||||
|
float wave2 = MathF.Sin((x01 * 36f) - timeSeconds * (flickerSpeed * 0.65f));
|
||||||
|
float wave3 = MathF.Sin((x01 * 72f) + timeSeconds * (flickerSpeed * 1.35f));
|
||||||
|
float turbulence = (wave1 * 0.45f + wave2 * 0.35f + wave3 * 0.20f) * (0.22f + high * 0.55f);
|
||||||
|
|
||||||
|
float localHeight = flameHeight + turbulence;
|
||||||
|
float flameMask = Clamp01((fromBottom - 0.02f) / MathF.Max(0.05f, localHeight));
|
||||||
|
float taper = Clamp01(1f - MathF.Pow(fromBottom / MathF.Max(0.05f, localHeight), 1.35f));
|
||||||
|
float flameCore = flameMask * taper;
|
||||||
|
|
||||||
|
float tongues = MathF.Sin((x01 * 110f) + timeSeconds * (6f + high * 8f) - fromBottom * 22f);
|
||||||
|
float tongueMask = Clamp01(tongues * 0.5f + 0.5f);
|
||||||
|
float flame = flameCore * (0.72f + tongueMask * 0.45f);
|
||||||
|
flame *= 0.65f + intensity * 0.7f;
|
||||||
|
|
||||||
|
float emberNoise = MathF.Sin((x01 * 180f) + (fromBottom * 90f) - timeSeconds * 18f);
|
||||||
|
float sparks = emberNoise > 0.992f && fromBottom < localHeight ? 0.55f + beat * 0.35f : 0f;
|
||||||
|
|
||||||
|
float r = Clamp01(flame * 2.4f + sparks);
|
||||||
|
float g = Clamp01(MathF.Pow(flame, 1.4f) * (1.05f + high * 0.35f) + sparks * 0.55f);
|
||||||
|
float b = Clamp01(MathF.Pow(flame, 3.1f) * (0.22f + beat * 0.18f));
|
||||||
|
|
||||||
|
if (y >= grillTop)
|
||||||
|
{
|
||||||
|
float line = MathF.Abs(((x % barSpacing) - barSpacing * 0.5f));
|
||||||
|
float barMask = Clamp01(1f - (line / barThickness));
|
||||||
|
float hot = 0.25f + intensity * 0.55f;
|
||||||
|
float metal = 0.08f;
|
||||||
|
float grillR = Clamp01(metal + barMask * hot);
|
||||||
|
float grillG = Clamp01(metal * 0.7f + barMask * hot * 0.45f);
|
||||||
|
float grillB = Clamp01(metal * 0.6f + barMask * hot * 0.18f);
|
||||||
|
|
||||||
|
r = MathF.Max(r * 0.45f, grillR);
|
||||||
|
g = MathF.Max(g * 0.45f, grillG);
|
||||||
|
b = MathF.Max(b * 0.45f, grillB);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pixelBuffer[idx] = new Color(r, g, b, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderFloatingNotes(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float level = Clamp01(features.Level * 3f);
|
||||||
|
float bass = Clamp01(features.Bass * 0.012f);
|
||||||
|
float high = Clamp01(features.High * 0.003f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
float intensity = Clamp01(level * 0.6f + bass * 0.25f + beat * 0.35f);
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float t = y / (float)(_internalHeight - 1);
|
||||||
|
float r = 0.03f + 0.08f * (1f - t);
|
||||||
|
float g = 0.02f + 0.05f * (1f - t);
|
||||||
|
float b = 0.08f + 0.20f * (1f - t);
|
||||||
|
Color bg = new(r, g, b, 1f);
|
||||||
|
int row = y * _internalWidth;
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
_pixelBuffer[row + x] = bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float spawnRate = 0.08f + intensity * 1.8f + high * 0.7f;
|
||||||
|
_noteSpawnAccumulator += spawnRate;
|
||||||
|
while (_noteSpawnAccumulator >= 1f)
|
||||||
|
{
|
||||||
|
SpawnNote(intensity);
|
||||||
|
_noteSpawnAccumulator -= 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = _notes.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
NoteParticle note = _notes[i];
|
||||||
|
note.Y += note.Vy;
|
||||||
|
note.X += note.Vx + MathF.Sin(timeSeconds * 2.8f + note.Phase) * 0.0012f;
|
||||||
|
note.Life -= 0.012f + (1f - intensity) * 0.004f;
|
||||||
|
|
||||||
|
if (note.Life <= 0f || note.Y < -0.12f || note.X < -0.2f || note.X > 1.2f)
|
||||||
|
{
|
||||||
|
_notes.RemoveAt(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_notes[i] = note;
|
||||||
|
DrawNote(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderFireworks(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float level = Clamp01(features.Level * 3f);
|
||||||
|
float bass = Clamp01(features.Bass * 0.012f);
|
||||||
|
float high = Clamp01(features.High * 0.003f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
float intensity = Clamp01(level * 0.55f + bass * 0.30f + beat * 0.45f);
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float t = y / (float)(_internalHeight - 1);
|
||||||
|
float r = 0.01f + 0.05f * (1f - t);
|
||||||
|
float g = 0.01f + 0.04f * (1f - t);
|
||||||
|
float b = 0.04f + 0.16f * (1f - t);
|
||||||
|
Color bg = new(r, g, b, 1f);
|
||||||
|
int row = y * _internalWidth;
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
_pixelBuffer[row + x] = bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float spawnRate = 0.02f + intensity * 0.45f + high * 0.15f;
|
||||||
|
_fireworkSpawnAccumulator += spawnRate;
|
||||||
|
while (_fireworkSpawnAccumulator >= 1f)
|
||||||
|
{
|
||||||
|
SpawnFireworkRocket(intensity);
|
||||||
|
_fireworkSpawnAccumulator -= 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = _fireworkRockets.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
FireworkRocket rocket = _fireworkRockets[i];
|
||||||
|
rocket.Y += rocket.Vy;
|
||||||
|
rocket.X += rocket.Vx;
|
||||||
|
rocket.Age += 0.016f;
|
||||||
|
|
||||||
|
DrawFilledCircle(
|
||||||
|
(int)(rocket.X * _internalWidth),
|
||||||
|
(int)(rocket.Y * _internalHeight),
|
||||||
|
1 + (int)(rocket.Size * 0.6f),
|
||||||
|
rocket.Color,
|
||||||
|
0.95f);
|
||||||
|
|
||||||
|
bool explode = rocket.Y <= rocket.TargetY || rocket.Age >= rocket.MaxAge;
|
||||||
|
if (explode)
|
||||||
|
{
|
||||||
|
ExplodeFirework(rocket, intensity);
|
||||||
|
_fireworkRockets.RemoveAt(i);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_fireworkRockets[i] = rocket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = _fireworkSparks.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
FireworkSpark spark = _fireworkSparks[i];
|
||||||
|
spark.X += spark.Vx;
|
||||||
|
spark.Y += spark.Vy;
|
||||||
|
spark.Vy += 0.00022f;
|
||||||
|
spark.Vx *= 0.995f;
|
||||||
|
spark.Life -= 0.012f;
|
||||||
|
|
||||||
|
if (spark.Life <= 0f || spark.Y > 1.1f || spark.X < -0.2f || spark.X > 1.2f)
|
||||||
|
{
|
||||||
|
_fireworkSparks.RemoveAt(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fireworkSparks[i] = spark;
|
||||||
|
int sx = (int)(spark.X * _internalWidth);
|
||||||
|
int sy = (int)(spark.Y * _internalHeight);
|
||||||
|
DrawFilledCircle(sx, sy, spark.Size, spark.Color, spark.Life);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SpawnFireworkRocket(float intensity)
|
||||||
|
{
|
||||||
|
float x = 0.1f + (float)_rng.NextDouble() * 0.8f;
|
||||||
|
float y = 1.02f;
|
||||||
|
float vy = -(0.0065f + (float)_rng.NextDouble() * (0.0025f + intensity * 0.0025f));
|
||||||
|
float vx = ((float)_rng.NextDouble() - 0.5f) * 0.0018f;
|
||||||
|
float targetY = 0.2f + (float)_rng.NextDouble() * 0.42f;
|
||||||
|
float maxAge = 0.7f + (float)_rng.NextDouble() * 0.45f;
|
||||||
|
|
||||||
|
Vector3 color = new(
|
||||||
|
0.55f + (float)_rng.NextDouble() * 0.45f,
|
||||||
|
0.45f + (float)_rng.NextDouble() * 0.55f,
|
||||||
|
0.45f + (float)_rng.NextDouble() * 0.55f);
|
||||||
|
|
||||||
|
_fireworkRockets.Add(new FireworkRocket
|
||||||
|
{
|
||||||
|
X = x,
|
||||||
|
Y = y,
|
||||||
|
Vx = vx,
|
||||||
|
Vy = vy,
|
||||||
|
TargetY = targetY,
|
||||||
|
Age = 0f,
|
||||||
|
MaxAge = maxAge,
|
||||||
|
Size = 1.4f + (float)_rng.NextDouble() * 1.4f,
|
||||||
|
Color = color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExplodeFirework(FireworkRocket rocket, float intensity)
|
||||||
|
{
|
||||||
|
int count = 22 + (int)(intensity * 38f);
|
||||||
|
float speedBase = 0.0018f + intensity * 0.0034f;
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
float a = (float)(i * (Math.PI * 2.0 / count));
|
||||||
|
float speed = speedBase + (float)_rng.NextDouble() * speedBase;
|
||||||
|
float vx = MathF.Cos(a) * speed;
|
||||||
|
float vy = MathF.Sin(a) * speed;
|
||||||
|
|
||||||
|
_fireworkSparks.Add(new FireworkSpark
|
||||||
|
{
|
||||||
|
X = rocket.X,
|
||||||
|
Y = rocket.Y,
|
||||||
|
Vx = vx,
|
||||||
|
Vy = vy,
|
||||||
|
Life = 0.65f + (float)_rng.NextDouble() * 0.35f,
|
||||||
|
Size = 1 + _rng.Next(0, 2),
|
||||||
|
Color = rocket.Color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SpawnNote(float intensity)
|
||||||
|
{
|
||||||
|
float x = 0.08f + (float)_rng.NextDouble() * 0.84f;
|
||||||
|
float y = 0.94f + (float)_rng.NextDouble() * 0.08f;
|
||||||
|
float vy = -(0.0025f + (float)_rng.NextDouble() * (0.0035f + intensity * 0.004f));
|
||||||
|
float vx = ((float)_rng.NextDouble() - 0.5f) * (0.0018f + intensity * 0.0015f);
|
||||||
|
float size = 0.75f + (float)_rng.NextDouble() * (1.6f + intensity * 1.2f);
|
||||||
|
bool flagged = _rng.NextDouble() > 0.42;
|
||||||
|
|
||||||
|
Vector3 color = new(
|
||||||
|
0.7f + (float)_rng.NextDouble() * 0.3f,
|
||||||
|
0.55f + (float)_rng.NextDouble() * 0.45f,
|
||||||
|
0.4f + (float)_rng.NextDouble() * 0.6f);
|
||||||
|
|
||||||
|
_notes.Add(new NoteParticle
|
||||||
|
{
|
||||||
|
X = x,
|
||||||
|
Y = y,
|
||||||
|
Vx = vx,
|
||||||
|
Vy = vy,
|
||||||
|
Life = 1f,
|
||||||
|
Size = size,
|
||||||
|
Phase = (float)_rng.NextDouble() * MathF.PI * 2f,
|
||||||
|
Color = color,
|
||||||
|
HasFlag = flagged
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawNote(NoteParticle note)
|
||||||
|
{
|
||||||
|
int px = (int)(note.X * _internalWidth);
|
||||||
|
int py = (int)(note.Y * _internalHeight);
|
||||||
|
int s = Math.Max(1, (int)MathF.Round(note.Size));
|
||||||
|
|
||||||
|
float alpha = Clamp01(note.Life);
|
||||||
|
Vector3 color = note.Color;
|
||||||
|
|
||||||
|
int headX = px;
|
||||||
|
int headY = py;
|
||||||
|
DrawFilledCircle(headX, headY, s + 1, color, alpha * 0.75f);
|
||||||
|
DrawFilledCircle(headX, headY, s, color, alpha);
|
||||||
|
|
||||||
|
int stemHeight = 6 + s * 4;
|
||||||
|
DrawRect(headX + s, headY - stemHeight, 2, stemHeight, color, alpha);
|
||||||
|
|
||||||
|
if (note.HasFlag)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 6 + s; i++)
|
||||||
|
{
|
||||||
|
int fx = headX + s + 1 + i;
|
||||||
|
int fy = headY - stemHeight + (int)(MathF.Sin(i * 0.55f) * (2f + s * 0.25f));
|
||||||
|
DrawRect(fx, fy, 2, 2, color, alpha * 0.95f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawFilledCircle(int cx, int cy, int radius, Vector3 color, float alpha)
|
||||||
|
{
|
||||||
|
int r2 = radius * radius;
|
||||||
|
for (int oy = -radius; oy <= radius; oy++)
|
||||||
|
{
|
||||||
|
int yy = cy + oy;
|
||||||
|
if (yy < 0 || yy >= _internalHeight)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int oy2 = oy * oy;
|
||||||
|
for (int ox = -radius; ox <= radius; ox++)
|
||||||
|
{
|
||||||
|
if (ox * ox + oy2 > r2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int xx = cx + ox;
|
||||||
|
if (xx < 0 || xx >= _internalWidth)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlendPixel(xx, yy, color, alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawRect(int x, int y, int width, int height, Vector3 color, float alpha)
|
||||||
|
{
|
||||||
|
for (int yy = y; yy < y + height; yy++)
|
||||||
|
{
|
||||||
|
if (yy < 0 || yy >= _internalHeight)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int xx = x; xx < x + width; xx++)
|
||||||
|
{
|
||||||
|
if (xx < 0 || xx >= _internalWidth)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlendPixel(xx, yy, color, alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BlendPixel(int x, int y, Vector3 color, float alpha)
|
||||||
|
{
|
||||||
|
int idx = y * _internalWidth + x;
|
||||||
|
Vector3 baseColor = _pixelBuffer[idx].ToVector3();
|
||||||
|
Vector3 mixed = Vector3.Lerp(baseColor, color, Clamp01(alpha));
|
||||||
|
_pixelBuffer[idx] = new Color(Clamp01(mixed.X), Clamp01(mixed.Y), Clamp01(mixed.Z), 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderJulia(AudioFeatures features, float timeSeconds)
|
||||||
|
{
|
||||||
|
float level = Clamp01(features.Level * 3f);
|
||||||
|
float bass = Clamp01(features.Bass * 0.015f);
|
||||||
|
float mid = Clamp01(features.Mid * 0.004f);
|
||||||
|
float high = Clamp01(features.High * 0.003f);
|
||||||
|
float beat = Clamp01(features.Beat);
|
||||||
|
|
||||||
|
float zoom = 1.1f - (bass * 0.45f) - (beat * 0.15f);
|
||||||
|
if (zoom < 0.45f)
|
||||||
|
{
|
||||||
|
zoom = 0.45f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float cRe = -0.8f + (mid * 1.6f) + (MathF.Sin(timeSeconds * 0.31f) * 0.06f);
|
||||||
|
float cIm = 0.156f + ((high - 0.5f) * 1.0f) + (MathF.Cos(timeSeconds * 0.23f) * 0.04f);
|
||||||
|
|
||||||
|
int maxIterations = 42 + (int)(level * 54f);
|
||||||
|
float aspect = _internalWidth / (float)_internalHeight;
|
||||||
|
|
||||||
|
for (int y = 0; y < _internalHeight; y++)
|
||||||
|
{
|
||||||
|
float ny = ((y / (float)_internalHeight) * 2f - 1f) * zoom;
|
||||||
|
for (int x = 0; x < _internalWidth; x++)
|
||||||
|
{
|
||||||
|
float nx = (((x / (float)_internalWidth) * 2f - 1f) * aspect) * zoom;
|
||||||
|
|
||||||
|
float zr = nx;
|
||||||
|
float zi = ny;
|
||||||
|
int iter = 0;
|
||||||
|
while (iter < maxIterations)
|
||||||
|
{
|
||||||
|
float zr2 = zr * zr;
|
||||||
|
float zi2 = zi * zi;
|
||||||
|
if (zr2 + zi2 > 4f)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
zi = (2f * zr * zi) + cIm;
|
||||||
|
zr = zr2 - zi2 + cRe;
|
||||||
|
iter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
float t = iter / (float)maxIterations;
|
||||||
|
float pulse = beat * 0.35f;
|
||||||
|
|
||||||
|
float r = MathF.Pow(t, 0.65f) * (0.35f + high * 1.2f + pulse);
|
||||||
|
float g = MathF.Pow(t, 1.2f) * (0.28f + mid * 1.1f);
|
||||||
|
float b = MathF.Pow(t, 0.85f) * (0.45f + bass * 1.5f + pulse);
|
||||||
|
|
||||||
|
_pixelBuffer[(y * _internalWidth) + x] = new Color(
|
||||||
|
Clamp01(r),
|
||||||
|
Clamp01(g),
|
||||||
|
Clamp01(b),
|
||||||
|
1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(FractalRenderer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fractalTexture.Dispose();
|
||||||
|
_spriteBatch.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NoteParticle
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
public float Y;
|
||||||
|
public float Vx;
|
||||||
|
public float Vy;
|
||||||
|
public float Life;
|
||||||
|
public float Size;
|
||||||
|
public float Phase;
|
||||||
|
public Vector3 Color;
|
||||||
|
public bool HasFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FireworkRocket
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
public float Y;
|
||||||
|
public float Vx;
|
||||||
|
public float Vy;
|
||||||
|
public float TargetY;
|
||||||
|
public float Age;
|
||||||
|
public float MaxAge;
|
||||||
|
public float Size;
|
||||||
|
public Vector3 Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FireworkSpark
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
public float Y;
|
||||||
|
public float Vx;
|
||||||
|
public float Vy;
|
||||||
|
public float Life;
|
||||||
|
public int Size;
|
||||||
|
public Vector3 Color;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace FractalVisualizer;
|
||||||
|
|
||||||
|
public sealed class SettingsManager
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
|
||||||
|
namespace FractalVisualizer.UI;
|
||||||
|
|
||||||
|
public sealed class HudBitmapFont
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<char, string[]> Glyphs = new()
|
||||||
|
{
|
||||||
|
['A'] = ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||||
|
['B'] = ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
|
||||||
|
['C'] = ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
|
||||||
|
['D'] = ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
|
||||||
|
['E'] = ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
|
||||||
|
['F'] = ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
|
||||||
|
['G'] = ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
|
||||||
|
['H'] = ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||||
|
['I'] = ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
|
||||||
|
['J'] = ["00001", "00001", "00001", "00001", "10001", "10001", "01110"],
|
||||||
|
['K'] = ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
|
||||||
|
['L'] = ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
|
||||||
|
['M'] = ["10001", "11011", "10101", "10001", "10001", "10001", "10001"],
|
||||||
|
['N'] = ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
|
||||||
|
['O'] = ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
|
||||||
|
['P'] = ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
|
||||||
|
['Q'] = ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
|
||||||
|
['R'] = ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
|
||||||
|
['S'] = ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
|
||||||
|
['T'] = ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
|
||||||
|
['U'] = ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
|
||||||
|
['V'] = ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
|
||||||
|
['W'] = ["10001", "10001", "10001", "10001", "10101", "11011", "10001"],
|
||||||
|
['X'] = ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
|
||||||
|
['Y'] = ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
|
||||||
|
['Z'] = ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
|
||||||
|
['0'] = ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
|
||||||
|
['1'] = ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
|
||||||
|
['2'] = ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
|
||||||
|
['3'] = ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
|
||||||
|
['4'] = ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
|
||||||
|
['5'] = ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
|
||||||
|
['6'] = ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
|
||||||
|
['7'] = ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
|
||||||
|
['8'] = ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
|
||||||
|
['9'] = ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
|
||||||
|
['.'] = ["00000", "00000", "00000", "00000", "00000", "00110", "00110"],
|
||||||
|
['-'] = ["00000", "00000", "00000", "01110", "00000", "00000", "00000"],
|
||||||
|
[':'] = ["00000", "00110", "00110", "00000", "00110", "00110", "00000"],
|
||||||
|
[' '] = ["00000", "00000", "00000", "00000", "00000", "00000", "00000"]
|
||||||
|
};
|
||||||
|
|
||||||
|
public void DrawText(SpriteBatch spriteBatch, Texture2D pixel, string text, int x, int y, int scale, Color color)
|
||||||
|
{
|
||||||
|
int cursorX = x;
|
||||||
|
for (int i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
char c = char.ToUpperInvariant(text[i]);
|
||||||
|
if (!Glyphs.TryGetValue(c, out string[]? glyph))
|
||||||
|
{
|
||||||
|
glyph = Glyphs[' '];
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawGlyph(spriteBatch, pixel, glyph, cursorX, y, scale, color);
|
||||||
|
cursorX += (6 * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawGlyph(SpriteBatch spriteBatch, Texture2D pixel, string[] glyph, int x, int y, int scale, Color color)
|
||||||
|
{
|
||||||
|
for (int row = 0; row < glyph.Length; row++)
|
||||||
|
{
|
||||||
|
string line = glyph[row];
|
||||||
|
for (int col = 0; col < line.Length; col++)
|
||||||
|
{
|
||||||
|
if (line[col] != '1')
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
spriteBatch.Draw(pixel, new Rectangle(x + (col * scale), y + (row * scale), scale, scale), color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="FractalVisualizer"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- A list of the Windows versions that this application has been tested on and is
|
||||||
|
is designed to work with. Uncomment the appropriate elements and Windows will
|
||||||
|
automatically selected the most compatible environment. -->
|
||||||
|
|
||||||
|
<!-- Windows Vista -->
|
||||||
|
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
|
||||||
|
|
||||||
|
<!-- Windows 7 -->
|
||||||
|
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||||
|
|
||||||
|
<!-- Windows 8 -->
|
||||||
|
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||||
|
|
||||||
|
<!-- Windows 8.1 -->
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||||
|
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</assembly>
|
||||||
@@ -5,19 +5,24 @@
|
|||||||
**Platform:** Windows 10/11\
|
**Platform:** Windows 10/11\
|
||||||
**Runtime:** .NET 10\
|
**Runtime:** .NET 10\
|
||||||
**Framework:** MonoGame (WindowsDX)\
|
**Framework:** MonoGame (WindowsDX)\
|
||||||
**Audio Library:** NAudio (WASAPI Loopback)
|
**Audio Library:** NAudio (WASAPI Capture + WASAPI Loopback)
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
# 1. Project Overview
|
# 1. Project Overview
|
||||||
|
|
||||||
Fractal Audio Visualizer is a real-time Windows desktop application that
|
Fractal Audio Visualizer is a real-time Windows desktop application that
|
||||||
captures system audio output (loopback) and generates GPU-accelerated
|
captures audio and generates GPU-accelerated fractal visualizations that
|
||||||
fractal visualizations that react dynamically to music.
|
react dynamically to sound.
|
||||||
|
|
||||||
The application listens to currently playing system audio (Spotify,
|
The application uses exactly 2 audio input presets:
|
||||||
YouTube, etc.) and renders procedural fractal graphics driven by
|
|
||||||
frequency spectrum analysis.
|
1. **Microphone (Default):** Listen to microphone input in real time.
|
||||||
|
2. **System Output:** Listen to sound played by any application on the
|
||||||
|
PC (Spotify, YouTube, games, browser audio, etc.).
|
||||||
|
|
||||||
|
Captured audio drives procedural fractal graphics via frequency spectrum
|
||||||
|
analysis.
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -25,10 +30,18 @@ frequency spectrum analysis.
|
|||||||
|
|
||||||
## 2.1 Audio Capture
|
## 2.1 Audio Capture
|
||||||
|
|
||||||
- Use **NAudio WASAPI Loopback Capture**
|
Input preset modes (exactly 2):
|
||||||
- Capture default output device
|
|
||||||
|
- **Microphone (Default):** Capture default microphone in real time
|
||||||
|
using NAudio WASAPI capture (shared mode, with WaveIn fallback if
|
||||||
|
needed)
|
||||||
|
- **System Output:** Use NAudio WASAPI Loopback Capture for audio
|
||||||
|
played by any application on the PC
|
||||||
|
|
||||||
|
Common requirements:
|
||||||
|
|
||||||
- Support 44.1kHz and 48kHz
|
- Support 44.1kHz and 48kHz
|
||||||
- Automatic device rebind if default device changes
|
- Automatic device rebind if default input/output device changes
|
||||||
- Buffer size: 1024--4096 samples
|
- Buffer size: 1024--4096 samples
|
||||||
- PCM float format internally
|
- PCM float format internally
|
||||||
|
|
||||||
@@ -138,7 +151,8 @@ Minimum 3 presets:
|
|||||||
# 6. UI Controls
|
# 6. UI Controls
|
||||||
|
|
||||||
Keyboard: - F11 → Fullscreen toggle - ESC → Exit fullscreen - SPACE →
|
Keyboard: - F11 → Fullscreen toggle - ESC → Exit fullscreen - SPACE →
|
||||||
Pause animation - LEFT/RIGHT → Switch preset - TAB → Toggle HUD
|
Pause animation - LEFT/RIGHT → Switch fractal preset - TAB → Toggle HUD
|
||||||
|
- M → Toggle audio input mode (Microphone/System Output)
|
||||||
|
|
||||||
Optional: Myra UI for settings overlay
|
Optional: Myra UI for settings overlay
|
||||||
|
|
||||||
@@ -146,7 +160,8 @@ Optional: Myra UI for settings overlay
|
|||||||
|
|
||||||
# 7. Threading Model
|
# 7. Threading Model
|
||||||
|
|
||||||
Audio Thread: - WASAPI callback - Push samples to ring buffer
|
Audio Thread: - Active input callback (Microphone or Loopback) - Push
|
||||||
|
samples to ring buffer
|
||||||
|
|
||||||
Analysis Thread: - FFT + feature extraction - Publish snapshot
|
Analysis Thread: - FFT + feature extraction - Publish snapshot
|
||||||
(lock-free)
|
(lock-free)
|
||||||
@@ -175,8 +190,9 @@ Main Thread: - MonoGame Update/Draw
|
|||||||
|
|
||||||
# 9. MVP Scope
|
# 9. MVP Scope
|
||||||
|
|
||||||
MVP Includes: - WASAPI loopback capture - FFT + 3-band energy
|
MVP Includes: - Exactly 2 input presets: Microphone (default) and
|
||||||
extraction - Julia shader - Preset switching - Fullscreen mode
|
System Output (switchable) - FFT + 3-band energy extraction - Julia
|
||||||
|
shader - Preset switching - Fullscreen mode
|
||||||
|
|
||||||
Estimated implementation time: 5--7 development days.
|
Estimated implementation time: 5--7 development days.
|
||||||
|
|
||||||
@@ -194,11 +210,13 @@ Estimated implementation time: 5--7 development days.
|
|||||||
|
|
||||||
# 11. Acceptance Criteria
|
# 11. Acceptance Criteria
|
||||||
|
|
||||||
1. App reacts to Spotify/YouTube playback without microphone.
|
1. App starts in Microphone mode and reacts to live mic input.
|
||||||
2. Bass drops visibly affect zoom/intensity.
|
2. User can switch to System Output mode and app reacts to
|
||||||
3. Preset switch occurs without frame drop.
|
Spotify/YouTube playback without microphone.
|
||||||
4. 60 FPS at 1080p (medium quality).
|
3. Bass drops visibly affect zoom/intensity.
|
||||||
5. No crashes on device change.
|
4. Preset switch occurs without frame drop.
|
||||||
|
5. 60 FPS at 1080p (medium quality).
|
||||||
|
6. No crashes on device change.
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
+3
-1
@@ -1 +1,3 @@
|
|||||||
<Solution />
|
<Solution>
|
||||||
|
<Project Path="FractalVisualizer/FractalVisualizer.csproj" />
|
||||||
|
</Solution>
|
||||||
|
|||||||
Reference in New Issue
Block a user