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.
This commit is contained in:
Alexander Shabarshov 2026-07-05 15:07:42 +01:00
parent e08d5f8991
commit 950e5f1dcc
9 changed files with 256 additions and 204 deletions

View File

@ -75,7 +75,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
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); });
@ -97,6 +97,24 @@ public sealed partial class PlaybackViewModel : ObservableObject
}
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)
{
foreach (var band in Bands)
@ -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
//------------------------------
@ -402,8 +426,4 @@ public sealed partial class PlaybackViewModel : ObservableObject
}
}
public void UpdateSpeed()
{
_engine.SetSpeed(PlaybackSpeed);
}
}

View File

@ -40,7 +40,16 @@
Grid.Column="0"
Margin="0,0,6,0">
<Path Fill="#e0e0e0"
Data="M 6 10 H 18 L 22 14 H 34 V 30 H 6 Z"/>
Stretch="None"
RenderTransformOrigin="0,0">
<Path.RenderTransform>
<TranslateTransform X="-8" Y="-8"/>
</Path.RenderTransform>
<Path.Data>
M 6 10 H 18 L 22 14 H 34 V 30 H 6 Z
</Path.Data>
</Path>
</Button>
<!-- Rewind -->
@ -50,7 +59,16 @@
Grid.Column="1"
Margin="0,0,6,0">
<Path Fill="#e0e0e0"
Data="M 30 8 L 10 20 L 30 32 Z M 18 8 L -2 20 L 18 32 Z"/>
Stretch="None"
RenderTransformOrigin="0,0">
<Path.RenderTransform>
<TranslateTransform X="-6" Y="-8"/>
</Path.RenderTransform>
<Path.Data>
M 30 8 L 10 20 L 30 32 Z M 18 8 L -2 20 L 18 32 Z
</Path.Data>
</Path>
</Button>
<!-- Play -->
@ -59,8 +77,17 @@
Height="40"
Grid.Column="2"
Margin="0,0,6,0">
<Path Fill="#00ff00"
Data="M 10 8 L 32 20 L 10 32 Z"/>
<Path Fill="#e0e0e0"
Stretch="None"
RenderTransformOrigin="0,0">
<Path.RenderTransform>
<TranslateTransform X="-8" Y="-8"/>
</Path.RenderTransform>
<Path.Data>
M 10 8 L 32 20 L 10 32 Z
</Path.Data>
</Path>
</Button>
<!-- Pause -->
@ -70,8 +97,17 @@
Grid.Column="3"
Margin="0,0,6,0">
<Path Fill="#e0e0e0"
Data="M 8 8 H 16 V 32 H 8 Z
M 24 8 H 32 V 32 H 24 Z"/>
Stretch="None"
RenderTransformOrigin="0,0">
<Path.RenderTransform>
<TranslateTransform X="-8" Y="-8"/>
</Path.RenderTransform>
<Path.Data>
M 8 8 H 16 V 32 H 8 Z
M 24 8 H 32 V 32 H 24 Z
</Path.Data>
</Path>
</Button>
<!-- Stop -->
@ -81,7 +117,16 @@
Grid.Column="4"
Margin="0,0,6,0">
<Path Fill="#e0e0e0"
Data="M 8 8 H 32 V 32 H 8 Z"/>
Stretch="None"
RenderTransformOrigin="0,0">
<Path.RenderTransform>
<TranslateTransform X="-8" Y="-8"/>
</Path.RenderTransform>
<Path.Data>
M 8 8 H 32 V 32 H 8 Z
</Path.Data>
</Path>
</Button>
<!-- Fast Forward -->
@ -90,7 +135,16 @@
Height="40"
Grid.Column="5">
<Path Fill="#e0e0e0"
Data="M 10 8 L 30 20 L 10 32 Z M 22 8 L 42 20 L 22 32 Z"/>
Stretch="None"
RenderTransformOrigin="0,0">
<Path.RenderTransform>
<TranslateTransform X="-14" Y="-8"/>
</Path.RenderTransform>
<Path.Data>
M 10 8 L 30 20 L 10 32 Z M 22 8 L 42 20 L 22 32 Z
</Path.Data>
</Path>
</Button>
</Grid>

View File

@ -12,10 +12,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
private PlaybackSession? _session;
private IStemDecoder[] _decoders = Array.Empty<IStemDecoder>();
private StemMixSettings[] _stemMixSettings = Array.Empty<StemMixSettings>();
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<TimeSpan>? _progressReporter;
// Reused per-block list, no per-frame allocation
private readonly List<AudioBlock> _stemBlocks = new(8);
public StemPlaybackEngine(
IStemDecoderFactory stemDecoderFactory,
IAudioOutputDevice outputDevice,
@ -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;
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;
outputStarted = _outputStarted;
_outputStarted = false;
if (outputStarted)
{
_outputDevice.Stop();
}
decodersToDispose = _decoders;
_decoders = Array.Empty<IStemDecoder>();
@ -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<TimeSpan>? progressReporter;
lock (_stateLock)
{
playing = _isPlaying;
decodersSnapshot = _decoders;
mixerSettingsSnapshot = _mixerSettings;
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<AudioBlock> 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;
await progressReporter.ReportProgress(progress);
}
continue;
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);
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)
{
@ -405,8 +357,12 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
}
}
finally
{
if (_outputStarted)
{
_outputDevice.Stop();
_outputStarted = false;
}
}
}

View File

@ -28,6 +28,7 @@ public sealed class WasapiOutputDevice : IAudioOutputDevice, IDisposable
{
_pool = pool;
_mixFormat = WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channels);
_isFloat = true;
SampleRate = sampleRate;
Channels = channels;

View File

@ -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);
}

View File

@ -17,5 +17,5 @@ public readonly struct TimeStretchedAudioBlock : IDisposable
SampleRate = sampleRate;
}
public void Dispose() => Buffer.Dispose();
public void Dispose() => Buffer?.Dispose();
}

View File

@ -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);
}

View File

@ -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);
// Dont 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);
// Dont 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();
}