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

@ -67,28 +67,28 @@ public sealed partial class PlaybackViewModel : ObservableObject
IStemWaveformService waveformService IStemWaveformService waveformService
) )
{ {
_engine = engine; _engine = engine;
_separator = separator; _separator = separator;
_decoderFactory = decoderFactory; _decoderFactory = decoderFactory;
_waveformService = waveformService; _waveformService = waveformService;
CancelConversionCommand = new RelayCommand(_ => CancelConversion()); CancelConversionCommand = new RelayCommand(_ => CancelConversion());
OpenFileCommand = new AsyncRelayCommand(OpenFileAsync); OpenFileCommand = new AsyncRelayCommand(OpenFileAsync);
PlayCommand = new AsyncRelayCommand(() => _engine.PlayAsync()); PlayCommand = new AsyncRelayCommand(OnPlay);
PauseCommand = new AsyncRelayCommand(() => _engine.PauseAsync()); PauseCommand = new AsyncRelayCommand(() => _engine.PauseAsync());
StopCommand = new AsyncRelayCommand(async () => { await _engine.StopAsync(); await _engine.SeekAsync(TimeSpan.Zero); }); StopCommand = new AsyncRelayCommand(async () => { await _engine.StopAsync(); await _engine.SeekAsync(TimeSpan.Zero); });
RewindCommand = new AsyncRelayCommand(() => _engine.SeekAsync(CurrentTime - TimeSpan.FromSeconds(5))); RewindCommand = new AsyncRelayCommand(() => _engine.SeekAsync(CurrentTime - TimeSpan.FromSeconds(5)));
FastForwardCommand = 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; _loopA = CurrentTime;
UpdateLoop(); UpdateLoop();
}); });
SetPointBCommand = new RelayCommand(_ => SetPointBCommand = new RelayCommand(_ =>
{ {
_loopB = CurrentTime; _loopB = CurrentTime;
UpdateLoop(); UpdateLoop();
@ -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) partial void OnCurrentTimeChanged(TimeSpan value)
{ {
foreach (var band in Bands) foreach (var band in Bands)
@ -112,6 +130,12 @@ public sealed partial class PlaybackViewModel : ObservableObject
band.UpdatePlaybackPosition(CurrentTime, TotalTime); band.UpdatePlaybackPosition(CurrentTime, TotalTime);
} }
} }
partial void OnPlaybackSpeedChanged(float value)
{
_engine.CurrentSession?.Speed.Speed = value;
}
// ----------------------------- // -----------------------------
// File open + stem conversion // File open + stem conversion
//------------------------------ //------------------------------
@ -358,18 +382,18 @@ public sealed partial class PlaybackViewModel : ObservableObject
if ( found != null ) if ( found != null )
return new StemMixSettings return new StemMixSettings
{ {
GainDb = found.GainDb, GainDb = found.GainDb,
Enabled = found.Enabled, Enabled = found.Enabled,
Pan = found.Pan Pan = found.Pan
}; };
found = new StemChannelViewModel(stem.Type); found = new StemChannelViewModel(stem.Type);
Mixer.Stems.Add(found); Mixer.Stems.Add(found);
return new StemMixSettings return new StemMixSettings
{ {
GainDb = found.GainDb, GainDb = found.GainDb,
Enabled = found.Enabled, 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);
}
} }

View File

@ -40,7 +40,16 @@
Grid.Column="0" Grid.Column="0"
Margin="0,0,6,0"> Margin="0,0,6,0">
<Path Fill="#e0e0e0" <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> </Button>
<!-- Rewind --> <!-- Rewind -->
@ -50,7 +59,16 @@
Grid.Column="1" Grid.Column="1"
Margin="0,0,6,0"> Margin="0,0,6,0">
<Path Fill="#e0e0e0" <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> </Button>
<!-- Play --> <!-- Play -->
@ -59,8 +77,17 @@
Height="40" Height="40"
Grid.Column="2" Grid.Column="2"
Margin="0,0,6,0"> Margin="0,0,6,0">
<Path Fill="#00ff00" <Path Fill="#e0e0e0"
Data="M 10 8 L 32 20 L 10 32 Z"/> 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> </Button>
<!-- Pause --> <!-- Pause -->
@ -70,9 +97,18 @@
Grid.Column="3" Grid.Column="3"
Margin="0,0,6,0"> Margin="0,0,6,0">
<Path Fill="#e0e0e0" <Path Fill="#e0e0e0"
Data="M 8 8 H 16 V 32 H 8 Z Stretch="None"
M 24 8 H 32 V 32 H 24 Z"/> RenderTransformOrigin="0,0">
</Button> <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 --> <!-- Stop -->
<Button Command="{Binding StopCommand}" <Button Command="{Binding StopCommand}"
@ -81,7 +117,16 @@
Grid.Column="4" Grid.Column="4"
Margin="0,0,6,0"> Margin="0,0,6,0">
<Path Fill="#e0e0e0" <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> </Button>
<!-- Fast Forward --> <!-- Fast Forward -->
@ -90,7 +135,16 @@
Height="40" Height="40"
Grid.Column="5"> Grid.Column="5">
<Path Fill="#e0e0e0" <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> </Button>
</Grid> </Grid>
@ -165,23 +219,23 @@
StrokeThickness="2" StrokeThickness="2"
Fill="Transparent" Fill="Transparent"
Data=" Data="
M 10 2.44 L 18.4 2.44 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 24.4 8.44
A 6 6 0 0 1 18.4 14.44 A 6 6 0 0 1 18.4 14.44
L 22 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 27.4 19.84
A 5.4 5.4 0 0 1 22 25.24 A 5.4 5.4 0 0 1 22 25.24
L 10 25.24 Z L 10 25.24 Z
M 19 7.24 L 19 10.44 M 19 7.24 L 19 10.44
L 25 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 26.2 9.24
A 1.2 1.2 0 0 0 25 7.24 Z A 1.2 1.2 0 0 0 25 7.24 Z
M 19 14.44 L 19 19.04 M 19 14.44 L 19 19.04
L 25 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 26.8 17.24
A 1.8 1.8 0 0 0 25 14.44 Z A 1.8 1.8 0 0 0 25 14.44 Z
"/> "/>
</Grid> </Grid>
</Button> </Button>

View File

@ -115,23 +115,23 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
{ {
FileName = "ffmpeg", FileName = "ffmpeg",
Arguments = Arguments =
$"-hide_banner -loglevel error " + $"-hide_banner -loglevel error " +
$"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " + $"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " +
$"-af \"rubberband=tempo={_speed}\" " + $"-af \"rubberband=tempo={_speed}\" " +
$"-f f32le -ar {_sampleRate} -ac {_channels} pipe:1", $"-f f32le -ar {_sampleRate} -ac {_channels} pipe:1",
RedirectStandardInput = true, RedirectStandardInput = true,
RedirectStandardOutput = true, RedirectStandardOutput = true,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true, CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden, WindowStyle = ProcessWindowStyle.Hidden,
}; };
_ff = System.Diagnostics.Process.Start(psi); _ff = System.Diagnostics.Process.Start(psi);
_stdin = _ff!.StandardInput.BaseStream; _stdin = _ff!.StandardInput.BaseStream;
_stdout = _ff!.StandardOutput.BaseStream; _stdout = _ff!.StandardOutput.BaseStream;
_readerRunning = true; _readerRunning = true;
_readerThread = new Thread(ReaderLoop) { IsBackground = true }; _readerThread = new Thread(ReaderLoop) { IsBackground = true };
_readerThread.Start(); _readerThread.Start();
} }

View File

@ -12,10 +12,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
private PlaybackSession? _session; private PlaybackSession? _session;
private IStemDecoder[] _decoders = Array.Empty<IStemDecoder>(); private IStemDecoder[] _decoders = Array.Empty<IStemDecoder>();
private StemMixSettings[] _stemMixSettings = Array.Empty<StemMixSettings>(); private MixerSettings? Mixer => _session?.Mixer;
private MixerSettings? _mixerSettings;
private PlaybackSpeedSettings _speedSettings = new();
private LoopRegion _loopRegion = new(); private LoopRegion _loopRegion = new();
private long _currentFramePosition; private long _currentFramePosition;
@ -23,10 +21,14 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
private long _loopEndFrames; private long _loopEndFrames;
private bool _isPlaying; private bool _isPlaying;
private bool _outputStarted;
private CancellationTokenSource? _renderCts; private CancellationTokenSource? _renderCts;
private Task? _renderTask; private Task? _renderTask;
private IProgressReporter<TimeSpan>? _progressReporter; private IProgressReporter<TimeSpan>? _progressReporter;
// Reused per-block list, no per-frame allocation
private readonly List<AudioBlock> _stemBlocks = new(8);
public StemPlaybackEngine( public StemPlaybackEngine(
IStemDecoderFactory stemDecoderFactory, IStemDecoderFactory stemDecoderFactory,
IAudioOutputDevice outputDevice, IAudioOutputDevice outputDevice,
@ -34,9 +36,9 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
ITimeStretchEngine timeStretchEngine) ITimeStretchEngine timeStretchEngine)
{ {
_stemDecoderFactory = stemDecoderFactory; _stemDecoderFactory = stemDecoderFactory;
_outputDevice = outputDevice; _outputDevice = outputDevice;
_audioMixer = audioMixer; _audioMixer = audioMixer;
_timeStretchEngine = timeStretchEngine; _timeStretchEngine = timeStretchEngine;
} }
public PlaybackSession? CurrentSession public PlaybackSession? CurrentSession
@ -50,7 +52,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
} }
} }
public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter<TimeSpan > progress) public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter<TimeSpan> progress)
{ {
await StopAsync().ConfigureAwait(false); await StopAsync().ConfigureAwait(false);
@ -63,17 +65,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
.Select(stem => _stemDecoderFactory.Create(stem)) .Select(stem => _stemDecoderFactory.Create(stem))
.ToArray(); .ToArray();
_stemMixSettings = session.Mixer.Stems.ToArray(); _timeStretchEngine.Configure(_session.Speed);
_mixerSettings = new MixerSettings
{
Stems = _stemMixSettings
};
_speedSettings = new PlaybackSpeedSettings
{
Speed = session.Speed.Speed
};
_timeStretchEngine.Configure(_speedSettings);
_loopRegion = session.Loop; _loopRegion = session.Loop;
if (_loopRegion.IsEnabled) if (_loopRegion.IsEnabled)
@ -109,11 +101,11 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
{ {
_renderCts?.Dispose(); _renderCts?.Dispose();
_renderCts = new CancellationTokenSource(); _renderCts = new CancellationTokenSource();
_outputStarted = false;
_renderTask = Task.Run(() => RenderLoopAsync(_renderCts.Token)); _renderTask = Task.Run(() => RenderLoopAsync(_renderCts.Token));
} }
_isPlaying = true; _isPlaying = true;
_outputDevice.Start();
} }
return Task.CompletedTask; return Task.CompletedTask;
@ -129,7 +121,12 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
} }
_isPlaying = false; _isPlaying = false;
_outputDevice.Stop();
if (_outputStarted)
{
_outputDevice.Stop();
_outputStarted = false;
}
} }
return Task.CompletedTask; return Task.CompletedTask;
@ -140,6 +137,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
CancellationTokenSource? ctsToCancel; CancellationTokenSource? ctsToCancel;
IStemDecoder[] decodersToDispose; IStemDecoder[] decodersToDispose;
Task? renderTask; Task? renderTask;
bool outputStarted;
lock (_stateLock) lock (_stateLock)
{ {
@ -154,7 +152,13 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
ctsToCancel = _renderCts; ctsToCancel = _renderCts;
_renderCts = null; _renderCts = null;
_outputDevice.Stop(); outputStarted = _outputStarted;
_outputStarted = false;
if (outputStarted)
{
_outputDevice.Stop();
}
decodersToDispose = _decoders; decodersToDispose = _decoders;
_decoders = Array.Empty<IStemDecoder>(); _decoders = Array.Empty<IStemDecoder>();
@ -168,9 +172,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
ctsToCancel.Cancel(); ctsToCancel.Cancel();
} }
// Do NOT wait on renderTask if we are already inside it. if (renderTask is not null && renderTask.Id != Task.CurrentId)
// Just let it observe cancellation and exit.
if (renderTask is not null && !ReferenceEquals(renderTask, Task.CurrentId))
{ {
try 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) private async Task RenderLoopAsync(CancellationToken ct)
{ {
try try
@ -315,51 +251,56 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
{ {
bool playing; bool playing;
IStemDecoder[] decodersSnapshot; IStemDecoder[] decodersSnapshot;
MixerSettings? mixerSettingsSnapshot;
long loopStart; long loopStart;
long loopEnd; long loopEnd;
bool loopEnabled; bool loopEnabled;
MixerSettings? mixerSnapshot;
IProgressReporter<TimeSpan>? progressReporter;
lock (_stateLock) lock (_stateLock)
{ {
playing = _isPlaying; playing = _isPlaying;
decodersSnapshot = _decoders; decodersSnapshot = _decoders;
mixerSettingsSnapshot = _mixerSettings; loopStart = _loopStartFrames;
loopStart = _loopStartFrames; loopEnd = _loopEndFrames;
loopEnd = _loopEndFrames; loopEnabled = _loopRegion.IsEnabled;
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); await Task.Delay(5, ct).ConfigureAwait(false);
continue; continue;
} }
List<AudioBlock> stemBlocks = new(); _stemBlocks.Clear();
var eofDetected = false;
bool eofDetected = false;
// Decode once, no double scanning
var totalFrames = decodersSnapshot[0].Stem.Duration.TotalSeconds * _outputDevice.SampleRate;
foreach (var decoder in decodersSnapshot) foreach (var decoder in decodersSnapshot)
{ {
if (!decoder.TryDecodeNextBlock(out var block)) if (!decoder.TryDecodeNextBlock(out var block))
{ {
eofDetected = true; eofDetected = true;
foreach (var b in stemBlocks)
b.Dispose(); for (var i = 0; i < _stemBlocks.Count; i++)
{
_stemBlocks[i].Dispose();
}
_stemBlocks.Clear();
break; break;
} }
stemBlocks.Add(block); _stemBlocks.Add(block);
} }
if (eofDetected || stemBlocks.Count == 0) if (eofDetected || _stemBlocks.Count == 0)
{ {
// Report EOF progress if (progressReporter is not null)
await _progressReporter!.ReportProgress(TimeSpan.FromSeconds(1.0)); {
await progressReporter.ReportProgress(TimeSpan.FromSeconds(1.0));
}
lock (_stateLock) lock (_stateLock)
{ {
@ -369,34 +310,45 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
break; break;
} }
// Report progress (0..1)
var progress = TimeSpan.FromSeconds((double)_currentFramePosition / _outputDevice.SampleRate); var progress = TimeSpan.FromSeconds((double)_currentFramePosition / _outputDevice.SampleRate);
await _progressReporter!.ReportProgress(progress); if (progressReporter is not null)
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)
{ {
foreach (var decoder in decodersSnapshot) await progressReporter.ReportProgress(progress);
decoder.Seek(loopStart);
lock (_stateLock)
{
_currentFramePosition = loopStart;
}
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); 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) lock (_stateLock)
{ {
@ -406,7 +358,11 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
} }
finally finally
{ {
_outputDevice.Stop(); if (_outputStarted)
{
_outputDevice.Stop();
_outputStarted = false;
}
} }
} }

View File

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

View File

@ -15,12 +15,4 @@ public interface IStemPlaybackEngine
// Loop // Loop
void SetLoop(TimeSpan start, TimeSpan end); void SetLoop(TimeSpan start, TimeSpan end);
void ClearLoop(); 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

@ -5,17 +5,17 @@ namespace AudioCore.Models;
public readonly struct TimeStretchedAudioBlock : IDisposable public readonly struct TimeStretchedAudioBlock : IDisposable
{ {
public AudioBuffer<float> Buffer { get; } public AudioBuffer<float> Buffer { get; }
public int Frames { get; } public int Frames { get; }
public int Channels { get; } public int Channels { get; }
public int SampleRate { get; } public int SampleRate { get; }
public TimeStretchedAudioBlock(AudioBuffer<float> buffer, int frames, int channels, int sampleRate) public TimeStretchedAudioBlock(AudioBuffer<float> buffer, int frames, int channels, int sampleRate)
{ {
Buffer = buffer; Buffer = buffer;
Frames = frames; Frames = frames;
Channels = channels; Channels = channels;
SampleRate = sampleRate; 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); var session = CreateSession(1);
await engine.LoadSessionAsync(session, new DummyProgressReporter()); await engine.LoadSessionAsync(session, new DummyProgressReporter());
engine.SetSpeed(1.5); engine.CurrentSession!.Speed.Speed = 1.5f;
Assert.IsFalse(output.Started); Assert.IsFalse(output.Started);
} }

View File

@ -41,7 +41,7 @@ public sealed class TimeStretchEngine_Tests
Assert.AreEqual(44100, output.SampleRate); Assert.AreEqual(44100, output.SampleRate);
// Output should be roughly same size at speed 1.0 // 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 // Validate PCM
foreach (var f in output.Buffer.Span) foreach (var f in output.Buffer.Span)
@ -55,10 +55,10 @@ public sealed class TimeStretchEngine_Tests
} }
[TestMethod] [TestMethod]
public void Process_Respects_Speed_Change() public void Process_Respects_Speed_Increase()
{ {
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2); 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 // Let the engine and FFmpeg warm up with a few calls
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
@ -75,14 +75,43 @@ public sealed class TimeStretchEngine_Tests
var faster = engine.Process(input); var faster = engine.Process(input);
// Dont insist on > 0; insist on “not more than” // Dont insist on > 0; insist on “not more than”
Assert.IsLessThanOrEqualTo(normalFrames, Assert.IsLessThanOrEqualTo(normalFrames, faster.Frames,
faster.Frames, $"Speed 1.5 should not increase frame count (normal={normalFrames}, faster={faster.Frames})"); $"Speed 1.5 should not increase frame count (normal={normalFrames}, faster={faster.Frames})");
input.Dispose(); input.Dispose();
normal.Dispose(); normal.Dispose();
faster.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();
}