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:
[39m[49m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│[38;2;0;0;0m[48;2;255;255;255mArtist [39m[49m [38;2;0;0;0m[48;2;255;255;255mAlbum [39m[49m [38;2;0;0;0m[48;2;255;255;255mTrack [39m[49m [38;2;0;0;0m[48;2;255;255;255mLength [39m[49m│
+│[30m[107mArtist [39m[49m [30m[107mAlbum [39m[49m [30m[107mTrack [39m[49m [30m[107mLength [39m[49m│
│Radiohead OK Computer BecomesVisible_SelectsCurren… 5:00 │
-│[38;2;0;0;0m[48;2;255;255;255mRadiohead OK Computer BecomesVisible_SelectsCurren… 5:00 [39m[49m│
+│[30m[107mRadiohead OK Computer BecomesVisible_SelectsCurren… 5:00 [39m[49m│
│ │
│ │
│ │
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:
[39m[49m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│[38;2;0;0;0m[48;2;255;255;255mArtist [39m[49m [38;2;0;0;0m[48;2;255;255;255mAlbum [39m[49m [38;2;0;0;0m[48;2;255;255;255mTrack [39m[49m [38;2;0;0;0m[48;2;255;255;255mLength [39m[49m│
+│[30m[107mArtist [39m[49m [30m[107mAlbum [39m[49m [30m[107mTrack [39m[49m [30m[107mLength [39m[49m│
│Radiohead OK Computer SongChanged_HighlightsSong1 5:00 │
-│[38;2;0;0;0m[48;2;255;255;255m[1mRadiohead OK Computer SongChanged_HighlightsSong2 5:00 [39m[49m[22m│
+│[30m[107m[1mRadiohead OK Computer SongChanged_HighlightsSong2 5:00 [39m[49m[22m│
│ │
│ │
│ │
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:
[39m[49m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│[38;2;0;0;0m[48;2;255;255;255m# [39m[49m [38;2;0;0;0m[48;2;255;255;255mArtist [39m[49m [38;2;0;0;0m[48;2;255;255;255mAlbum [39m[49m [38;2;0;0;0m[48;2;255;255;255mTrack [39m[49m [38;2;0;0;0m[48;2;255;255;255mLength[39m[49m [38;2;0;0;0m[48;2;255;255;255mYear [39m[49m│
+│[30m[107m# [39m[49m [30m[107mArtist [39m[49m [30m[107mAlbum [39m[49m [30m[107mTrack [39m[49m [30m[107mLength[39m[49m [30m[107mYear [39m[49m│
│1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │
-│[38;2;255;255;255m[48;2;0;0;0m[1m1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 [39m[49m[22m│
+│[97m[40m[1m1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 [39m[49m[22m│
│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:
-[38;2;38;38;38m[48;2;148;148;148m[1m [38;2;148;148;148m[48;2;58;58;58m[22m [38;2;38;38;38m[48;2;148;148;148m[1m SMoC v1.0.0
+[30m[47m[1m [37m[40m[22m [30m[47m[1m SMoC v1.0.0
[39m[49m[22m
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:
[39m[49m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│[30m[107mArtist [39m[49m [30m[107mAlbum [39m[49m [30m[107mTrack [39m[49m [30m[107mLength [39m[49m│
+│[38;2;0;0;0m[48;2;255;255;255mArtist [39m[49m [38;2;0;0;0m[48;2;255;255;255mAlbum [39m[49m [38;2;0;0;0m[48;2;255;255;255mTrack [39m[49m [38;2;0;0;0m[48;2;255;255;255mLength [39m[49m│
│Radiohead OK Computer BecomesVisible_SelectsCurren… 5:00 │
-│[30m[107mRadiohead OK Computer BecomesVisible_SelectsCurren… 5:00 [39m[49m│
+│[38;2;0;0;0m[48;2;255;255;255mRadiohead OK Computer BecomesVisible_SelectsCurren… 5:00 [39m[49m│
│ │
│ │
│ │
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:
[39m[49m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│[30m[107mArtist [39m[49m [30m[107mAlbum [39m[49m [30m[107mTrack [39m[49m [30m[107mLength [39m[49m│
+│[38;2;0;0;0m[48;2;255;255;255mArtist [39m[49m [38;2;0;0;0m[48;2;255;255;255mAlbum [39m[49m [38;2;0;0;0m[48;2;255;255;255mTrack [39m[49m [38;2;0;0;0m[48;2;255;255;255mLength [39m[49m│
│Radiohead OK Computer SongChanged_HighlightsSong1 5:00 │
-│[30m[107m[1mRadiohead OK Computer SongChanged_HighlightsSong2 5:00 [39m[49m[22m│
+│[38;2;0;0;0m[48;2;255;255;255m[1mRadiohead OK Computer SongChanged_HighlightsSong2 5:00 [39m[49m[22m│
│ │
│ │
│ │
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:
[39m[49m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│[30m[107m# [39m[49m [30m[107mArtist [39m[49m [30m[107mAlbum [39m[49m [30m[107mTrack [39m[49m [30m[107mLength[39m[49m [30m[107mYear [39m[49m│
+│[38;2;0;0;0m[48;2;255;255;255m# [39m[49m [38;2;0;0;0m[48;2;255;255;255mArtist [39m[49m [38;2;0;0;0m[48;2;255;255;255mAlbum [39m[49m [38;2;0;0;0m[48;2;255;255;255mTrack [39m[49m [38;2;0;0;0m[48;2;255;255;255mLength[39m[49m [38;2;0;0;0m[48;2;255;255;255mYear [39m[49m│
│1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 │
-│[97m[40m[1m1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 [39m[49m[22m│
+│[38;2;255;255;255m[48;2;0;0;0m[1m1 Radiohead OK Computer SetHighlightedRow_Highli… 5:00 1970 [39m[49m[22m│
│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:
-[30m[47m[1m [37m[40m[22m [30m[47m[1m SMoC v1.0.0
+[38;2;38;38;38m[48;2;148;148;148m[1m [38;2;148;148;148m[48;2;58;58;58m[22m [38;2;38;38;38m[48;2;148;148;148m[1m SMoC v1.0.0
[39m[49m[22m
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;