This commit is contained in:
Michal Vrba
2026-02-13 19:58:55 +01:00
parent 9b071ccf91
commit 5a9c290816
21 changed files with 2197 additions and 19 deletions
@@ -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
View File
@@ -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"
}
],
}
+291
View File
@@ -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;
}
}
+363
View File
@@ -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;
}
}
}
+14
View File
@@ -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>
+237
View File
@@ -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

+20
View File
@@ -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
}
+2
View File
@@ -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 @@
+5
View File
@@ -0,0 +1,5 @@
namespace FractalVisualizer;
public sealed class SettingsManager
{
}
+1
View File
@@ -0,0 +1 @@
+85
View File
@@ -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);
}
}
}
}
+43
View File
@@ -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>
+36 -18
View File
@@ -5,19 +5,24 @@
**Platform:** Windows 10/11\
**Runtime:** .NET 10\
**Framework:** MonoGame (WindowsDX)\
**Audio Library:** NAudio (WASAPI Loopback)
**Audio Library:** NAudio (WASAPI Capture + WASAPI Loopback)
------------------------------------------------------------------------
# 1. Project Overview
Fractal Audio Visualizer is a real-time Windows desktop application that
captures system audio output (loopback) and generates GPU-accelerated
fractal visualizations that react dynamically to music.
captures audio and generates GPU-accelerated fractal visualizations that
react dynamically to sound.
The application listens to currently playing system audio (Spotify,
YouTube, etc.) and renders procedural fractal graphics driven by
frequency spectrum analysis.
The application uses exactly 2 audio input presets:
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
- Use **NAudio WASAPI Loopback Capture**
- Capture default output device
Input preset modes (exactly 2):
- **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
- Automatic device rebind if default device changes
- Automatic device rebind if default input/output device changes
- Buffer size: 1024--4096 samples
- PCM float format internally
@@ -138,7 +151,8 @@ Minimum 3 presets:
# 6. UI Controls
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
@@ -146,7 +160,8 @@ Optional: Myra UI for settings overlay
# 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
(lock-free)
@@ -175,8 +190,9 @@ Main Thread: - MonoGame Update/Draw
# 9. MVP Scope
MVP Includes: - WASAPI loopback capture - FFT + 3-band energy
extraction - Julia shader - Preset switching - Fullscreen mode
MVP Includes: - Exactly 2 input presets: Microphone (default) and
System Output (switchable) - FFT + 3-band energy extraction - Julia
shader - Preset switching - Fullscreen mode
Estimated implementation time: 5--7 development days.
@@ -194,11 +210,13 @@ Estimated implementation time: 5--7 development days.
# 11. Acceptance Criteria
1. App reacts to Spotify/YouTube playback without microphone.
2. Bass drops visibly affect zoom/intensity.
3. Preset switch occurs without frame drop.
4. 60 FPS at 1080p (medium quality).
5. No crashes on device change.
1. App starts in Microphone mode and reacts to live mic input.
2. User can switch to System Output mode and app reacts to
Spotify/YouTube playback without microphone.
3. Bass drops visibly affect zoom/intensity.
4. Preset switch occurs without frame drop.
5. 60 FPS at 1080p (medium quality).
6. No crashes on device change.
------------------------------------------------------------------------
+7
View File
@@ -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
View File
@@ -1 +1,3 @@
<Solution />
<Solution>
<Project Path="FractalVisualizer/FractalVisualizer.csproj" />
</Solution>