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; + } }