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/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/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.Tests/Ui/Components/FrequencyHistogramViewTest.cs b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs
new file mode 100644
index 0000000..5fba362
--- /dev/null
+++ b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs
@@ -0,0 +1,202 @@
+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;
+
+///
+/// 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(ITimeProvider timeProvider) => new FrequencyHistogramView(_mockPlaybackQueue.Object, timeProvider);
+
+ 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));
+ }
+ 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();
+ context.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;
+ }
+
+ ///
+ /// 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(startEpochOffsetMs: 3500);
+
+ // Trigger two update ticks to transition attack/decay interpolation towards target values
+ context.AdvanceTime(TimeSpan.FromMilliseconds(101));
+ context.AdvanceTime(TimeSpan.FromMilliseconds(101));
+
+ _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] = 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));
+
+ _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(startEpochOffsetMs: 2000);
+
+ // Pump initial values
+ 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(101));
+ context.AdvanceTime(TimeSpan.FromMilliseconds(101));
+
+ _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.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..602587f
--- /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..4e2c3e4
--- /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..785a15c
--- /dev/null
+++ b/smoc.Tests/goldens/FrequencyHistogramViewTest/StateTransition_ToPaused_DecaysFrequencies.golden
@@ -0,0 +1,12 @@
+FrequencyHistogramViewTest.StateTransition_ToPaused_DecaysFrequencies_0:
+
+
+
+
+
+
+ ▇ ▁
+ █ ▁ █ ▆ ▃ ▁
+ █ █ █ █ █ ▆ ▁ █ ▁ █ ▁ ▂ █ ▁ ▂ ▃
+▆ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ▇ █ █ ▂
+
diff --git a/smoc/Configuration/SmocConfiguration.cs b/smoc/Configuration/SmocConfiguration.cs
index 4e8211c..1e1abd0 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 24.
+ ///
+ [ConfigurationProperty(Scope = typeof(SettingsScope))]
+ public static int VisualizerFps { get; set; } = 24;
}
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..e44a409 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,9 @@ public sealed class SoundFlowPlaybackService : IPlaybackService {
private readonly AssetDataProvider _streamDataProvider;
private readonly SoundPlayer _soundPlayer;
private readonly Song _song;
+ private readonly SpectrumAnalyzer? _spectrumAnalyzer;
+ private float[] _cachedSpectrumData = [];
+ private readonly object _spectrumLock = new();
///
public event EventHandler? SongEnded;
@@ -36,6 +40,33 @@ public sealed class SoundFlowPlaybackService : IPlaybackService {
///
public TimeSpan CurrentTime => TimeSpan.FromSeconds(_soundPlayer.Time);
+ ///
+ public float[] SpectrumData {
+ get {
+ if (_spectrumAnalyzer == null) return [];
+ float[] raw = _spectrumAnalyzer.SpectrumData;
+ if (raw == null || raw.Length == 0) return [];
+
+ lock (_spectrumLock) {
+ if (_cachedSpectrumData.Length != raw.Length) {
+ _cachedSpectrumData = new float[raw.Length];
+ }
+ for (int i = 0; i < raw.Length; i++) {
+ _cachedSpectrumData[i] = raw[i] / _soundPlayer.Volume;
+ }
+ return _cachedSpectrumData;
+ }
+ }
+ }
+
+ ///
+ public bool IsSpectrumActive {
+ get => _spectrumAnalyzer?.Enabled ?? false;
+ set {
+ _spectrumAnalyzer?.Enabled = value;
+ }
+ }
+
///
public float Progress => _soundPlayer.Time / _soundPlayer.Duration;
@@ -67,6 +98,16 @@ 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, fftSize: 1024) {
+ Enabled = false
+ };
+ _soundPlayer.AddAnalyzer(_spectrumAnalyzer);
+ } 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 +146,9 @@ public void Seek(TimeSpan position) {
///
public void Dispose() {
+ if (_spectrumAnalyzer != null) {
+ _soundPlayer.RemoveAnalyzer(_spectrumAnalyzer);
+ }
_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..5a1d50a 100644
--- a/smoc/Services/StandardPlaybackQueueService.cs
+++ b/smoc/Services/StandardPlaybackQueueService.cs
@@ -50,6 +50,20 @@ public sealed class StandardPlaybackQueueService : IPlaybackQueueService {
///
public float Progress => _playbackService.Resource?.Progress ?? 0;
+ ///
+ public float[] SpectrumData => _playbackService.Resource?.SpectrumData ?? Array.Empty();
+
+ ///
+ public bool IsSpectrumActive {
+ get;
+ set {
+ field = value;
+ if (_playbackService.Resource != null) {
+ _playbackService.Resource.IsSpectrumActive = value;
+ }
+ }
+ } = false;
+
///
public IEnumerable GetCurrentPlaybackQueue() => _playbackQueue.ToList();
@@ -223,6 +237,7 @@ public async Task Play() {
}
_playbackService.Replace(playback);
+ playback.IsSpectrumActive = IsSpectrumActive;
playback.SongEnded += OnSongEnded;
playback.PositionChanged += OnPositionChanged;
playback.PlaybackStateChanged += OnPlaybackStateChanged;
diff --git a/smoc/Ui/Components/FrequencyHistogramView.cs b/smoc/Ui/Components/FrequencyHistogramView.cs
new file mode 100644
index 0000000..def89a8
--- /dev/null
+++ b/smoc/Ui/Components/FrequencyHistogramView.cs
@@ -0,0 +1,283 @@
+using Terminal.Gui.Time;
+using Smoc.Configuration;
+
+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 ITimeProvider _timeProvider;
+ private float[] _amplitudes = [];
+ private object? _timerToken;
+ private int _timerFps;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The playback queue service for retrieving spectrum data.
+ /// An optional custom time provider for deterministic testing.
+ public FrequencyHistogramView(IPlaybackQueueService playbackQueueService, ITimeProvider? timeProvider = null) {
+ _playbackQueueService = playbackQueueService;
+ _timeProvider = timeProvider ?? new SystemTimeProvider();
+ 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 currBar = 0; currBar < totalBars; currBar++) {
+ int col = currBar * 2;
+ float amp = _amplitudes[currBar];
+ float totalLevels = amp * height * 8;
+
+ 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)rowIndex / 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 => '\u2581',
+ 2 => '▂',
+ 3 => '▃',
+ 4 => '▄',
+ 5 => '▅',
+ 6 => '▆',
+ 7 => '▇',
+ _ => 'X'
+ };
+ 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 is not null || App == null) {
+ return;
+ }
+
+ _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;
+ });
+ }
+
+ 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 && Viewport.Width > 0) {
+ totalBars = (Viewport.Width + 1) / 2;
+ Array.Resize(ref _amplitudes, totalBars);
+ }
+
+ 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) {
+ // Group spectrum bins logarithmically into columns
+ 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;
+ }
+
+ double magnitude = 0;
+ for (int spectrumIndex = startSpectrumIndex; spectrumIndex < endSpectrumIndex; spectrumIndex++) {
+ magnitude = Math.Max(magnitude, spectrum[spectrumIndex]);
+ }
+ magnitude = Math.Log(magnitude + 1) / Math.Log(256.0);
+
+ // Attack & decay smoothing
+ if (magnitude > _amplitudes[currBar]) {
+ _amplitudes[currBar] += (float)(magnitude - _amplitudes[currBar]) * 0.9f; // Quick attack
+ } else {
+ _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 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 + 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[currBar]) {
+ _amplitudes[currBar] += (target - _amplitudes[currBar]) * 0.6f;
+ } else {
+ _amplitudes[currBar] += (target - _amplitudes[currBar]) * 0.3f;
+ }
+ }
+ } else {
+ // Natural visual decay fallback to 0 when paused or stopped
+ bool stillDecaying = false;
+ for (int currBar = 0; currBar < totalBars; currBar++) {
+ _amplitudes[currBar] *= 0.75f;
+ if (_amplitudes[currBar] < 0.01f) {
+ _amplitudes[currBar] = 0f;
+ } else {
+ stillDecaying = true;
+ }
+ }
+
+ if (!stillDecaying) {
+ StopTimer();
+ }
+ }
+
+ SetNeedsDraw();
+ }
+}
diff --git a/smoc/Ui/NowPlayingView.cs b/smoc/Ui/NowPlayingView.cs
index 3e5589b..014859f 100644
--- a/smoc/Ui/NowPlayingView.cs
+++ b/smoc/Ui/NowPlayingView.cs
@@ -1,14 +1,14 @@
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;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
+using Terminal.Gui.Input;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
@@ -29,6 +29,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 +38,7 @@ private static class Messages {
private readonly Label _durationLabel;
private Album? _currentAlbum;
private CancellationTokenSource? _albumArtCancellationTokenSource;
+ private bool _showVisualization = false;
///
@@ -77,6 +79,15 @@ 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 = _albumArtView.Width,
+ Height = _albumArtView.Height,
+ Visible = false
+ };
+ _histogramView.Margin!.Thickness = new Thickness(0, 0, 0, 1);
+
_songLabel = new Label() {
X = Pos.Center(),
Y = Pos.Bottom(_albumArtView),
@@ -114,12 +125,18 @@ 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);
+ _commandService.RegisterCommand("np-vis-fps", OnSetFpsCommand);
+
+ 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 +183,8 @@ 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);
@@ -176,6 +195,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 +207,41 @@ 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 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) {
+ ToggleVisualization();
+ return true;
+ }
+ }
+ return null;
+ }
}