diff --git a/FractalVisualizer/.config/dotnet-tools.json b/FractalVisualizer/.config/dotnet-tools.json new file mode 100644 index 0000000..a07174a --- /dev/null +++ b/FractalVisualizer/.config/dotnet-tools.json @@ -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" + ] + } + } +} \ No newline at end of file diff --git a/FractalVisualizer/.vscode/launch.json b/FractalVisualizer/.vscode/launch.json new file mode 100644 index 0000000..7fbad20 --- /dev/null +++ b/FractalVisualizer/.vscode/launch.json @@ -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" + } + ], + } \ No newline at end of file diff --git a/FractalVisualizer/Audio/AudioAnalyzer.cs b/FractalVisualizer/Audio/AudioAnalyzer.cs new file mode 100644 index 0000000..a8bf528 --- /dev/null +++ b/FractalVisualizer/Audio/AudioAnalyzer.cs @@ -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; + } +} diff --git a/FractalVisualizer/Audio/AudioCapture.cs b/FractalVisualizer/Audio/AudioCapture.cs new file mode 100644 index 0000000..39bd8d6 --- /dev/null +++ b/FractalVisualizer/Audio/AudioCapture.cs @@ -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? ModeChanged; + + public event Action? 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[] 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[] 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(); + } + + 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) + { + } + } +} diff --git a/FractalVisualizer/Audio/AudioSampleRingBuffer.cs b/FractalVisualizer/Audio/AudioSampleRingBuffer.cs new file mode 100644 index 0000000..8381826 --- /dev/null +++ b/FractalVisualizer/Audio/AudioSampleRingBuffer.cs @@ -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 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 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; + } + } +} diff --git a/FractalVisualizer/Content/Content.mgcb b/FractalVisualizer/Content/Content.mgcb new file mode 100644 index 0000000..6d267b8 --- /dev/null +++ b/FractalVisualizer/Content/Content.mgcb @@ -0,0 +1,14 @@ + +#----------------------------- Global Properties ----------------------------# + +/outputDir:bin/$(Platform) +/intermediateDir:obj/$(Platform) +/platform:Windows +/config: +/profile:Reach +/compress:False + +#-------------------------------- References --------------------------------# + + +#---------------------------------- Content ---------------------------------# diff --git a/FractalVisualizer/FractalVisualizer.csproj b/FractalVisualizer/FractalVisualizer.csproj new file mode 100644 index 0000000..ce9af89 --- /dev/null +++ b/FractalVisualizer/FractalVisualizer.csproj @@ -0,0 +1,21 @@ + + + WinExe + net10.0-windows + enable + Major + false + false + true + + + app.manifest + Icon.ico + + + + + + + + diff --git a/FractalVisualizer/Game1.cs b/FractalVisualizer/Game1.cs new file mode 100644 index 0000000..2466a6f --- /dev/null +++ b/FractalVisualizer/Game1.cs @@ -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(); + } +} diff --git a/FractalVisualizer/Icon.ico b/FractalVisualizer/Icon.ico new file mode 100644 index 0000000..7d9dec1 Binary files /dev/null and b/FractalVisualizer/Icon.ico differ diff --git a/FractalVisualizer/Models/AudioFeatures.cs b/FractalVisualizer/Models/AudioFeatures.cs new file mode 100644 index 0000000..7476e30 --- /dev/null +++ b/FractalVisualizer/Models/AudioFeatures.cs @@ -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] + }; + } +} diff --git a/FractalVisualizer/Models/AudioInputMode.cs b/FractalVisualizer/Models/AudioInputMode.cs new file mode 100644 index 0000000..5b607ab --- /dev/null +++ b/FractalVisualizer/Models/AudioInputMode.cs @@ -0,0 +1,7 @@ +namespace FractalVisualizer.Models; + +public enum AudioInputMode +{ + Microphone = 0, + SystemOutput = 1 +} diff --git a/FractalVisualizer/Program.cs b/FractalVisualizer/Program.cs new file mode 100644 index 0000000..d809a73 --- /dev/null +++ b/FractalVisualizer/Program.cs @@ -0,0 +1,2 @@ +using var game = new FractalVisualizer.Game1(); +game.Run(); diff --git a/FractalVisualizer/Rendering/FractalRenderer.cs b/FractalVisualizer/Rendering/FractalRenderer.cs new file mode 100644 index 0000000..c496db9 --- /dev/null +++ b/FractalVisualizer/Rendering/FractalRenderer.cs @@ -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 _notes = []; + private readonly List _fireworkRockets = []; + private readonly List _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; + } +} diff --git a/FractalVisualizer/Rendering/Shaders/.gitkeep b/FractalVisualizer/Rendering/Shaders/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/FractalVisualizer/Rendering/Shaders/.gitkeep @@ -0,0 +1 @@ + diff --git a/FractalVisualizer/SettingsManager.cs b/FractalVisualizer/SettingsManager.cs new file mode 100644 index 0000000..a08f43a --- /dev/null +++ b/FractalVisualizer/SettingsManager.cs @@ -0,0 +1,5 @@ +namespace FractalVisualizer; + +public sealed class SettingsManager +{ +} diff --git a/FractalVisualizer/UI/.gitkeep b/FractalVisualizer/UI/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/FractalVisualizer/UI/.gitkeep @@ -0,0 +1 @@ + diff --git a/FractalVisualizer/UI/HudBitmapFont.cs b/FractalVisualizer/UI/HudBitmapFont.cs new file mode 100644 index 0000000..99853b9 --- /dev/null +++ b/FractalVisualizer/UI/HudBitmapFont.cs @@ -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 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); + } + } + } +} diff --git a/FractalVisualizer/app.manifest b/FractalVisualizer/app.manifest new file mode 100644 index 0000000..9f744e9 --- /dev/null +++ b/FractalVisualizer/app.manifest @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + diff --git a/Fractal_Audio_Visualizer_Spec.md b/Fractal_Audio_Visualizer_Spec.md index da265f0..71ae3b7 100644 --- a/Fractal_Audio_Visualizer_Spec.md +++ b/Fractal_Audio_Visualizer_Spec.md @@ -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. ------------------------------------------------------------------------ diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/SoundFlix.slnx b/SoundFlix.slnx index 4e2253d..d469458 100644 --- a/SoundFlix.slnx +++ b/SoundFlix.slnx @@ -1 +1,3 @@ - + + +