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