From 950e5f1dcc7cecf76f7f80a4f7bc7333282010e7 Mon Sep 17 00:00:00 2001 From: Alexander Shabarshov Date: Sun, 5 Jul 2026 15:07:42 +0100 Subject: [PATCH] Refactor playback speed & mixer management; UI tweaks Refactor playback speed and mixer settings to be managed via PlaybackSession, removing SetSpeed/SetStem* methods. Update Play command to apply current mixer and speed. Optimize RenderLoop with reusable audio block list and conditional output start. Align playback control icons in XAML. Update tests for new speed handling and add speed decrease coverage. Remove unused IStemPlaybackEngine methods. --- ABStemPlayer/ViewModels/PlaybackViewModel.cs | 52 ++-- ABStemPlayer/Views/PlaybackControls.axaml | 102 ++++++-- AudioCore/Impl/RubberBandTimeStretchEngine.cs | 22 +- AudioCore/Impl/StemPlaybackEngine.cs | 222 +++++++----------- AudioCore/Impl/WasapiOutputDevice.cs | 1 + AudioCore/Interfaces/IStemPlaybackEngine.cs | 8 - AudioCore/Models/TimeStretchedAudioBlock.cs | 12 +- AudioCore_Tests/StemPlaybackEngine_Tests.cs | 2 +- AudioCore_Tests/TimeStretchEngine_Tests.cs | 39 ++- 9 files changed, 256 insertions(+), 204 deletions(-) diff --git a/ABStemPlayer/ViewModels/PlaybackViewModel.cs b/ABStemPlayer/ViewModels/PlaybackViewModel.cs index 33bf515..008fb09 100644 --- a/ABStemPlayer/ViewModels/PlaybackViewModel.cs +++ b/ABStemPlayer/ViewModels/PlaybackViewModel.cs @@ -67,34 +67,52 @@ public sealed partial class PlaybackViewModel : ObservableObject IStemWaveformService waveformService ) { - _engine = engine; - _separator = separator; - _decoderFactory = decoderFactory; - _waveformService = waveformService; + _engine = engine; + _separator = separator; + _decoderFactory = decoderFactory; + _waveformService = waveformService; CancelConversionCommand = new RelayCommand(_ => CancelConversion()); OpenFileCommand = new AsyncRelayCommand(OpenFileAsync); - PlayCommand = new AsyncRelayCommand(() => _engine.PlayAsync()); + PlayCommand = new AsyncRelayCommand(OnPlay); PauseCommand = new AsyncRelayCommand(() => _engine.PauseAsync()); StopCommand = new AsyncRelayCommand(async () => { await _engine.StopAsync(); await _engine.SeekAsync(TimeSpan.Zero); }); RewindCommand = new AsyncRelayCommand(() => _engine.SeekAsync(CurrentTime - TimeSpan.FromSeconds(5))); FastForwardCommand = new AsyncRelayCommand(() => _engine.SeekAsync(CurrentTime + TimeSpan.FromSeconds(5))); - SetPointACommand = new RelayCommand(_ => + SetPointACommand = new RelayCommand(_ => { _loopA = CurrentTime; UpdateLoop(); }); - SetPointBCommand = new RelayCommand(_ => + SetPointBCommand = new RelayCommand(_ => { _loopB = CurrentTime; UpdateLoop(); }); - + + } + + private async Task OnPlay() + { + if (_engine.CurrentSession == null) + return; + + var mixer = new MixerSettings + { + Stems = _engine.CurrentSession.StemSet.Stems.Select(GetMixerSettings).ToList() + }; + + _engine.CurrentSession.Mixer = mixer; + _engine.CurrentSession.Speed = new PlaybackSpeedSettings + { + Speed = PlaybackSpeed + }; + await _engine.PlayAsync(); } partial void OnCurrentTimeChanged(TimeSpan value) @@ -112,6 +130,12 @@ public sealed partial class PlaybackViewModel : ObservableObject band.UpdatePlaybackPosition(CurrentTime, TotalTime); } } + + partial void OnPlaybackSpeedChanged(float value) + { + _engine.CurrentSession?.Speed.Speed = value; + } + // ----------------------------- // File open + stem conversion //------------------------------ @@ -358,18 +382,18 @@ public sealed partial class PlaybackViewModel : ObservableObject if ( found != null ) return new StemMixSettings { - GainDb = found.GainDb, + GainDb = found.GainDb, Enabled = found.Enabled, - Pan = found.Pan + Pan = found.Pan }; found = new StemChannelViewModel(stem.Type); Mixer.Stems.Add(found); return new StemMixSettings { - GainDb = found.GainDb, + GainDb = found.GainDb, Enabled = found.Enabled, - Pan = found.Pan + Pan = found.Pan }; } @@ -402,8 +426,4 @@ public sealed partial class PlaybackViewModel : ObservableObject } } - public void UpdateSpeed() - { - _engine.SetSpeed(PlaybackSpeed); - } } diff --git a/ABStemPlayer/Views/PlaybackControls.axaml b/ABStemPlayer/Views/PlaybackControls.axaml index 2245f49..8122128 100644 --- a/ABStemPlayer/Views/PlaybackControls.axaml +++ b/ABStemPlayer/Views/PlaybackControls.axaml @@ -40,7 +40,16 @@ Grid.Column="0" Margin="0,0,6,0"> + Stretch="None" + RenderTransformOrigin="0,0"> + + + + + + M 6 10 H 18 L 22 14 H 34 V 30 H 6 Z + + @@ -50,7 +59,16 @@ Grid.Column="1" Margin="0,0,6,0"> + Stretch="None" + RenderTransformOrigin="0,0"> + + + + + + M 30 8 L 10 20 L 30 32 Z M 18 8 L -2 20 L 18 32 Z + + @@ -59,8 +77,17 @@ Height="40" Grid.Column="2" Margin="0,0,6,0"> - + + + + + + + M 10 8 L 32 20 L 10 32 Z + + @@ -70,9 +97,18 @@ Grid.Column="3" Margin="0,0,6,0"> - + Stretch="None" + RenderTransformOrigin="0,0"> + + + + + + M 8 8 H 16 V 32 H 8 Z + M 24 8 H 32 V 32 H 24 Z + + + @@ -90,7 +135,16 @@ Height="40" Grid.Column="5"> + Stretch="None" + RenderTransformOrigin="0,0"> + + + + + + M 10 8 L 30 20 L 10 32 Z M 22 8 L 42 20 L 22 32 Z + + @@ -165,23 +219,23 @@ StrokeThickness="2" Fill="Transparent" Data=" - M 10 2.44 L 18.4 2.44 - A 6 6 0 0 1 24.4 8.44 - A 6 6 0 0 1 18.4 14.44 - L 22 14.44 - A 5.4 5.4 0 0 1 27.4 19.84 - A 5.4 5.4 0 0 1 22 25.24 - L 10 25.24 Z + M 10 2.44 L 18.4 2.44 + A 6 6 0 0 1 24.4 8.44 + A 6 6 0 0 1 18.4 14.44 + L 22 14.44 + A 5.4 5.4 0 0 1 27.4 19.84 + A 5.4 5.4 0 0 1 22 25.24 + L 10 25.24 Z - M 19 7.24 L 19 10.44 - L 25 10.44 - A 1.2 1.2 0 0 0 26.2 9.24 - A 1.2 1.2 0 0 0 25 7.24 Z + M 19 7.24 L 19 10.44 + L 25 10.44 + A 1.2 1.2 0 0 0 26.2 9.24 + A 1.2 1.2 0 0 0 25 7.24 Z - M 19 14.44 L 19 19.04 - L 25 19.04 - A 1.8 1.8 0 0 0 26.8 17.24 - A 1.8 1.8 0 0 0 25 14.44 Z + M 19 14.44 L 19 19.04 + L 25 19.04 + A 1.8 1.8 0 0 0 26.8 17.24 + A 1.8 1.8 0 0 0 25 14.44 Z "/> diff --git a/AudioCore/Impl/RubberBandTimeStretchEngine.cs b/AudioCore/Impl/RubberBandTimeStretchEngine.cs index 5a381bc..a08c6b9 100644 --- a/AudioCore/Impl/RubberBandTimeStretchEngine.cs +++ b/AudioCore/Impl/RubberBandTimeStretchEngine.cs @@ -115,23 +115,23 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl { FileName = "ffmpeg", Arguments = - $"-hide_banner -loglevel error " + - $"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " + - $"-af \"rubberband=tempo={_speed}\" " + - $"-f f32le -ar {_sampleRate} -ac {_channels} pipe:1", - RedirectStandardInput = true, + $"-hide_banner -loglevel error " + + $"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " + + $"-af \"rubberband=tempo={_speed}\" " + + $"-f f32le -ar {_sampleRate} -ac {_channels} pipe:1", + RedirectStandardInput = true, RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, }; - _ff = System.Diagnostics.Process.Start(psi); - _stdin = _ff!.StandardInput.BaseStream; + _ff = System.Diagnostics.Process.Start(psi); + _stdin = _ff!.StandardInput.BaseStream; _stdout = _ff!.StandardOutput.BaseStream; _readerRunning = true; - _readerThread = new Thread(ReaderLoop) { IsBackground = true }; + _readerThread = new Thread(ReaderLoop) { IsBackground = true }; _readerThread.Start(); } diff --git a/AudioCore/Impl/StemPlaybackEngine.cs b/AudioCore/Impl/StemPlaybackEngine.cs index 3421f46..831a9af 100644 --- a/AudioCore/Impl/StemPlaybackEngine.cs +++ b/AudioCore/Impl/StemPlaybackEngine.cs @@ -12,10 +12,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable private PlaybackSession? _session; private IStemDecoder[] _decoders = Array.Empty(); - private StemMixSettings[] _stemMixSettings = Array.Empty(); - private MixerSettings? _mixerSettings; + private MixerSettings? Mixer => _session?.Mixer; - private PlaybackSpeedSettings _speedSettings = new(); private LoopRegion _loopRegion = new(); private long _currentFramePosition; @@ -23,10 +21,14 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable private long _loopEndFrames; private bool _isPlaying; + private bool _outputStarted; private CancellationTokenSource? _renderCts; private Task? _renderTask; private IProgressReporter? _progressReporter; + // Reused per-block list, no per-frame allocation + private readonly List _stemBlocks = new(8); + public StemPlaybackEngine( IStemDecoderFactory stemDecoderFactory, IAudioOutputDevice outputDevice, @@ -34,9 +36,9 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable ITimeStretchEngine timeStretchEngine) { _stemDecoderFactory = stemDecoderFactory; - _outputDevice = outputDevice; - _audioMixer = audioMixer; - _timeStretchEngine = timeStretchEngine; + _outputDevice = outputDevice; + _audioMixer = audioMixer; + _timeStretchEngine = timeStretchEngine; } public PlaybackSession? CurrentSession @@ -50,7 +52,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable } } - public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter progress) + public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter progress) { await StopAsync().ConfigureAwait(false); @@ -63,17 +65,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable .Select(stem => _stemDecoderFactory.Create(stem)) .ToArray(); - _stemMixSettings = session.Mixer.Stems.ToArray(); - _mixerSettings = new MixerSettings - { - Stems = _stemMixSettings - }; - - _speedSettings = new PlaybackSpeedSettings - { - Speed = session.Speed.Speed - }; - _timeStretchEngine.Configure(_speedSettings); + _timeStretchEngine.Configure(_session.Speed); _loopRegion = session.Loop; if (_loopRegion.IsEnabled) @@ -109,11 +101,11 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable { _renderCts?.Dispose(); _renderCts = new CancellationTokenSource(); + _outputStarted = false; _renderTask = Task.Run(() => RenderLoopAsync(_renderCts.Token)); } _isPlaying = true; - _outputDevice.Start(); } return Task.CompletedTask; @@ -129,7 +121,12 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable } _isPlaying = false; - _outputDevice.Stop(); + + if (_outputStarted) + { + _outputDevice.Stop(); + _outputStarted = false; + } } return Task.CompletedTask; @@ -140,6 +137,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable CancellationTokenSource? ctsToCancel; IStemDecoder[] decodersToDispose; Task? renderTask; + bool outputStarted; lock (_stateLock) { @@ -154,7 +152,13 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable ctsToCancel = _renderCts; _renderCts = null; - _outputDevice.Stop(); + outputStarted = _outputStarted; + _outputStarted = false; + + if (outputStarted) + { + _outputDevice.Stop(); + } decodersToDispose = _decoders; _decoders = Array.Empty(); @@ -168,9 +172,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable ctsToCancel.Cancel(); } - // Do NOT wait on renderTask if we are already inside it. - // Just let it observe cancellation and exit. - if (renderTask is not null && !ReferenceEquals(renderTask, Task.CurrentId)) + if (renderTask is not null && renderTask.Id != Task.CurrentId) { try { @@ -241,72 +243,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable } } - public void SetSpeed(double speedFactor) - { - lock (_stateLock) - { - _speedSettings.Speed = (float)speedFactor; - _timeStretchEngine.Configure(_speedSettings); - } - } - - public void SetStemEnabled(int stemIndex, bool enabled) - { - lock (_stateLock) - { - if (stemIndex < 0 || stemIndex >= _stemMixSettings.Length) - { - return; - } - - var current = _stemMixSettings[stemIndex]; - _stemMixSettings[stemIndex] = new StemMixSettings - { - Enabled = enabled, - GainDb = current.GainDb, - Pan = current.Pan - }; - } - } - - public void SetStemGain(int stemIndex, float gainDb) - { - lock (_stateLock) - { - if (stemIndex < 0 || stemIndex >= _stemMixSettings.Length) - { - return; - } - - var current = _stemMixSettings[stemIndex]; - _stemMixSettings[stemIndex] = new StemMixSettings - { - Enabled = current.Enabled, - GainDb = gainDb, - Pan = current.Pan - }; - } - } - - public void SetStemPan(int stemIndex, float pan) - { - lock (_stateLock) - { - if (stemIndex < 0 || stemIndex >= _stemMixSettings.Length) - { - return; - } - - var current = _stemMixSettings[stemIndex]; - _stemMixSettings[stemIndex] = new StemMixSettings - { - Enabled = current.Enabled, - GainDb = current.GainDb, - Pan = pan - }; - } - } - private async Task RenderLoopAsync(CancellationToken ct) { try @@ -315,51 +251,56 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable { bool playing; IStemDecoder[] decodersSnapshot; - MixerSettings? mixerSettingsSnapshot; long loopStart; long loopEnd; bool loopEnabled; + MixerSettings? mixerSnapshot; + IProgressReporter? progressReporter; lock (_stateLock) { - playing = _isPlaying; - decodersSnapshot = _decoders; - mixerSettingsSnapshot = _mixerSettings; - loopStart = _loopStartFrames; - loopEnd = _loopEndFrames; - loopEnabled = _loopRegion.IsEnabled; + playing = _isPlaying; + decodersSnapshot = _decoders; + loopStart = _loopStartFrames; + loopEnd = _loopEndFrames; + loopEnabled = _loopRegion.IsEnabled; + mixerSnapshot = Mixer; + progressReporter = _progressReporter; } - if (!playing || decodersSnapshot.Length == 0 || mixerSettingsSnapshot is null) + if (!playing || decodersSnapshot.Length == 0 || mixerSnapshot is null) { await Task.Delay(5, ct).ConfigureAwait(false); continue; } - List stemBlocks = new(); - - bool eofDetected = false; - - // Decode once, no double scanning - var totalFrames = decodersSnapshot[0].Stem.Duration.TotalSeconds * _outputDevice.SampleRate; + _stemBlocks.Clear(); + var eofDetected = false; foreach (var decoder in decodersSnapshot) { if (!decoder.TryDecodeNextBlock(out var block)) { eofDetected = true; - foreach (var b in stemBlocks) - b.Dispose(); + + for (var i = 0; i < _stemBlocks.Count; i++) + { + _stemBlocks[i].Dispose(); + } + + _stemBlocks.Clear(); break; } - stemBlocks.Add(block); + _stemBlocks.Add(block); } - if (eofDetected || stemBlocks.Count == 0) + if (eofDetected || _stemBlocks.Count == 0) { - // Report EOF progress - await _progressReporter!.ReportProgress(TimeSpan.FromSeconds(1.0)); + if (progressReporter is not null) + { + await progressReporter.ReportProgress(TimeSpan.FromSeconds(1.0)); + } lock (_stateLock) { @@ -369,34 +310,45 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable break; } - // Report progress (0..1) var progress = TimeSpan.FromSeconds((double)_currentFramePosition / _outputDevice.SampleRate); - await _progressReporter!.ReportProgress(progress); - - using var mixed = _audioMixer.Mix(stemBlocks, mixerSettingsSnapshot); - - foreach (var block in stemBlocks) - block.Dispose(); - - var nextPosition = mixed.SamplePosition + mixed.Frames; - - // Loop region handling - if (loopEnabled && loopEnd > loopStart && nextPosition >= loopEnd) + if (progressReporter is not null) { - foreach (var decoder in decodersSnapshot) - decoder.Seek(loopStart); - - lock (_stateLock) - { - _currentFramePosition = loopStart; - } - - continue; + await progressReporter.ReportProgress(progress); } + using var mixed = _audioMixer.Mix(_stemBlocks, mixerSnapshot); + + for (var i = 0; i < _stemBlocks.Count; i++) + { + _stemBlocks[i].Dispose(); + } + _stemBlocks.Clear(); + using var stretched = _timeStretchEngine.Process(mixed); - _outputDevice.Write(stretched.Buffer.Span); + if (stretched.Buffer != null) + { + if (!_outputStarted) + { + _outputDevice.Start(); + _outputStarted = true; + } + + _outputDevice.Write(stretched.Buffer.Span); + } + + var nextPosition = mixed.SamplePosition + mixed.Frames; + + if (loopEnabled && loopEnd > loopStart && nextPosition >= loopEnd) + { + lock (_stateLock) + { + _currentFramePosition = loopEnd; + _isPlaying = false; + } + + break; + } lock (_stateLock) { @@ -406,7 +358,11 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable } finally { - _outputDevice.Stop(); + if (_outputStarted) + { + _outputDevice.Stop(); + _outputStarted = false; + } } } diff --git a/AudioCore/Impl/WasapiOutputDevice.cs b/AudioCore/Impl/WasapiOutputDevice.cs index 5f5d353..beb8385 100644 --- a/AudioCore/Impl/WasapiOutputDevice.cs +++ b/AudioCore/Impl/WasapiOutputDevice.cs @@ -28,6 +28,7 @@ public sealed class WasapiOutputDevice : IAudioOutputDevice, IDisposable { _pool = pool; _mixFormat = WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channels); + _isFloat = true; SampleRate = sampleRate; Channels = channels; diff --git a/AudioCore/Interfaces/IStemPlaybackEngine.cs b/AudioCore/Interfaces/IStemPlaybackEngine.cs index 04c36ae..0198320 100644 --- a/AudioCore/Interfaces/IStemPlaybackEngine.cs +++ b/AudioCore/Interfaces/IStemPlaybackEngine.cs @@ -15,12 +15,4 @@ public interface IStemPlaybackEngine // Loop void SetLoop(TimeSpan start, TimeSpan end); void ClearLoop(); - - // Speed - void SetSpeed(double speedFactor); - - // Mixer - void SetStemEnabled(int stemNo, bool enabled); - void SetStemGain(int stemNo, float gainDb); - void SetStemPan(int stemNo, float pan); } diff --git a/AudioCore/Models/TimeStretchedAudioBlock.cs b/AudioCore/Models/TimeStretchedAudioBlock.cs index 1a65157..38856f8 100644 --- a/AudioCore/Models/TimeStretchedAudioBlock.cs +++ b/AudioCore/Models/TimeStretchedAudioBlock.cs @@ -5,17 +5,17 @@ namespace AudioCore.Models; public readonly struct TimeStretchedAudioBlock : IDisposable { public AudioBuffer Buffer { get; } - public int Frames { get; } - public int Channels { get; } + public int Frames { get; } + public int Channels { get; } public int SampleRate { get; } public TimeStretchedAudioBlock(AudioBuffer buffer, int frames, int channels, int sampleRate) { - Buffer = buffer; - Frames = frames; - Channels = channels; + Buffer = buffer; + Frames = frames; + Channels = channels; SampleRate = sampleRate; } - public void Dispose() => Buffer.Dispose(); + public void Dispose() => Buffer?.Dispose(); } diff --git a/AudioCore_Tests/StemPlaybackEngine_Tests.cs b/AudioCore_Tests/StemPlaybackEngine_Tests.cs index 4ddc9b2..07e1b20 100644 --- a/AudioCore_Tests/StemPlaybackEngine_Tests.cs +++ b/AudioCore_Tests/StemPlaybackEngine_Tests.cs @@ -336,7 +336,7 @@ public sealed class StemPlaybackEngine_Tests var session = CreateSession(1); await engine.LoadSessionAsync(session, new DummyProgressReporter()); - engine.SetSpeed(1.5); + engine.CurrentSession!.Speed.Speed = 1.5f; Assert.IsFalse(output.Started); } diff --git a/AudioCore_Tests/TimeStretchEngine_Tests.cs b/AudioCore_Tests/TimeStretchEngine_Tests.cs index 27fd6eb..add674f 100644 --- a/AudioCore_Tests/TimeStretchEngine_Tests.cs +++ b/AudioCore_Tests/TimeStretchEngine_Tests.cs @@ -41,7 +41,7 @@ public sealed class TimeStretchEngine_Tests Assert.AreEqual(44100, output.SampleRate); // Output should be roughly same size at speed 1.0 - Assert.IsTrue(output.Frames >= 1000 && output.Frames <= 1300); + Assert.AreEqual(5000, output.Frames); // Validate PCM foreach (var f in output.Buffer.Span) @@ -55,10 +55,10 @@ public sealed class TimeStretchEngine_Tests } [TestMethod] - public void Process_Respects_Speed_Change() + public void Process_Respects_Speed_Increase() { using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2); - var input = MakeBlock(25000); + var input = MakeBlock(1000); // Let the engine and FFmpeg warm up with a few calls for (var i = 0; i < 5; i++) @@ -75,14 +75,43 @@ public sealed class TimeStretchEngine_Tests var faster = engine.Process(input); // Don’t insist on > 0; insist on “not more than” - Assert.IsLessThanOrEqualTo(normalFrames, -faster.Frames, $"Speed 1.5 should not increase frame count (normal={normalFrames}, faster={faster.Frames})"); + Assert.IsLessThanOrEqualTo(normalFrames, faster.Frames, + $"Speed 1.5 should not increase frame count (normal={normalFrames}, faster={faster.Frames})"); input.Dispose(); normal.Dispose(); faster.Dispose(); } + [TestMethod] + public void Process_Respects_Speed_Decrease() + { + using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2); + var input = MakeBlock(1000); + + // Let the engine and FFmpeg warm up with a few calls + for (var i = 0; i < 5; i++) + _ = engine.Process(input); + + var normal = engine.Process(input); + var normalFrames = normal.Frames; + + engine.Configure(new PlaybackSpeedSettings { Speed = 0.5f }); + + for (var i = 0; i < 5; i++) + _ = engine.Process(input); + + var slower = engine.Process(input); + + // Don’t insist on > 0; insist on “not more than” + Assert.IsGreaterThanOrEqualTo(normalFrames, slower.Frames, + $"Speed 0.5 should not decrease frame count (normal={normalFrames}, slower={slower.Frames})"); + + input.Dispose(); + normal.Dispose(); + slower.Dispose(); + } +