From f5f0332aa4744602b814e942cc6f47308e4ec160 Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 16:03:24 -0400 Subject: [PATCH 1/9] feat: add optional real-time frequency histogram visualizer to now playing view --- smoc.Tests/Fakes/FakePlaybackService.cs | 13 + .../Components/FrequencyHistogramViewTest.cs | 127 +++++++++ smoc.Tests/Ui/NowPlayingViewTest.cs | 70 ++++- .../InitialState_ShowsEmptyEqualizer.golden | 12 + ...alFallback_RendersDeterministically.golden | 12 + ...ithRealSpectrum_MapsLogarithmically.golden | 12 + ...ansition_ToPaused_DecaysFrequencies.golden | 12 + .../BecomesVisible_SelectsCurrentSong.golden | 4 +- .../SongChanged_HighlightsSong.golden | 4 +- .../SetHighlightedRow_HighlightsRow.golden | 4 +- .../InitialState_ShowsEmpty.golden | 2 +- smoc/Services/Audio/IPlaybackService.cs | 10 + .../SoundFlow/SoundFlowPlaybackService.cs | 51 ++++ smoc/Services/IPlaybackQueueService.cs | 10 + smoc/Services/StandardPlaybackQueueService.cs | 13 + smoc/Ui/Components/FrequencyHistogramView.cs | 266 ++++++++++++++++++ smoc/Ui/NowPlayingView.cs | 45 ++- 17 files changed, 658 insertions(+), 9 deletions(-) create mode 100644 smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs create mode 100644 smoc.Tests/goldens/FrequencyHistogramViewTest/InitialState_ShowsEmptyEqualizer.golden create mode 100644 smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden create mode 100644 smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden create mode 100644 smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden create mode 100644 smoc/Ui/Components/FrequencyHistogramView.cs diff --git a/smoc.Tests/Fakes/FakePlaybackService.cs b/smoc.Tests/Fakes/FakePlaybackService.cs index 6de4c25..28c780f 100644 --- a/smoc.Tests/Fakes/FakePlaybackService.cs +++ b/smoc.Tests/Fakes/FakePlaybackService.cs @@ -13,8 +13,21 @@ public class FakePlaybackService(Song song) : IPlaybackService { public PlaybackState PlaybackState { get; private set; } = PlaybackState.Stopped; + /// + /// Gets the simulated frequency spectrum data. + /// public Song Song { get; } = song; + /// + /// Gets or sets the simulated frequency spectrum data array. + /// + public float[] SpectrumData { get; set; } = Array.Empty(); + + /// + /// Gets or sets a value indicating whether simulated frequency spectrum analysis is active. + /// + public bool IsSpectrumActive { get; set; } = false; + public event EventHandler? SongEnded; public event EventHandler? PositionChanged; public event EventHandler? PlaybackStateChanged; diff --git a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs new file mode 100644 index 0000000..0775108 --- /dev/null +++ b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs @@ -0,0 +1,127 @@ +namespace smoc.Tests.Ui.Components; + +using Moq; +using smoc.Tests.TestInfra; +using Smoc.Services; +using Smoc.Ui.Components; +using Terminal.Gui.Views; +using View = Terminal.Gui.ViewBase.View; + +/// +/// Unit tests for verifying rendering, mapping, and state changes. +/// +public class FrequencyHistogramViewTest { + private readonly Mock _mockPlaybackQueue; + private readonly ScreenshotDiffer _screenshotDiffer; + + /// + /// Initializes a new instance of the class. + /// + /// The test output helper provided by xUnit. + public FrequencyHistogramViewTest(ITestOutputHelper output) { + _mockPlaybackQueue = new Mock(); + _screenshotDiffer = new ScreenshotDiffer(output); + } + + private static AppTestHelper NewContext(int width = 40, int height = 10) => With.A(width, height, TestDriver.ANSI.ToString()); + + private FrequencyHistogramView NewVisualizer(Func? timeSource = null) => new FrequencyHistogramView(_mockPlaybackQueue.Object, timeSource); + + private AppTestHelper NewVisualizerContext(int width = 40, int height = 10, Func? timeSource = null) { + var view = NewVisualizer(timeSource); + view.Visible = true; + return NewContext(width, height).AddAndLayout(view); + } + + /// + /// Verifies that when playback is stopped/empty, the equalizer displays completely flat/empty bars. + /// + [Fact] + public void InitialState_ShowsEmptyEqualizer() { + _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Stopped); + _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]); + + using var context = NewVisualizerContext(); + _screenshotDiffer.AssertEqualsGolden(context); + } + + /// + /// Verifies that when music is playing and no real FFT data is available, the procedural wave fallback renders beautifully and deterministically. + /// + [Fact] + public void PlayingState_ProceduralFallback_RendersDeterministically() { + _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing); + _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]); + + // Use a fixed simulated time point for frozen deterministic screenshot + using var context = NewVisualizerContext(timeSource: () => 3500.0); + + // Trigger two update ticks to transition attack/decay interpolation towards target values + context.Then((_) => { + // Direct call to trigger a redraw update + var view = (FrequencyHistogramView)context.App!.TopRunnableView!.SubViews.First(); + // Invoke private UpdateVisualization via reflection to force update states + var updateMethod = typeof(FrequencyHistogramView).GetMethod("UpdateVisualization", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + updateMethod?.Invoke(view, null); + updateMethod?.Invoke(view, null); + }); + + _screenshotDiffer.AssertEqualsGolden(context); + } + + /// + /// Verifies that when real-time spectrum data is active, the frequency analyzer logarithmically maps the FFT bands correctly to equalizer columns. + /// + [Fact] + public void PlayingState_WithRealSpectrum_MapsLogarithmically() { + _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing); + + // Simulate 32 frequency bins from SoundFlow with distinct bass (strong) and treble peaks + float[] mockFrequencies = new float[32]; + mockFrequencies[1] = 0.8f; // Bass peak + mockFrequencies[2] = 0.9f; + mockFrequencies[10] = 0.5f; // Mid peak + mockFrequencies[28] = 0.3f; // Treble peak + _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns(mockFrequencies); + + using var context = NewVisualizerContext(); + + context.Then((_) => { + var view = (FrequencyHistogramView)context.App!.TopRunnableView!.SubViews.First(); + var updateMethod = typeof(FrequencyHistogramView).GetMethod("UpdateVisualization", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + updateMethod?.Invoke(view, null); + updateMethod?.Invoke(view, null); + }); + + _screenshotDiffer.AssertEqualsGolden(context); + } + + /// + /// Verifies that when music transitions from playing to paused/stopped, the frequencies decay smoothly towards zero. + /// + [Fact] + public void StateTransition_ToPaused_DecaysFrequencies() { + // 1. Start as playing with procedural values + _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing); + _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]); + + using var context = NewVisualizerContext(timeSource: () => 2000.0); + + context.Then((_) => { + var view = (FrequencyHistogramView)context.App!.TopRunnableView!.SubViews.First(); + var updateMethod = typeof(FrequencyHistogramView).GetMethod("UpdateVisualization", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + // Pump initial values + updateMethod?.Invoke(view, null); + updateMethod?.Invoke(view, null); + + // 2. Pause playback + _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Paused); + + // 3. Pump updates to run the decay logic + updateMethod?.Invoke(view, null); + updateMethod?.Invoke(view, null); + }); + + _screenshotDiffer.AssertEqualsGolden(context); + } +} diff --git a/smoc.Tests/Ui/NowPlayingViewTest.cs b/smoc.Tests/Ui/NowPlayingViewTest.cs index 9cb28f3..a22ca52 100644 --- a/smoc.Tests/Ui/NowPlayingViewTest.cs +++ b/smoc.Tests/Ui/NowPlayingViewTest.cs @@ -8,6 +8,7 @@ using Smoc.Ui; using Smoc.Ui.Models; using Terminal.Gui.Views; +using View = Terminal.Gui.ViewBase.View; namespace smoc.Tests.Ui; @@ -81,7 +82,6 @@ public void OnSongChanged_SongIsNull_UpdatesSongDetails() { .Then((_) => handler?.Invoke(null, null)); _screenshotDiffer.AssertEqualsGolden(context); } - [Fact] public void OnSongChanged_LoadsAlbumArt() { var song = EntityTestFactory.GenerateSong(); @@ -144,6 +144,74 @@ public void OnPositionChanged_UpdatesPosition_MultipleTimes() { _screenshotDiffer.AssertEqualsGolden(context); } + /// + /// Verifies that pressing the 'v' hotkey successfully toggles the visualizer component. + /// + [Fact] + public void ToggleVisualization_Hotkey_TogglesVisibility() { + using var context = NewNowPlayingContext(); + var view = (NowPlayingView)context.App!.TopRunnableView!.SubViews.First(); + + // Find internal fields via reflection to verify visibility + var albumArtField = typeof(NowPlayingView).GetField("_albumArtView", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var histogramField = typeof(NowPlayingView).GetField("_histogramView", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + var albumArt = (View?)albumArtField?.GetValue(view); + var histogram = (View?)histogramField?.GetValue(view); + + Assert.NotNull(albumArt); + Assert.NotNull(histogram); + + // Initial state: album art visible, histogram hidden + Assert.True(albumArt.Visible); + Assert.False(histogram.Visible); + + // Toggle ON with 'v' hotkey + context.KeyDown(Terminal.Gui.Input.Key.V); + Assert.False(albumArt.Visible); + Assert.True(histogram.Visible); + _mockPlaybackQueue.VerifySet(q => q.IsSpectrumActive = true, Times.Once()); + + // Toggle OFF + context.KeyDown(Terminal.Gui.Input.Key.V); + Assert.True(albumArt.Visible); + Assert.False(histogram.Visible); + _mockPlaybackQueue.VerifySet(q => q.IsSpectrumActive = false, Times.Exactly(2)); + } + + /// + /// Verifies that running the 'np-vis' command successfully toggles the visualizer component. + /// + [Fact] + public void ToggleVisualization_Command_TogglesVisibility() { + using var context = NewNowPlayingContext(); + var view = (NowPlayingView)context.App!.TopRunnableView!.SubViews.First(); + + var albumArtField = typeof(NowPlayingView).GetField("_albumArtView", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var histogramField = typeof(NowPlayingView).GetField("_histogramView", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + var albumArt = (View?)albumArtField?.GetValue(view); + var histogram = (View?)histogramField?.GetValue(view); + + Assert.NotNull(albumArt); + Assert.NotNull(histogram); + + Assert.True(albumArt.Visible); + Assert.False(histogram.Visible); + + // Toggle ON with command + context.Then((_) => _commandService.ExecuteCommand("np-vis")); + Assert.False(albumArt.Visible); + Assert.True(histogram.Visible); + _mockPlaybackQueue.VerifySet(q => q.IsSpectrumActive = true, Times.Once()); + + // Toggle OFF + context.Then((_) => _commandService.ExecuteCommand("np-vis")); + Assert.True(albumArt.Visible); + Assert.False(histogram.Visible); + _mockPlaybackQueue.VerifySet(q => q.IsSpectrumActive = false, Times.Exactly(2)); + } + private static Image GetImage() { return new Image(10, 10); } diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/InitialState_ShowsEmptyEqualizer.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/InitialState_ShowsEmptyEqualizer.golden new file mode 100644 index 0000000..29f3c31 --- /dev/null +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/InitialState_ShowsEmptyEqualizer.golden @@ -0,0 +1,12 @@ +FrequencyHistogramViewTest.InitialState_ShowsEmptyEqualizer_0: + + + + + + + + + + + diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden new file mode 100644 index 0000000..579fca1 --- /dev/null +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden @@ -0,0 +1,12 @@ +FrequencyHistogramViewTest.PlayingState_ProceduralFallback_RendersDeterministically_0: + + + + + + + + + + + diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden new file mode 100644 index 0000000..ae0669e --- /dev/null +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden @@ -0,0 +1,12 @@ +FrequencyHistogramViewTest.PlayingState_WithRealSpectrum_MapsLogarithmically_0: + + + + + + + + + + + diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden new file mode 100644 index 0000000..8bdb566 --- /dev/null +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden @@ -0,0 +1,12 @@ +FrequencyHistogramViewTest.StateTransition_ToPaused_DecaysFrequencies_0: + + + + + + + + + + + diff --git a/smoc.Tests/goldens/PlaybackQueueViewTest/BecomesVisible_SelectsCurrentSong.golden b/smoc.Tests/goldens/PlaybackQueueViewTest/BecomesVisible_SelectsCurrentSong.golden index 48e4679..36347b7 100644 --- a/smoc.Tests/goldens/PlaybackQueueViewTest/BecomesVisible_SelectsCurrentSong.golden +++ b/smoc.Tests/goldens/PlaybackQueueViewTest/BecomesVisible_SelectsCurrentSong.golden @@ -1,8 +1,8 @@ PlaybackQueueViewTest.BecomesVisible_SelectsCurrentSong_0_ansi: ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│Artist  Album  Track  Length │ +│Artist  Album  Track  Length │ │Radiohead OK Computer BecomesVisible_SelectsCurren… 5:00 │ -│Radiohead OK Computer BecomesVisible_SelectsCurren… 5:00 │ +│Radiohead OK Computer BecomesVisible_SelectsCurren… 5:00 │ │ │ │ │ │ │ diff --git a/smoc.Tests/goldens/PlaybackQueueViewTest/SongChanged_HighlightsSong.golden b/smoc.Tests/goldens/PlaybackQueueViewTest/SongChanged_HighlightsSong.golden index c58df36..1716c58 100644 --- a/smoc.Tests/goldens/PlaybackQueueViewTest/SongChanged_HighlightsSong.golden +++ b/smoc.Tests/goldens/PlaybackQueueViewTest/SongChanged_HighlightsSong.golden @@ -1,8 +1,8 @@ PlaybackQueueViewTest.SongChanged_HighlightsSong_0_ansi: ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│Artist  Album  Track  Length │ +│Artist  Album  Track  Length │ │Radiohead OK Computer SongChanged_HighlightsSong1 5:00 │ -│Radiohead OK Computer SongChanged_HighlightsSong2 5:00 │ +│Radiohead OK Computer SongChanged_HighlightsSong2 5:00 │ │ │ │ │ │ │ diff --git a/smoc.Tests/goldens/SongTableTest/SetHighlightedRow_HighlightsRow.golden b/smoc.Tests/goldens/SongTableTest/SetHighlightedRow_HighlightsRow.golden index 3794f2c..a10a413 100644 --- a/smoc.Tests/goldens/SongTableTest/SetHighlightedRow_HighlightsRow.golden +++ b/smoc.Tests/goldens/SongTableTest/SetHighlightedRow_HighlightsRow.golden @@ -1,8 +1,8 @@ SongTableTest.SetHighlightedRow_HighlightsRow_0_ansi: ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│#  Artist  Album  Track  Length Year │ +│#  Artist  Album  Track  Length Year │ │1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │ -│1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │ +│1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │ │1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │ │ │ │ │ diff --git a/smoc.Tests/goldens/StatusBarTest/InitialState_ShowsEmpty.golden b/smoc.Tests/goldens/StatusBarTest/InitialState_ShowsEmpty.golden index f6a71cd..0d8f108 100644 --- a/smoc.Tests/goldens/StatusBarTest/InitialState_ShowsEmpty.golden +++ b/smoc.Tests/goldens/StatusBarTest/InitialState_ShowsEmpty.golden @@ -1,5 +1,5 @@ StatusBarTest.InitialState_ShowsEmpty_0_ansi: -   SMoC v1.0.0 +   SMoC v1.0.0  diff --git a/smoc/Services/Audio/IPlaybackService.cs b/smoc/Services/Audio/IPlaybackService.cs index 6ded11c..cb84163 100644 --- a/smoc/Services/Audio/IPlaybackService.cs +++ b/smoc/Services/Audio/IPlaybackService.cs @@ -21,6 +21,16 @@ public interface IPlaybackService : IDisposable { /// event EventHandler? PlaybackStateChanged; + /// + /// Gets the current frequency spectrum data from playback. + /// + float[] SpectrumData { get; } + + /// + /// Gets or sets a value indicating whether frequency spectrum analysis is active. + /// + bool IsSpectrumActive { get; set; } + /// /// Gets the current playback position; or if no song is playing. /// diff --git a/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs b/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs index eb17b0a..1f45516 100644 --- a/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs +++ b/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs @@ -6,6 +6,7 @@ using SoundFlow.Providers; using SoundFlow.Structs; using Terminal.Gui.App; +using SoundFlow.Visualization; using SoundFlowPlaybackState = SoundFlow.Enums.PlaybackState; namespace Smoc.Services.Audio.SoundFlow; @@ -20,6 +21,8 @@ public sealed class SoundFlowPlaybackService : IPlaybackService { private readonly AssetDataProvider _streamDataProvider; private readonly SoundPlayer _soundPlayer; private readonly Song _song; + private readonly SpectrumAnalyzer? _spectrumAnalyzer; + private readonly LevelMeterAnalyzer? _levelMeterAnalyzer; /// public event EventHandler? SongEnded; @@ -36,6 +39,35 @@ public sealed class SoundFlowPlaybackService : IPlaybackService { /// public TimeSpan CurrentTime => TimeSpan.FromSeconds(_soundPlayer.Time); + /// + public float[] SpectrumData { + get { + if (_spectrumAnalyzer == null) return Array.Empty(); + float[] raw = _spectrumAnalyzer.SpectrumData; + if (raw == null || raw.Length == 0) return Array.Empty(); + + float peak = _levelMeterAnalyzer?.Peak ?? 1.0f; + float[] scaled = new float[raw.Length]; + for (int i = 0; i < raw.Length; i++) { + scaled[i] = raw[i] * peak; + } + return scaled; + } + } + + /// + public bool IsSpectrumActive { + get => (_spectrumAnalyzer?.Enabled ?? false) && (_levelMeterAnalyzer?.Enabled ?? false); + set { + if (_spectrumAnalyzer != null) { + _spectrumAnalyzer.Enabled = value; + } + if (_levelMeterAnalyzer != null) { + _levelMeterAnalyzer.Enabled = value; + } + } + } + /// public float Progress => _soundPlayer.Time / _soundPlayer.Duration; @@ -67,6 +99,19 @@ public SoundFlowPlaybackService(MiniAudioEngine audioEngine, AudioPlaybackDevice _streamDataProvider = new AssetDataProvider(audioEngine, audioFormat, songStream); _soundPlayer = new SoundPlayer(audioEngine, audioFormat, _streamDataProvider); _playbackDevice.MasterMixer.AddComponent(_soundPlayer); + + try { + _spectrumAnalyzer = new SpectrumAnalyzer(audioFormat, 1024, null); + _spectrumAnalyzer.Enabled = false; + _soundPlayer.AddAnalyzer(_spectrumAnalyzer); + + _levelMeterAnalyzer = new LevelMeterAnalyzer(audioFormat, null); + _levelMeterAnalyzer.Enabled = false; + _soundPlayer.AddAnalyzer(_levelMeterAnalyzer); + } catch (Exception ex) { + Logging.Warning($"Failed to initialize spectrum analyzer: {ex.Message}"); + } + _streamDataProvider.PositionChanged += (sender, args) => PositionChanged?.Invoke(this, CurrentTime); _soundPlayer.PlaybackEnded += (sender, args) => SongEnded?.Invoke(this, EventArgs.Empty); } @@ -105,6 +150,12 @@ public void Seek(TimeSpan position) { /// public void Dispose() { + if (_spectrumAnalyzer != null) { + _soundPlayer.RemoveAnalyzer(_spectrumAnalyzer); + } + if (_levelMeterAnalyzer != null) { + _soundPlayer.RemoveAnalyzer(_levelMeterAnalyzer); + } _playbackDevice.MasterMixer.RemoveComponent(_soundPlayer); _soundPlayer.Dispose(); _streamDataProvider.Dispose(); diff --git a/smoc/Services/IPlaybackQueueService.cs b/smoc/Services/IPlaybackQueueService.cs index 6f479e8..379aefc 100644 --- a/smoc/Services/IPlaybackQueueService.cs +++ b/smoc/Services/IPlaybackQueueService.cs @@ -21,6 +21,16 @@ public interface IPlaybackQueueService : IDisposable { /// event EventHandler? PlaybackStateChanged; + /// + /// Gets the current frequency spectrum data from the active song. + /// + float[] SpectrumData { get; } + + /// + /// Gets or sets a value indicating whether frequency spectrum analysis is active. + /// + bool IsSpectrumActive { get; set; } + /// /// Occurs when the playback position changes (e.g. during playback). /// diff --git a/smoc/Services/StandardPlaybackQueueService.cs b/smoc/Services/StandardPlaybackQueueService.cs index f9ae3e1..27fef78 100644 --- a/smoc/Services/StandardPlaybackQueueService.cs +++ b/smoc/Services/StandardPlaybackQueueService.cs @@ -50,6 +50,19 @@ public sealed class StandardPlaybackQueueService : IPlaybackQueueService { /// public float Progress => _playbackService.Resource?.Progress ?? 0; + /// + public float[] SpectrumData => _playbackService.Resource?.SpectrumData ?? Array.Empty(); + + /// + public bool IsSpectrumActive { + get => _playbackService.Resource?.IsSpectrumActive ?? false; + set { + if (_playbackService.Resource != null) { + _playbackService.Resource.IsSpectrumActive = value; + } + } + } + /// public IEnumerable GetCurrentPlaybackQueue() => _playbackQueue.ToList(); diff --git a/smoc/Ui/Components/FrequencyHistogramView.cs b/smoc/Ui/Components/FrequencyHistogramView.cs new file mode 100644 index 0000000..744f7b8 --- /dev/null +++ b/smoc/Ui/Components/FrequencyHistogramView.cs @@ -0,0 +1,266 @@ +namespace Smoc.Ui.Components; + +using System; +using System.Drawing; +using Smoc.Services; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Color = Terminal.Gui.Drawing.Color; +using Attribute = Terminal.Gui.Drawing.Attribute; + +/// +/// A terminal view that renders a beautiful dynamic frequency histogram/equalizer +/// representing the frequencies of the currently playing track. +/// +public sealed class FrequencyHistogramView : View { + private static readonly Color[] _gradientColors = [ + new Color(0, 220, 100), // Green + new Color(100, 220, 0), // Lime + new Color(220, 220, 0), // Yellow + new Color(255, 150, 0), // Orange + new Color(255, 50, 50) // Red + ]; + + private readonly IPlaybackQueueService _playbackQueueService; + private readonly Func _timeSource; + private float[] _amplitudes = []; + private object? _timerToken; + + /// + /// Initializes a new instance of the class. + /// + /// The playback queue service for retrieving spectrum data. + /// An optional custom time source for deterministic testing. + public FrequencyHistogramView(IPlaybackQueueService playbackQueueService, Func? timeSource = null) { + _playbackQueueService = playbackQueueService; + _timeSource = timeSource ?? (() => DateTime.UtcNow.Subtract(DateTime.UnixEpoch).TotalMilliseconds); + CanFocus = false; + + _playbackQueueService.PlaybackStateChanged += OnPlaybackStateChanged; + } + + /// + protected override void OnVisibleChanged() { + base.OnVisibleChanged(); + UpdateTimerState(); + } + + /// + protected override bool OnDrawingContent(DrawContext? context) { + base.OnDrawingContent(context); + + Rectangle contentArea = Viewport; + if (contentArea.Width <= 0 || contentArea.Height <= 0) { + return true; + } + + // Space-separated bars (1 character for bar, 1 character space gap) + int totalBars = (contentArea.Width + 1) / 2; + if (_amplitudes.Length != totalBars) { + Array.Resize(ref _amplitudes, totalBars); + } + + UpdateTimerState(); + + int height = contentArea.Height; + var currentAttr = GetCurrentAttribute(); + + for (int i = 0; i < totalBars; i++) { + int col = i * 2; + float amp = _amplitudes[i]; + float totalLevels = amp * height * 8; + + for (int r = 0; r < height; r++) { + int cellLevel = (int)totalLevels - (r * 8); + int row = height - 1 - r; // Draw from bottom up + + // Select the color for this vertical level segment + int colorIndex = (int)Math.Clamp((double)r / Math.Max(1, height) * _gradientColors.Length, 0, _gradientColors.Length - 1); + var barColor = _gradientColors[colorIndex]; + SetAttribute(new Attribute(barColor, currentAttr.Background)); + + if (cellLevel <= 0) { + if (Move(col, row)) { + AddRune(' '); + } + } else if (cellLevel >= 8) { + if (Move(col, row)) { + AddRune('█'); + } + } else { + char blockChar = cellLevel switch { + 1 => ' ', + 2 => '▂', + 3 => '▃', + 4 => '▄', + 5 => '▅', + 6 => '▆', + 7 => '▇', + _ => ' ' + }; + if (Move(col, row)) { + AddRune(blockChar); + } + } + + if (col + 1 < contentArea.Width) { + SetAttribute(currentAttr); + if (Move(col + 1, row)) { + AddRune(' '); + } + } + } + } + + // Restore the original terminal style + SetAttribute(currentAttr); + return true; + } + + /// + protected override void Dispose(bool disposing) { + _playbackQueueService.PlaybackStateChanged -= OnPlaybackStateChanged; + StopTimer(); + base.Dispose(disposing); + } + + private void OnPlaybackStateChanged(object? sender, PlaybackState state) { + UpdateTimerState(); + } + + private void UpdateTimerState() { + bool isDecaying = false; + if (_amplitudes != null) { + for (int i = 0; i < _amplitudes.Length; i++) { + if (_amplitudes[i] > 0f) { + isDecaying = true; + break; + } + } + } + + bool shouldRun = Visible && (_playbackQueueService.PlaybackState == PlaybackState.Playing || isDecaying); + if (shouldRun) { + StartTimer(); + } else { + StopTimer(); + } + } + + private void StartTimer() { + if (_timerToken != null || App == null) { + return; + } + + _timerToken = App.AddTimeout(TimeSpan.FromMilliseconds(100), () => { + UpdateVisualization(); + return true; + }); + } + + private void StopTimer() { + if (_timerToken != null && App != null) { + App.RemoveTimeout(_timerToken); + _timerToken = null; + } + } + + private void UpdateVisualization() { + if (App == null || !Visible) { + StopTimer(); + return; + } + + bool isPlaying = _playbackQueueService.PlaybackState == PlaybackState.Playing; + int totalBars = _amplitudes.Length; + + if (totalBars == 0) { + SetNeedsDraw(); + return; + } + + float[] spectrum = _playbackQueueService.SpectrumData; + + // Use atomic / thread-safe local reference for the spectrum data + if (isPlaying && spectrum != null && spectrum.Length > 0) { + int minBin = Math.Min(1, spectrum.Length - 1); + // Discard DC offset (bin 0) and map up to 20 kHz (approx 90.7% of Nyquist at 44.1 kHz sample rate) + int maxBin = Math.Clamp((int)(spectrum.Length * 0.907f), minBin + 1, spectrum.Length); + + // Group spectrum bins logarithmically into columns + for (int i = 0; i < totalBars; i++) { + double lowFrac = Math.Pow((double)i / totalBars, 1.5); + double highFrac = Math.Pow((double)(i + 1) / totalBars, 1.5); + + int startBin = minBin + (int)(lowFrac * (maxBin - minBin)); + int endBin = minBin + (int)(highFrac * (maxBin - minBin)); + if (endBin <= startBin) { + endBin = startBin + 1; + } + + float sum = 0f; + int count = 0; + for (int bin = startBin; bin < endBin && bin < spectrum.Length; bin++) { + sum += spectrum[bin]; + count++; + } + float amp = count > 0 ? sum / count : 0f; + + // Apply a psychoacoustic weighting filter (sine window) to roll off subsonic DC offset on the left and ultrasonic noise on the right + double fraction = (double)i / totalBars; + double weight = Math.Sin((0.05 + 0.90 * fraction) * Math.PI); + + // Scale with a safer 4.5f coefficient to completely eliminate excessive pegging + float target = amp * 4.5f * (float)weight; + target = Math.Clamp(target, 0.0f, 1.0f); + + // Attack & decay smoothing + if (target > _amplitudes[i]) { + _amplitudes[i] += (target - _amplitudes[i]) * 0.7f; // Quick attack + } else { + _amplitudes[i] += (target - _amplitudes[i]) * 0.25f; // Gradual decay + } + } + } else if (isPlaying) { + // Procedural fallback animation (e.g. for unit tests or streams without FFT) + double t = _timeSource(); + for (int i = 0; i < totalBars; i++) { + double tSeconds = t / 1000.0; + double fraction = (double)i / totalBars; + + double speed = 3.0 + fraction * 10.0; + double wave1 = Math.Sin(tSeconds * speed + i * 0.8); + double wave2 = Math.Cos(tSeconds * (speed * 0.5) - i * 0.4); + double wave3 = Math.Sin(tSeconds * (speed * 0.2) + i * 1.5); + double blended = 0.5 * wave1 + 0.3 * wave2 + 0.2 * wave3; + + double freqBias = Math.Pow(1.0 - fraction, 0.3); + float target = (float)((0.1 + 0.9 * Math.Abs(blended)) * freqBias); + target = Math.Clamp(target, 0.0f, 1.0f); + + if (target > _amplitudes[i]) { + _amplitudes[i] += (target - _amplitudes[i]) * 0.6f; + } else { + _amplitudes[i] += (target - _amplitudes[i]) * 0.3f; + } + } + } else { + // Natural visual decay fallback to 0 when paused or stopped + bool stillDecaying = false; + for (int i = 0; i < totalBars; i++) { + _amplitudes[i] *= 0.75f; + if (_amplitudes[i] < 0.01f) { + _amplitudes[i] = 0f; + } else { + stillDecaying = true; + } + } + + if (!stillDecaying) { + StopTimer(); + } + } + + SetNeedsDraw(); + } +} diff --git a/smoc/Ui/NowPlayingView.cs b/smoc/Ui/NowPlayingView.cs index 3e5589b..0a855fa 100644 --- a/smoc/Ui/NowPlayingView.cs +++ b/smoc/Ui/NowPlayingView.cs @@ -9,6 +9,7 @@ namespace Smoc.Ui; using Smoc.Ui.Models; using Terminal.Gui.App; using Terminal.Gui.Drawing; +using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; @@ -29,6 +30,7 @@ private static class Messages { private readonly IMainWindow _mainWindow; private readonly CommandService _commandService; private readonly SixelImageView _albumArtView; + private readonly FrequencyHistogramView _histogramView; private readonly Label _songLabel; private readonly Label _artistLabel; private readonly View _progressContainer; @@ -37,6 +39,7 @@ private static class Messages { private readonly Label _durationLabel; private Album? _currentAlbum; private CancellationTokenSource? _albumArtCancellationTokenSource; + private bool _showVisualization = false; /// @@ -77,6 +80,14 @@ public NowPlayingView(IMainWindow mainWindow, CommandService commandService, IPl }; _albumArtView.Margin!.Thickness = new Thickness(0, 0, 1, 1); + _histogramView = new FrequencyHistogramView(playbackQueueService) { + X = Pos.Center(), + Y = Pos.Center() - Pos.Percent(10), + Width = Dim.Func((v) => _albumArtView.Frame.Width), + Height = Dim.Func((v) => _albumArtView.Frame.Height), + Visible = false + }; + _songLabel = new Label() { X = Pos.Center(), Y = Pos.Bottom(_albumArtView), @@ -114,12 +125,17 @@ public NowPlayingView(IMainWindow mainWindow, CommandService commandService, IPl Reset(); - Add(_albumArtView, _songLabel, _artistLabel, _progressContainer); + Add(_albumArtView, _histogramView, _songLabel, _artistLabel, _progressContainer); _playbackQueueService.SongChanged += OnSongChanged; _playbackQueueService.PositionChanged += OnPositionChanged; _commandService.RegisterCommand("np", OnNowPlayingCommand); + _commandService.RegisterCommand("np-vis", OnToggleVisualizationCommand); + + AddCommand(Command.HotKey, OnHotKey); + HotKeyBindings.Add(Key.V, Command.HotKey); + HotKeyBindings.Add(Key.V.WithShift, Command.HotKey); } private void OnPositionChanged(object? sender, TimeSpan e) { @@ -166,6 +182,7 @@ private async void OnSongChanged(object? sender, Song? song) { protected override void Dispose(bool disposing) { _commandService.UnregisterCommand("np"); + _commandService.UnregisterCommand("np-vis"); _playbackQueueService.SongChanged -= OnSongChanged; _playbackQueueService.PositionChanged -= OnPositionChanged; base.Dispose(disposing); @@ -176,6 +193,10 @@ private void OnNowPlayingCommand(string _, string __) { } private void Reset() { + _showVisualization = false; + _albumArtView.Visible = true; + _histogramView.Visible = false; + _playbackQueueService.IsSpectrumActive = false; _songLabel.Text = Messages.NO_SONG; _artistLabel.Text = Messages.NO_ARTIST; _positionLabel.Text = "--:--"; @@ -184,4 +205,26 @@ private void Reset() { _albumArtView.BorderStyle = LineStyle.Dashed; _progressBar.Fraction = 0.0f; } + + private void ToggleVisualization() { + _showVisualization = !_showVisualization; + _albumArtView.Visible = !_showVisualization; + _histogramView.Visible = _showVisualization; + _playbackQueueService.IsSpectrumActive = _showVisualization; + SetNeedsDraw(); + } + + private void OnToggleVisualizationCommand(string _, string __) { + ToggleVisualization(); + } + + private bool? OnHotKey(ICommandContext? ctx) { + if (ctx?.Binding is KeyBinding keyBinding && keyBinding.Key is Key pressedKey) { + if (pressedKey == Key.V || pressedKey == Key.V.WithShift) { + ToggleVisualization(); + return true; + } + } + return null; + } } From b8d5ac55bbd1277a06e9ca5df7477fb6c6c375bf Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 16:31:36 -0400 Subject: [PATCH 2/9] feat: add IsSpectrumActive state persistence across track transitions --- .../StandardPlaybackQueueServiceTest.cs | 35 +++++++++++++++++++ smoc/Services/StandardPlaybackQueueService.cs | 6 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs b/smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs index 34065d3..d801e3d 100644 --- a/smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs +++ b/smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs @@ -1059,4 +1059,39 @@ public async Task OnSongEnded_PreloadedTrackPlaysImmediately() { Assert.Equal(PlaybackState.Playing, fakePlayerService2.PlaybackState); } + + /// + /// Verifies that the IsSpectrumActive state persists across track transitions. + /// + [Fact] + public async Task Play_NewSong_PersistsIsSpectrumActiveState() { + var song1 = EntityTestFactory.GenerateSong(id: "1", postfix: "1"); + var song2 = EntityTestFactory.GenerateSong(id: "2", postfix: "2"); + using var sut = NewStandardPlaybackQueue(); + var fakePlayerService1 = new FakePlaybackService(song1); + var fakePlayerService2 = new FakePlaybackService(song2); + _mockAudioService.Setup(a => a.MakePlaybackService(song1, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(fakePlayerService1); + _mockAudioService.Setup(a => a.MakePlaybackService(song2, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(fakePlayerService2); + _mockStreamingClient.Setup(c => c.GetSongStreamAsync(song1.Id, It.IsAny())) + .ReturnsAsync(new SongStream(song1.Id, "m4a", new MemoryStream())); + _mockStreamingClient.Setup(c => c.GetSongStreamAsync(song2.Id, It.IsAny())) + .ReturnsAsync(new SongStream(song2.Id, "m4a", new MemoryStream())); + + sut.QueueNext([song1, song2]); + + // Enable visualizer + sut.IsSpectrumActive = true; + + await sut.Play(); + Assert.True(fakePlayerService1.IsSpectrumActive); + + // Transition to the next track + await sut.ChangeTrack(1); + await sut.Play(); + + // Verify that the second song inherits the IsSpectrumActive state automatically + Assert.True(fakePlayerService2.IsSpectrumActive); + } } \ No newline at end of file diff --git a/smoc/Services/StandardPlaybackQueueService.cs b/smoc/Services/StandardPlaybackQueueService.cs index 27fef78..c4a5171 100644 --- a/smoc/Services/StandardPlaybackQueueService.cs +++ b/smoc/Services/StandardPlaybackQueueService.cs @@ -53,10 +53,13 @@ public sealed class StandardPlaybackQueueService : IPlaybackQueueService { /// public float[] SpectrumData => _playbackService.Resource?.SpectrumData ?? Array.Empty(); + private bool _isSpectrumActive = false; + /// public bool IsSpectrumActive { - get => _playbackService.Resource?.IsSpectrumActive ?? false; + get => _isSpectrumActive; set { + _isSpectrumActive = value; if (_playbackService.Resource != null) { _playbackService.Resource.IsSpectrumActive = value; } @@ -236,6 +239,7 @@ public async Task Play() { } _playbackService.Replace(playback); + playback.IsSpectrumActive = _isSpectrumActive; playback.SongEnded += OnSongEnded; playback.PositionChanged += OnPositionChanged; playback.PlaybackStateChanged += OnPlaybackStateChanged; From fd787e1bd7b6678c5b33f20eb12825618028d942 Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 17:33:57 -0400 Subject: [PATCH 3/9] fix: tests not correctly rendering --- .../Components/FrequencyHistogramViewTest.cs | 55 +++++++++---------- ...alFallback_RendersDeterministically.golden | 8 +-- ...ithRealSpectrum_MapsLogarithmically.golden | 14 ++--- ...ansition_ToPaused_DecaysFrequencies.golden | 6 +- smoc/Ui/Components/FrequencyHistogramView.cs | 5 ++ 5 files changed, 44 insertions(+), 44 deletions(-) diff --git a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs index 0775108..a43da73 100644 --- a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs +++ b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs @@ -29,8 +29,17 @@ public FrequencyHistogramViewTest(ITestOutputHelper output) { private AppTestHelper NewVisualizerContext(int width = 40, int height = 10, Func? timeSource = null) { var view = NewVisualizer(timeSource); - view.Visible = true; - return NewContext(width, height).AddAndLayout(view); + view.Visible = false; // Start invisible so that OnVisibleChanged fires when context is active + view.Width = Terminal.Gui.ViewBase.Dim.Fill(); + view.Height = Terminal.Gui.ViewBase.Dim.Fill(); + var context = NewContext(width, height).AddAndLayout(view); + + // Set visible inside the running main loop so the timer successfully registers with App + context.Then((_) => { + view.Visible = true; + context.App!.TopRunnableView!.Layout(); + }); + return context; } /// @@ -57,14 +66,8 @@ public void PlayingState_ProceduralFallback_RendersDeterministically() { using var context = NewVisualizerContext(timeSource: () => 3500.0); // Trigger two update ticks to transition attack/decay interpolation towards target values - context.Then((_) => { - // Direct call to trigger a redraw update - var view = (FrequencyHistogramView)context.App!.TopRunnableView!.SubViews.First(); - // Invoke private UpdateVisualization via reflection to force update states - var updateMethod = typeof(FrequencyHistogramView).GetMethod("UpdateVisualization", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - updateMethod?.Invoke(view, null); - updateMethod?.Invoke(view, null); - }); + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); _screenshotDiffer.AssertEqualsGolden(context); } @@ -86,12 +89,8 @@ public void PlayingState_WithRealSpectrum_MapsLogarithmically() { using var context = NewVisualizerContext(); - context.Then((_) => { - var view = (FrequencyHistogramView)context.App!.TopRunnableView!.SubViews.First(); - var updateMethod = typeof(FrequencyHistogramView).GetMethod("UpdateVisualization", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - updateMethod?.Invoke(view, null); - updateMethod?.Invoke(view, null); - }); + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); _screenshotDiffer.AssertEqualsGolden(context); } @@ -107,20 +106,16 @@ public void StateTransition_ToPaused_DecaysFrequencies() { using var context = NewVisualizerContext(timeSource: () => 2000.0); - context.Then((_) => { - var view = (FrequencyHistogramView)context.App!.TopRunnableView!.SubViews.First(); - var updateMethod = typeof(FrequencyHistogramView).GetMethod("UpdateVisualization", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - // Pump initial values - updateMethod?.Invoke(view, null); - updateMethod?.Invoke(view, null); - - // 2. Pause playback - _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Paused); - - // 3. Pump updates to run the decay logic - updateMethod?.Invoke(view, null); - updateMethod?.Invoke(view, null); - }); + // Pump initial values + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + + // 2. Pause playback + _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Paused); + + // 3. Pump updates to run the decay logic + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); _screenshotDiffer.AssertEqualsGolden(context); } diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden index 579fca1..16f42b8 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden @@ -5,8 +5,8 @@ FrequencyHistogramViewTest.PlayingState_ProceduralFallback_RendersDeterministica - - - - + ▇ ▃ ▆ + ▃ █ █ ▂ █ ▇ █ ▇ + █ ▇ ▂ █ █ █ █ █ ▅ █ ▆ █ +█ █ █ █ █ █ █ █ █ █ █ █ █ ▇ █ █ █ ▆ ▅ ▅ diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden index ae0669e..31a7cb8 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden @@ -2,11 +2,11 @@ FrequencyHistogramViewTest.PlayingState_WithRealSpectrum_MapsLogarithmically_0: - - - - - - - + █ █ █ █ + █ █ █ █ + █ █ █ █ +▇ █ █ █ █ +█ █ █ █ █ +█ █ █ █ █ +█ █ █ █ █ ▇ diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden index 8bdb566..54f2f2b 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden @@ -6,7 +6,7 @@ FrequencyHistogramViewTest.StateTransition_ToPaused_DecaysFrequencies_0: - - - + ▃ ▆ + █ █ █ ▃ ▃ █ ▇ ▃ ▂ +█ █ █ █ ▇ ▂ █ █ █ █ █ █ ▆ █ ▅ █ ▄ █ ▆ ▆ diff --git a/smoc/Ui/Components/FrequencyHistogramView.cs b/smoc/Ui/Components/FrequencyHistogramView.cs index 744f7b8..f94b127 100644 --- a/smoc/Ui/Components/FrequencyHistogramView.cs +++ b/smoc/Ui/Components/FrequencyHistogramView.cs @@ -174,6 +174,11 @@ private void UpdateVisualization() { bool isPlaying = _playbackQueueService.PlaybackState == PlaybackState.Playing; int totalBars = _amplitudes.Length; + if (totalBars == 0 && Viewport.Width > 0) { + totalBars = (Viewport.Width + 1) / 2; + Array.Resize(ref _amplitudes, totalBars); + } + if (totalBars == 0) { SetNeedsDraw(); return; From eff5c0124d541b96bd716f540ab32c4707b7526d Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 17:39:17 -0400 Subject: [PATCH 4/9] refactor: refactor FrequencyHistogramView to use ITimeProvider for time management --- .../Ui/Components/FrequencyHistogramViewTest.cs | 17 +++++++++++------ ...uralFallback_RendersDeterministically.golden | 10 +++++----- ...Transition_ToPaused_DecaysFrequencies.golden | 6 +++--- smoc/Ui/Components/FrequencyHistogramView.cs | 12 +++++++----- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs index a43da73..77b9da4 100644 --- a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs +++ b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs @@ -5,6 +5,7 @@ namespace smoc.Tests.Ui.Components; using Smoc.Services; using Smoc.Ui.Components; using Terminal.Gui.Views; +using Terminal.Gui.Time; using View = Terminal.Gui.ViewBase.View; /// @@ -25,14 +26,18 @@ public FrequencyHistogramViewTest(ITestOutputHelper output) { private static AppTestHelper NewContext(int width = 40, int height = 10) => With.A(width, height, TestDriver.ANSI.ToString()); - private FrequencyHistogramView NewVisualizer(Func? timeSource = null) => new FrequencyHistogramView(_mockPlaybackQueue.Object, timeSource); + private FrequencyHistogramView NewVisualizer(ITimeProvider timeProvider) => new FrequencyHistogramView(_mockPlaybackQueue.Object, timeProvider); - private AppTestHelper NewVisualizerContext(int width = 40, int height = 10, Func? timeSource = null) { - var view = NewVisualizer(timeSource); + private AppTestHelper NewVisualizerContext(int width = 40, int height = 10, double? startEpochOffsetMs = null) { + var context = NewContext(width, height); + if (startEpochOffsetMs.HasValue) { + (context.TimeProvider as VirtualTimeProvider)?.SetTime(DateTime.UnixEpoch.AddMilliseconds(startEpochOffsetMs.Value)); + } + var view = NewVisualizer(context.TimeProvider); view.Visible = false; // Start invisible so that OnVisibleChanged fires when context is active view.Width = Terminal.Gui.ViewBase.Dim.Fill(); view.Height = Terminal.Gui.ViewBase.Dim.Fill(); - var context = NewContext(width, height).AddAndLayout(view); + context.AddAndLayout(view); // Set visible inside the running main loop so the timer successfully registers with App context.Then((_) => { @@ -63,7 +68,7 @@ public void PlayingState_ProceduralFallback_RendersDeterministically() { _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]); // Use a fixed simulated time point for frozen deterministic screenshot - using var context = NewVisualizerContext(timeSource: () => 3500.0); + using var context = NewVisualizerContext(startEpochOffsetMs: 3500.0); // Trigger two update ticks to transition attack/decay interpolation towards target values context.AdvanceTime(TimeSpan.FromMilliseconds(100)); @@ -104,7 +109,7 @@ public void StateTransition_ToPaused_DecaysFrequencies() { _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing); _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]); - using var context = NewVisualizerContext(timeSource: () => 2000.0); + using var context = NewVisualizerContext(startEpochOffsetMs: 2000.0); // Pump initial values context.AdvanceTime(TimeSpan.FromMilliseconds(100)); diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden index 16f42b8..3f94d4f 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden @@ -4,9 +4,9 @@ FrequencyHistogramViewTest.PlayingState_ProceduralFallback_RendersDeterministica - - ▇ ▃ ▆ - ▃ █ █ ▂ █ ▇ █ ▇ - █ ▇ ▂ █ █ █ █ █ ▅ █ ▆ █ -█ █ █ █ █ █ █ █ █ █ █ █ █ ▇ █ █ █ ▆ ▅ ▅ + ▂ + ▅ ▄ ▇ █ + █ █ █ ▄ █ █ + █ █ ▂ █ █ █ █ ▃ ▅ █ ▃ ▇ +█ █ █ █ █ █ █ █ ▇ █ ▆ █ █ █ █ █ ▆ ▇ ▃ █ diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden index 54f2f2b..3d94ea4 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden @@ -6,7 +6,7 @@ FrequencyHistogramViewTest.StateTransition_ToPaused_DecaysFrequencies_0: - ▃ ▆ - █ █ █ ▃ ▃ █ ▇ ▃ ▂ -█ █ █ █ ▇ ▂ █ █ █ █ █ █ ▆ █ ▅ █ ▄ █ ▆ ▆ + ▇ + █ ▂ ▅ ▄ █ ▄ █ ▅ ▆ +▃ ▅ █ █ █ █ █ █ ▅ █ ▅ █ █ ▃ █ ▄ ▄ █ ▆ diff --git a/smoc/Ui/Components/FrequencyHistogramView.cs b/smoc/Ui/Components/FrequencyHistogramView.cs index f94b127..1c11c87 100644 --- a/smoc/Ui/Components/FrequencyHistogramView.cs +++ b/smoc/Ui/Components/FrequencyHistogramView.cs @@ -1,3 +1,5 @@ +using Terminal.Gui.Time; + namespace Smoc.Ui.Components; using System; @@ -22,7 +24,7 @@ public sealed class FrequencyHistogramView : View { ]; private readonly IPlaybackQueueService _playbackQueueService; - private readonly Func _timeSource; + private readonly ITimeProvider _timeProvider; private float[] _amplitudes = []; private object? _timerToken; @@ -30,10 +32,10 @@ public sealed class FrequencyHistogramView : View { /// Initializes a new instance of the class. /// /// The playback queue service for retrieving spectrum data. - /// An optional custom time source for deterministic testing. - public FrequencyHistogramView(IPlaybackQueueService playbackQueueService, Func? timeSource = null) { + /// An optional custom time provider for deterministic testing. + public FrequencyHistogramView(IPlaybackQueueService playbackQueueService, ITimeProvider? timeProvider = null) { _playbackQueueService = playbackQueueService; - _timeSource = timeSource ?? (() => DateTime.UtcNow.Subtract(DateTime.UnixEpoch).TotalMilliseconds); + _timeProvider = timeProvider ?? new SystemTimeProvider(); CanFocus = false; _playbackQueueService.PlaybackStateChanged += OnPlaybackStateChanged; @@ -228,7 +230,7 @@ private void UpdateVisualization() { } } else if (isPlaying) { // Procedural fallback animation (e.g. for unit tests or streams without FFT) - double t = _timeSource(); + double t = _timeProvider.Now.ToUniversalTime().Subtract(DateTime.UnixEpoch).TotalMilliseconds; for (int i = 0; i < totalBars; i++) { double tSeconds = t / 1000.0; double fraction = (double)i / totalBars; From 15b8bdb6eab87577d844a4d6f07f97fd3683cbaf Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 18:24:44 -0400 Subject: [PATCH 5/9] fix: address comments --- ...duralFallback_RendersDeterministically.golden | 4 ++-- ...eTransition_ToPaused_DecaysFrequencies.golden | 6 +++--- .../BecomesVisible_SelectsCurrentSong.golden | 4 ++-- .../SongChanged_HighlightsSong.golden | 4 ++-- .../SetHighlightedRow_HighlightsRow.golden | 4 ++-- .../StatusBarTest/InitialState_ShowsEmpty.golden | 2 +- .../Audio/SoundFlow/SoundFlowPlaybackService.cs | 16 +++++++++++----- smoc/Ui/Components/FrequencyHistogramView.cs | 2 +- smoc/Ui/NowPlayingView.cs | 4 ++-- 9 files changed, 26 insertions(+), 20 deletions(-) diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden index 3f94d4f..0f8cd54 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden @@ -5,8 +5,8 @@ FrequencyHistogramViewTest.PlayingState_ProceduralFallback_RendersDeterministica ▂ - ▅ ▄ ▇ █ + ▅ ▁ ▄ ▇ █ █ █ █ ▄ █ █ - █ █ ▂ █ █ █ █ ▃ ▅ █ ▃ ▇ +▁ █ █ ▂ █ █ █ █ ▃ ▅ █ ▃ ▇ █ █ █ █ █ █ █ █ ▇ █ ▆ █ █ █ █ █ ▆ ▇ ▃ █ diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden index 3d94ea4..977c859 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden @@ -6,7 +6,7 @@ FrequencyHistogramViewTest.StateTransition_ToPaused_DecaysFrequencies_0: - ▇ - █ ▂ ▅ ▄ █ ▄ █ ▅ ▆ -▃ ▅ █ █ █ █ █ █ ▅ █ ▅ █ █ ▃ █ ▄ ▄ █ ▆ + ▇ ▁ + █ ▂ ▅ ▄ █ ▄ █ ▅ ▆ ▁ +▃ ▅ █ █ █ █ █ █ ▅ █ ▅ █ █ ▃ █ ▄ ▄ █ ▆ ▁ diff --git a/smoc.Tests/goldens/PlaybackQueueViewTest/BecomesVisible_SelectsCurrentSong.golden b/smoc.Tests/goldens/PlaybackQueueViewTest/BecomesVisible_SelectsCurrentSong.golden index 36347b7..48e4679 100644 --- a/smoc.Tests/goldens/PlaybackQueueViewTest/BecomesVisible_SelectsCurrentSong.golden +++ b/smoc.Tests/goldens/PlaybackQueueViewTest/BecomesVisible_SelectsCurrentSong.golden @@ -1,8 +1,8 @@ PlaybackQueueViewTest.BecomesVisible_SelectsCurrentSong_0_ansi: ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│Artist  Album  Track  Length │ +│Artist  Album  Track  Length │ │Radiohead OK Computer BecomesVisible_SelectsCurren… 5:00 │ -│Radiohead OK Computer BecomesVisible_SelectsCurren… 5:00 │ +│Radiohead OK Computer BecomesVisible_SelectsCurren… 5:00 │ │ │ │ │ │ │ diff --git a/smoc.Tests/goldens/PlaybackQueueViewTest/SongChanged_HighlightsSong.golden b/smoc.Tests/goldens/PlaybackQueueViewTest/SongChanged_HighlightsSong.golden index 1716c58..c58df36 100644 --- a/smoc.Tests/goldens/PlaybackQueueViewTest/SongChanged_HighlightsSong.golden +++ b/smoc.Tests/goldens/PlaybackQueueViewTest/SongChanged_HighlightsSong.golden @@ -1,8 +1,8 @@ PlaybackQueueViewTest.SongChanged_HighlightsSong_0_ansi: ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│Artist  Album  Track  Length │ +│Artist  Album  Track  Length │ │Radiohead OK Computer SongChanged_HighlightsSong1 5:00 │ -│Radiohead OK Computer SongChanged_HighlightsSong2 5:00 │ +│Radiohead OK Computer SongChanged_HighlightsSong2 5:00 │ │ │ │ │ │ │ diff --git a/smoc.Tests/goldens/SongTableTest/SetHighlightedRow_HighlightsRow.golden b/smoc.Tests/goldens/SongTableTest/SetHighlightedRow_HighlightsRow.golden index a10a413..3794f2c 100644 --- a/smoc.Tests/goldens/SongTableTest/SetHighlightedRow_HighlightsRow.golden +++ b/smoc.Tests/goldens/SongTableTest/SetHighlightedRow_HighlightsRow.golden @@ -1,8 +1,8 @@ SongTableTest.SetHighlightedRow_HighlightsRow_0_ansi: ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│#  Artist  Album  Track  Length Year │ +│#  Artist  Album  Track  Length Year │ │1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │ -│1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │ +│1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │ │1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │ │ │ │ │ diff --git a/smoc.Tests/goldens/StatusBarTest/InitialState_ShowsEmpty.golden b/smoc.Tests/goldens/StatusBarTest/InitialState_ShowsEmpty.golden index 0d8f108..f6a71cd 100644 --- a/smoc.Tests/goldens/StatusBarTest/InitialState_ShowsEmpty.golden +++ b/smoc.Tests/goldens/StatusBarTest/InitialState_ShowsEmpty.golden @@ -1,5 +1,5 @@ StatusBarTest.InitialState_ShowsEmpty_0_ansi: -   SMoC v1.0.0 +   SMoC v1.0.0  diff --git a/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs b/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs index 1f45516..5c29d47 100644 --- a/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs +++ b/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs @@ -23,6 +23,8 @@ public sealed class SoundFlowPlaybackService : IPlaybackService { private readonly Song _song; private readonly SpectrumAnalyzer? _spectrumAnalyzer; private readonly LevelMeterAnalyzer? _levelMeterAnalyzer; + private float[] _cachedSpectrumData = []; + private readonly object _spectrumLock = new(); /// public event EventHandler? SongEnded; @@ -46,12 +48,16 @@ public float[] SpectrumData { float[] raw = _spectrumAnalyzer.SpectrumData; if (raw == null || raw.Length == 0) return Array.Empty(); - float peak = _levelMeterAnalyzer?.Peak ?? 1.0f; - float[] scaled = new float[raw.Length]; - for (int i = 0; i < raw.Length; i++) { - scaled[i] = raw[i] * peak; + lock (_spectrumLock) { + if (_cachedSpectrumData.Length != raw.Length) { + _cachedSpectrumData = new float[raw.Length]; + } + float peak = _levelMeterAnalyzer?.Peak ?? 1.0f; + for (int i = 0; i < raw.Length; i++) { + _cachedSpectrumData[i] = raw[i] * peak; + } + return _cachedSpectrumData; } - return scaled; } } diff --git a/smoc/Ui/Components/FrequencyHistogramView.cs b/smoc/Ui/Components/FrequencyHistogramView.cs index 1c11c87..83bd532 100644 --- a/smoc/Ui/Components/FrequencyHistogramView.cs +++ b/smoc/Ui/Components/FrequencyHistogramView.cs @@ -91,7 +91,7 @@ protected override bool OnDrawingContent(DrawContext? context) { } } else { char blockChar = cellLevel switch { - 1 => ' ', + 1 => '\u2581', 2 => '▂', 3 => '▃', 4 => '▄', diff --git a/smoc/Ui/NowPlayingView.cs b/smoc/Ui/NowPlayingView.cs index 0a855fa..c1435d5 100644 --- a/smoc/Ui/NowPlayingView.cs +++ b/smoc/Ui/NowPlayingView.cs @@ -83,8 +83,8 @@ public NowPlayingView(IMainWindow mainWindow, CommandService commandService, IPl _histogramView = new FrequencyHistogramView(playbackQueueService) { X = Pos.Center(), Y = Pos.Center() - Pos.Percent(10), - Width = Dim.Func((v) => _albumArtView.Frame.Width), - Height = Dim.Func((v) => _albumArtView.Frame.Height), + Width = _albumArtView.Width, + Height = _albumArtView.Height, Visible = false }; From a766604fc90480de30862e96117ca175f406e5e7 Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 19:52:29 -0400 Subject: [PATCH 6/9] feat: make fps for the visual configurable --- .vscode/launch.json | 26 +++++++ .../Components/FrequencyHistogramViewTest.cs | 78 ++++++++++++++++++- smoc/Configuration/SmocConfiguration.cs | 7 ++ smoc/Ui/Components/FrequencyHistogramView.cs | 24 +++++- smoc/Ui/NowPlayingView.cs | 21 ++++- 5 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d0e3ea6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/smoc/bin/Debug/net9.0/linux-x64/smoc.dll", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs index 77b9da4..ef7db3f 100644 --- a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs +++ b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs @@ -1,12 +1,13 @@ namespace smoc.Tests.Ui.Components; +using System; using Moq; using smoc.Tests.TestInfra; +using Smoc.Configuration; using Smoc.Services; using Smoc.Ui.Components; using Terminal.Gui.Views; using Terminal.Gui.Time; -using View = Terminal.Gui.ViewBase.View; /// /// Unit tests for verifying rendering, mapping, and state changes. @@ -124,4 +125,79 @@ public void StateTransition_ToPaused_DecaysFrequencies() { _screenshotDiffer.AssertEqualsGolden(context); } + + /// + /// Verifies that when changes dynamically, + /// the timer is recreated with the correct new interval. + /// + [Fact] + public void VisualizerFps_DynamicChange_RecreatesTimerWithNewInterval() { + int originalFps = SmocConfiguration.VisualizerFps; + try { + // 1. Initialize to 10 FPS (100ms interval) + SmocConfiguration.VisualizerFps = 10; + _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing); + _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]); + + // Create a visualizer context inline + using var context = NewContext(40, 10); + (context.TimeProvider as VirtualTimeProvider)?.SetTime(DateTime.UnixEpoch.AddMilliseconds(1000.0)); + + var view = NewVisualizer(context.TimeProvider); + view.Visible = false; + view.Width = Terminal.Gui.ViewBase.Dim.Fill(); + view.Height = Terminal.Gui.ViewBase.Dim.Fill(); + context.AddAndLayout(view); + + context.Then((_) => { + view.Visible = true; + context.App!.TopRunnableView!.Layout(); + }); + + var amplitudesField = typeof(FrequencyHistogramView).GetField("_amplitudes", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(amplitudesField); + + // Initially, amplitudes are uninitialized/all zero + var initialAmplitudes = (float[]?)amplitudesField.GetValue(view); + Assert.NotNull(initialAmplitudes); + Assert.All(initialAmplitudes, amp => Assert.Equal(0f, amp)); + + // Advance by 100ms: the 10 FPS timer should trigger and procedural values should be populated + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + + var amplitudesAfter100ms = (float[]?)amplitudesField.GetValue(view); + Assert.NotNull(amplitudesAfter100ms); + Assert.Contains(amplitudesAfter100ms, amp => amp > 0f); + + // Now change the configuration FPS dynamically to 2 FPS (500ms interval) + SmocConfiguration.VisualizerFps = 2; + + // Advance by 101ms to reach the scheduled next tick (at t = 200ms) + // When this tick fires, it detects the FPS change (10 -> 2) + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); + + // Copy the amplitudes to compare later + var amplitudesAfter200ms = (float[]?)amplitudesField.GetValue(view); + Assert.NotNull(amplitudesAfter200ms); + var copyAmplitudes = (float[])amplitudesAfter200ms.Clone(); + + // Since the new 2 FPS timer has a 500ms interval starting at t = 200ms, + // advancing by another 100ms (to t = 300ms) should NOT trigger any update. + context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + + var amplitudesAfter300ms = (float[]?)amplitudesField.GetValue(view); + Assert.Equal(copyAmplitudes, amplitudesAfter300ms); + + // Advancing by another 400ms (to t = 700ms, which is 500ms after the recreation at t = 200ms) + // should trigger the new 2 FPS timer. + context.AdvanceTime(TimeSpan.FromMilliseconds(400)); + + var amplitudesAfter700ms = (float[]?)amplitudesField.GetValue(view); + // Frequencies should have moved/updated from the wave procedural generator + Assert.NotEqual(copyAmplitudes, amplitudesAfter700ms); + } finally { + // Restore configuration + SmocConfiguration.VisualizerFps = originalFps; + } + } } diff --git a/smoc/Configuration/SmocConfiguration.cs b/smoc/Configuration/SmocConfiguration.cs index 4e8211c..6c77ca6 100644 --- a/smoc/Configuration/SmocConfiguration.cs +++ b/smoc/Configuration/SmocConfiguration.cs @@ -57,4 +57,11 @@ public static class SmocConfiguration { /// [ConfigurationProperty(Scope = typeof(SettingsScope))] public static int AlbumCoverCacheMaxElements { get; set; } = 0; + + /// + /// Gets or sets the visualizer refresh rate in frames per second (FPS). + /// The default value is 10. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static int VisualizerFps { get; set; } = 10; } diff --git a/smoc/Ui/Components/FrequencyHistogramView.cs b/smoc/Ui/Components/FrequencyHistogramView.cs index 83bd532..1dede91 100644 --- a/smoc/Ui/Components/FrequencyHistogramView.cs +++ b/smoc/Ui/Components/FrequencyHistogramView.cs @@ -1,4 +1,5 @@ using Terminal.Gui.Time; +using Smoc.Configuration; namespace Smoc.Ui.Components; @@ -27,6 +28,7 @@ public sealed class FrequencyHistogramView : View { private readonly ITimeProvider _timeProvider; private float[] _amplitudes = []; private object? _timerToken; + private int _timerFps; /// /// Initializes a new instance of the class. @@ -150,12 +152,30 @@ private void UpdateTimerState() { } private void StartTimer() { - if (_timerToken != null || App == null) { + if (_timerToken is not null || App == null) { return; } - _timerToken = App.AddTimeout(TimeSpan.FromMilliseconds(100), () => { + _timerFps = Math.Clamp(SmocConfiguration.VisualizerFps, 1, 60); + double intervalMs = 1000.0 / _timerFps; + + _timerToken = App.AddTimeout(TimeSpan.FromMilliseconds(intervalMs), () => { UpdateVisualization(); + + int desiredFps = Math.Clamp(SmocConfiguration.VisualizerFps, 1, 60); + + if (_timerToken == null) { + // If something cleared the token during processing, we need to cancel this timer to avoid orphaned timers running indefinitely. + return false; + } + + if (desiredFps != _timerFps) { + // Clear the token to allow a new timer to be created with the updated FPS + _timerToken = null; + StartTimer(); + return false; + } + return true; }); } diff --git a/smoc/Ui/NowPlayingView.cs b/smoc/Ui/NowPlayingView.cs index c1435d5..014859f 100644 --- a/smoc/Ui/NowPlayingView.cs +++ b/smoc/Ui/NowPlayingView.cs @@ -1,9 +1,8 @@ namespace Smoc.Ui; using System; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; using Smoc.Services; +using Smoc.Configuration; using Smoc.Streaming; using Smoc.Ui.Components; using Smoc.Ui.Models; @@ -87,6 +86,7 @@ public NowPlayingView(IMainWindow mainWindow, CommandService commandService, IPl Height = _albumArtView.Height, Visible = false }; + _histogramView.Margin!.Thickness = new Thickness(0, 0, 0, 1); _songLabel = new Label() { X = Pos.Center(), @@ -132,6 +132,7 @@ public NowPlayingView(IMainWindow mainWindow, CommandService commandService, IPl _commandService.RegisterCommand("np", OnNowPlayingCommand); _commandService.RegisterCommand("np-vis", OnToggleVisualizationCommand); + _commandService.RegisterCommand("np-vis-fps", OnSetFpsCommand); AddCommand(Command.HotKey, OnHotKey); HotKeyBindings.Add(Key.V, Command.HotKey); @@ -183,6 +184,7 @@ private async void OnSongChanged(object? sender, Song? song) { protected override void Dispose(bool disposing) { _commandService.UnregisterCommand("np"); _commandService.UnregisterCommand("np-vis"); + _commandService.UnregisterCommand("np-vis-fps"); _playbackQueueService.SongChanged -= OnSongChanged; _playbackQueueService.PositionChanged -= OnPositionChanged; base.Dispose(disposing); @@ -218,6 +220,21 @@ private void OnToggleVisualizationCommand(string _, string __) { ToggleVisualization(); } + private void OnSetFpsCommand(string command, string args) { + var splitArgs = CommandService.GetArgs(args); + if (splitArgs.Length == 0) { + return; + } + + if (!int.TryParse(splitArgs[0], out int fps) || fps < 1 || fps > 60) { + Logging.Warning($"Invalid FPS: {splitArgs[0]}"); + _mainWindow.DisplayError($"invalid FPS: {splitArgs[0]} ([1-60] expected)"); + return; + } + + SmocConfiguration.VisualizerFps = fps; + } + private bool? OnHotKey(ICommandContext? ctx) { if (ctx?.Binding is KeyBinding keyBinding && keyBinding.Key is Key pressedKey) { if (pressedKey == Key.V || pressedKey == Key.V.WithShift) { From 00e4a074824e44483c136e33801faaa327af4ab3 Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 20:08:17 -0400 Subject: [PATCH 7/9] fix: offset time advances slightly to attempt to remove non-determinism --- .../Components/FrequencyHistogramViewTest.cs | 22 +++++++++---------- ...alFallback_RendersDeterministically.golden | 10 ++++----- ...ithRealSpectrum_MapsLogarithmically.golden | 8 +++---- ...ansition_ToPaused_DecaysFrequencies.golden | 6 ++--- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs index ef7db3f..f3a6753 100644 --- a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs +++ b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs @@ -29,7 +29,7 @@ public FrequencyHistogramViewTest(ITestOutputHelper output) { private FrequencyHistogramView NewVisualizer(ITimeProvider timeProvider) => new FrequencyHistogramView(_mockPlaybackQueue.Object, timeProvider); - private AppTestHelper NewVisualizerContext(int width = 40, int height = 10, double? startEpochOffsetMs = null) { + private AppTestHelper NewVisualizerContext(int width = 40, int height = 10, int? startEpochOffsetMs = null) { var context = NewContext(width, height); if (startEpochOffsetMs.HasValue) { (context.TimeProvider as VirtualTimeProvider)?.SetTime(DateTime.UnixEpoch.AddMilliseconds(startEpochOffsetMs.Value)); @@ -69,11 +69,11 @@ public void PlayingState_ProceduralFallback_RendersDeterministically() { _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]); // Use a fixed simulated time point for frozen deterministic screenshot - using var context = NewVisualizerContext(startEpochOffsetMs: 3500.0); + using var context = NewVisualizerContext(startEpochOffsetMs: 3500); // Trigger two update ticks to transition attack/decay interpolation towards target values - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); _screenshotDiffer.AssertEqualsGolden(context); } @@ -95,8 +95,8 @@ public void PlayingState_WithRealSpectrum_MapsLogarithmically() { using var context = NewVisualizerContext(); - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); _screenshotDiffer.AssertEqualsGolden(context); } @@ -110,18 +110,18 @@ public void StateTransition_ToPaused_DecaysFrequencies() { _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing); _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]); - using var context = NewVisualizerContext(startEpochOffsetMs: 2000.0); + using var context = NewVisualizerContext(startEpochOffsetMs: 2000); // Pump initial values - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); // 2. Pause playback _mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Paused); // 3. Pump updates to run the decay logic - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); _screenshotDiffer.AssertEqualsGolden(context); } diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden index 0f8cd54..602587f 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_ProceduralFallback_RendersDeterministically.golden @@ -3,10 +3,10 @@ FrequencyHistogramViewTest.PlayingState_ProceduralFallback_RendersDeterministica - - ▂ - ▅ ▁ ▄ ▇ █ + ▁ ▅ + █ █ █ █ █ █ █ ▄ █ █ -▁ █ █ ▂ █ █ █ █ ▃ ▅ █ ▃ ▇ -█ █ █ █ █ █ █ █ ▇ █ ▆ █ █ █ █ █ ▆ ▇ ▃ █ + █ █ ▄ █ █ █ ▁ ▃ ▁ ▅ █ ▂ ▃ +▅ █ ▃ █ █ █ █ █ █ █ ▇ █ █ █ ▅ ▆ █ ▃ █ +█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ▆ █ diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden index 31a7cb8..b387d4f 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden @@ -1,12 +1,12 @@ FrequencyHistogramViewTest.PlayingState_WithRealSpectrum_MapsLogarithmically_0: - - █ █ █ █ █ █ █ █ █ █ █ █ -▇ █ █ █ █ + █ █ █ █ +█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ -█ █ █ █ █ ▇ +█ █ █ █ █ ▁ +█ █ █ █ █ █ diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden index 977c859..785a15c 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden @@ -5,8 +5,8 @@ FrequencyHistogramViewTest.StateTransition_ToPaused_DecaysFrequencies_0: - ▇ ▁ - █ ▂ ▅ ▄ █ ▄ █ ▅ ▆ ▁ -▃ ▅ █ █ █ █ █ █ ▅ █ ▅ █ █ ▃ █ ▄ ▄ █ ▆ ▁ + █ ▁ █ ▆ ▃ ▁ + █ █ █ █ █ ▆ ▁ █ ▁ █ ▁ ▂ █ ▁ ▂ ▃ +▆ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ▇ █ █ ▂ From 949af48676e395174133b0d05d651fde9dacb549 Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 22:25:29 -0400 Subject: [PATCH 8/9] fix: Improve visuals --- .../Components/FrequencyHistogramViewTest.cs | 9 +- ...ithRealSpectrum_MapsLogarithmically.golden | 18 ++-- smoc/Configuration/SmocConfiguration.cs | 4 +- .../SoundFlow/SoundFlowPlaybackService.cs | 29 ++---- smoc/Ui/Components/FrequencyHistogramView.cs | 92 +++++++++---------- 5 files changed, 64 insertions(+), 88 deletions(-) diff --git a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs index f3a6753..5fba362 100644 --- a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs +++ b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs @@ -87,15 +87,14 @@ public void PlayingState_WithRealSpectrum_MapsLogarithmically() { // Simulate 32 frequency bins from SoundFlow with distinct bass (strong) and treble peaks float[] mockFrequencies = new float[32]; - mockFrequencies[1] = 0.8f; // Bass peak - mockFrequencies[2] = 0.9f; - mockFrequencies[10] = 0.5f; // Mid peak - mockFrequencies[28] = 0.3f; // Treble peak + mockFrequencies[1] = 225; // Bass peak + mockFrequencies[2] = 225; // Bass peak + mockFrequencies[10] = 64; // Mid peak + mockFrequencies[28] = 10; // Treble peak _mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns(mockFrequencies); using var context = NewVisualizerContext(); - context.AdvanceTime(TimeSpan.FromMilliseconds(101)); context.AdvanceTime(TimeSpan.FromMilliseconds(101)); _screenshotDiffer.AssertEqualsGolden(context); diff --git a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden index b387d4f..4e2c3e4 100644 --- a/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden +++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/PlayingState_WithRealSpectrum_MapsLogarithmically.golden @@ -1,12 +1,12 @@ FrequencyHistogramViewTest.PlayingState_WithRealSpectrum_MapsLogarithmically_0: - █ █ █ █ - █ █ █ █ - █ █ █ █ - █ █ █ █ -█ █ █ █ █ -█ █ █ █ █ -█ █ █ █ █ -█ █ █ █ █ ▁ -█ █ █ █ █ █ + ▆ ▆ + █ █ + █ █ ▆ + █ █ █ + █ █ █ + █ █ █ ▇ + █ █ █ █ + █ █ █ █ + █ █ █ █ diff --git a/smoc/Configuration/SmocConfiguration.cs b/smoc/Configuration/SmocConfiguration.cs index 6c77ca6..1e1abd0 100644 --- a/smoc/Configuration/SmocConfiguration.cs +++ b/smoc/Configuration/SmocConfiguration.cs @@ -60,8 +60,8 @@ public static class SmocConfiguration { /// /// Gets or sets the visualizer refresh rate in frames per second (FPS). - /// The default value is 10. + /// The default value is 24. /// [ConfigurationProperty(Scope = typeof(SettingsScope))] - public static int VisualizerFps { get; set; } = 10; + public static int VisualizerFps { get; set; } = 24; } diff --git a/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs b/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs index 5c29d47..e44a409 100644 --- a/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs +++ b/smoc/Services/Audio/SoundFlow/SoundFlowPlaybackService.cs @@ -22,7 +22,6 @@ public sealed class SoundFlowPlaybackService : IPlaybackService { private readonly SoundPlayer _soundPlayer; private readonly Song _song; private readonly SpectrumAnalyzer? _spectrumAnalyzer; - private readonly LevelMeterAnalyzer? _levelMeterAnalyzer; private float[] _cachedSpectrumData = []; private readonly object _spectrumLock = new(); @@ -44,17 +43,16 @@ public sealed class SoundFlowPlaybackService : IPlaybackService { /// public float[] SpectrumData { get { - if (_spectrumAnalyzer == null) return Array.Empty(); + if (_spectrumAnalyzer == null) return []; float[] raw = _spectrumAnalyzer.SpectrumData; - if (raw == null || raw.Length == 0) return Array.Empty(); + if (raw == null || raw.Length == 0) return []; lock (_spectrumLock) { if (_cachedSpectrumData.Length != raw.Length) { _cachedSpectrumData = new float[raw.Length]; } - float peak = _levelMeterAnalyzer?.Peak ?? 1.0f; for (int i = 0; i < raw.Length; i++) { - _cachedSpectrumData[i] = raw[i] * peak; + _cachedSpectrumData[i] = raw[i] / _soundPlayer.Volume; } return _cachedSpectrumData; } @@ -63,14 +61,9 @@ public float[] SpectrumData { /// public bool IsSpectrumActive { - get => (_spectrumAnalyzer?.Enabled ?? false) && (_levelMeterAnalyzer?.Enabled ?? false); + get => _spectrumAnalyzer?.Enabled ?? false; set { - if (_spectrumAnalyzer != null) { - _spectrumAnalyzer.Enabled = value; - } - if (_levelMeterAnalyzer != null) { - _levelMeterAnalyzer.Enabled = value; - } + _spectrumAnalyzer?.Enabled = value; } } @@ -107,13 +100,10 @@ public SoundFlowPlaybackService(MiniAudioEngine audioEngine, AudioPlaybackDevice _playbackDevice.MasterMixer.AddComponent(_soundPlayer); try { - _spectrumAnalyzer = new SpectrumAnalyzer(audioFormat, 1024, null); - _spectrumAnalyzer.Enabled = false; + _spectrumAnalyzer = new SpectrumAnalyzer(audioFormat, fftSize: 1024) { + Enabled = false + }; _soundPlayer.AddAnalyzer(_spectrumAnalyzer); - - _levelMeterAnalyzer = new LevelMeterAnalyzer(audioFormat, null); - _levelMeterAnalyzer.Enabled = false; - _soundPlayer.AddAnalyzer(_levelMeterAnalyzer); } catch (Exception ex) { Logging.Warning($"Failed to initialize spectrum analyzer: {ex.Message}"); } @@ -159,9 +149,6 @@ public void Dispose() { if (_spectrumAnalyzer != null) { _soundPlayer.RemoveAnalyzer(_spectrumAnalyzer); } - if (_levelMeterAnalyzer != null) { - _soundPlayer.RemoveAnalyzer(_levelMeterAnalyzer); - } _playbackDevice.MasterMixer.RemoveComponent(_soundPlayer); _soundPlayer.Dispose(); _streamDataProvider.Dispose(); diff --git a/smoc/Ui/Components/FrequencyHistogramView.cs b/smoc/Ui/Components/FrequencyHistogramView.cs index 1dede91..def89a8 100644 --- a/smoc/Ui/Components/FrequencyHistogramView.cs +++ b/smoc/Ui/Components/FrequencyHistogramView.cs @@ -69,17 +69,17 @@ protected override bool OnDrawingContent(DrawContext? context) { int height = contentArea.Height; var currentAttr = GetCurrentAttribute(); - for (int i = 0; i < totalBars; i++) { - int col = i * 2; - float amp = _amplitudes[i]; + for (int currBar = 0; currBar < totalBars; currBar++) { + int col = currBar * 2; + float amp = _amplitudes[currBar]; float totalLevels = amp * height * 8; - for (int r = 0; r < height; r++) { - int cellLevel = (int)totalLevels - (r * 8); - int row = height - 1 - r; // Draw from bottom up + for (int rowIndex = 0; rowIndex < height; rowIndex++) { + int cellLevel = (int)totalLevels - (rowIndex * 8); + int row = height - 1 - rowIndex; // Draw from bottom up // Select the color for this vertical level segment - int colorIndex = (int)Math.Clamp((double)r / Math.Max(1, height) * _gradientColors.Length, 0, _gradientColors.Length - 1); + int colorIndex = (int)Math.Clamp((double)rowIndex / Math.Max(1, height) * _gradientColors.Length, 0, _gradientColors.Length - 1); var barColor = _gradientColors[colorIndex]; SetAttribute(new Attribute(barColor, currentAttr.Background)); @@ -100,7 +100,7 @@ protected override bool OnDrawingContent(DrawContext? context) { 5 => '▅', 6 => '▆', 7 => '▇', - _ => ' ' + _ => 'X' }; if (Move(col, row)) { AddRune(blockChar); @@ -210,74 +210,64 @@ private void UpdateVisualization() { // Use atomic / thread-safe local reference for the spectrum data if (isPlaying && spectrum != null && spectrum.Length > 0) { - int minBin = Math.Min(1, spectrum.Length - 1); - // Discard DC offset (bin 0) and map up to 20 kHz (approx 90.7% of Nyquist at 44.1 kHz sample rate) - int maxBin = Math.Clamp((int)(spectrum.Length * 0.907f), minBin + 1, spectrum.Length); - // Group spectrum bins logarithmically into columns - for (int i = 0; i < totalBars; i++) { - double lowFrac = Math.Pow((double)i / totalBars, 1.5); - double highFrac = Math.Pow((double)(i + 1) / totalBars, 1.5); - - int startBin = minBin + (int)(lowFrac * (maxBin - minBin)); - int endBin = minBin + (int)(highFrac * (maxBin - minBin)); - if (endBin <= startBin) { - endBin = startBin + 1; + for (int currBar = 0; currBar < totalBars; currBar++) { + // Invert the logarithmic mapping so wider spectrum ranges occur at higher frequencies + double startFrac = (double)currBar / totalBars; + double endFrac = (double)(currBar + 1) / totalBars; + double startLogIndex = 1.0 - Math.Log10(1 + 9 * (1.0 - startFrac)); + double endLogIndex = 1.0 - Math.Log10(1 + 9 * (1.0 - endFrac)); + + int startSpectrumIndex = (int)Math.Round(startLogIndex * (spectrum.Length - 1)); + int endSpectrumIndex = (int)Math.Round(endLogIndex * (spectrum.Length - 1)) - 1; + + if (startSpectrumIndex == endSpectrumIndex) { + endSpectrumIndex = startSpectrumIndex + 1; } - float sum = 0f; - int count = 0; - for (int bin = startBin; bin < endBin && bin < spectrum.Length; bin++) { - sum += spectrum[bin]; - count++; + double magnitude = 0; + for (int spectrumIndex = startSpectrumIndex; spectrumIndex < endSpectrumIndex; spectrumIndex++) { + magnitude = Math.Max(magnitude, spectrum[spectrumIndex]); } - float amp = count > 0 ? sum / count : 0f; - - // Apply a psychoacoustic weighting filter (sine window) to roll off subsonic DC offset on the left and ultrasonic noise on the right - double fraction = (double)i / totalBars; - double weight = Math.Sin((0.05 + 0.90 * fraction) * Math.PI); - - // Scale with a safer 4.5f coefficient to completely eliminate excessive pegging - float target = amp * 4.5f * (float)weight; - target = Math.Clamp(target, 0.0f, 1.0f); + magnitude = Math.Log(magnitude + 1) / Math.Log(256.0); // Attack & decay smoothing - if (target > _amplitudes[i]) { - _amplitudes[i] += (target - _amplitudes[i]) * 0.7f; // Quick attack + if (magnitude > _amplitudes[currBar]) { + _amplitudes[currBar] += (float)(magnitude - _amplitudes[currBar]) * 0.9f; // Quick attack } else { - _amplitudes[i] += (target - _amplitudes[i]) * 0.25f; // Gradual decay + _amplitudes[currBar] += (float)(magnitude - _amplitudes[currBar]) * 0.4f; // Gradual decay } } } else if (isPlaying) { // Procedural fallback animation (e.g. for unit tests or streams without FFT) - double t = _timeProvider.Now.ToUniversalTime().Subtract(DateTime.UnixEpoch).TotalMilliseconds; - for (int i = 0; i < totalBars; i++) { - double tSeconds = t / 1000.0; - double fraction = (double)i / totalBars; + double time = _timeProvider.Now.ToUniversalTime().Subtract(DateTime.UnixEpoch).TotalMilliseconds; + for (int currBar = 0; currBar < totalBars; currBar++) { + double tSeconds = time / 1000.0; + double fraction = (double)currBar / totalBars; double speed = 3.0 + fraction * 10.0; - double wave1 = Math.Sin(tSeconds * speed + i * 0.8); - double wave2 = Math.Cos(tSeconds * (speed * 0.5) - i * 0.4); - double wave3 = Math.Sin(tSeconds * (speed * 0.2) + i * 1.5); + double wave1 = Math.Sin(tSeconds * speed + currBar * 0.8); + double wave2 = Math.Cos(tSeconds * (speed * 0.5) - currBar * 0.4); + double wave3 = Math.Sin(tSeconds * (speed * 0.2) + currBar * 1.5); double blended = 0.5 * wave1 + 0.3 * wave2 + 0.2 * wave3; double freqBias = Math.Pow(1.0 - fraction, 0.3); float target = (float)((0.1 + 0.9 * Math.Abs(blended)) * freqBias); target = Math.Clamp(target, 0.0f, 1.0f); - if (target > _amplitudes[i]) { - _amplitudes[i] += (target - _amplitudes[i]) * 0.6f; + if (target > _amplitudes[currBar]) { + _amplitudes[currBar] += (target - _amplitudes[currBar]) * 0.6f; } else { - _amplitudes[i] += (target - _amplitudes[i]) * 0.3f; + _amplitudes[currBar] += (target - _amplitudes[currBar]) * 0.3f; } } } else { // Natural visual decay fallback to 0 when paused or stopped bool stillDecaying = false; - for (int i = 0; i < totalBars; i++) { - _amplitudes[i] *= 0.75f; - if (_amplitudes[i] < 0.01f) { - _amplitudes[i] = 0f; + for (int currBar = 0; currBar < totalBars; currBar++) { + _amplitudes[currBar] *= 0.75f; + if (_amplitudes[currBar] < 0.01f) { + _amplitudes[currBar] = 0f; } else { stillDecaying = true; } From 09c2476c90668e0efd573366338460a6a8e35bec Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Mon, 25 May 2026 22:29:11 -0400 Subject: [PATCH 9/9] refactor: use auto property --- smoc/Services/StandardPlaybackQueueService.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/smoc/Services/StandardPlaybackQueueService.cs b/smoc/Services/StandardPlaybackQueueService.cs index c4a5171..5a1d50a 100644 --- a/smoc/Services/StandardPlaybackQueueService.cs +++ b/smoc/Services/StandardPlaybackQueueService.cs @@ -53,18 +53,16 @@ public sealed class StandardPlaybackQueueService : IPlaybackQueueService { /// public float[] SpectrumData => _playbackService.Resource?.SpectrumData ?? Array.Empty(); - private bool _isSpectrumActive = false; - /// public bool IsSpectrumActive { - get => _isSpectrumActive; + get; set { - _isSpectrumActive = value; + field = value; if (_playbackService.Resource != null) { _playbackService.Resource.IsSpectrumActive = value; } } - } + } = false; /// public IEnumerable GetCurrentPlaybackQueue() => _playbackQueue.ToList(); @@ -239,7 +237,7 @@ public async Task Play() { } _playbackService.Replace(playback); - playback.IsSpectrumActive = _isSpectrumActive; + playback.IsSpectrumActive = IsSpectrumActive; playback.SongEnded += OnSongEnded; playback.PositionChanged += OnPositionChanged; playback.PlaybackStateChanged += OnPlaybackStateChanged;