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