diff --git a/ABStemPlayer/ViewModels/WaveformBandViewModel.cs b/ABStemPlayer/ViewModels/WaveformBandViewModel.cs index df6b69b..54c6b7c 100644 --- a/ABStemPlayer/ViewModels/WaveformBandViewModel.cs +++ b/ABStemPlayer/ViewModels/WaveformBandViewModel.cs @@ -47,7 +47,7 @@ public partial class WaveformBandViewModel : ObservableObject return; } - double ratio = current.TotalSeconds / total.TotalSeconds; + double ratio = current.TotalMicroseconds / total.TotalMicroseconds; PlaybackX = ratio * CanvasWidth; } diff --git a/AudioCore/AudioCore.csproj b/AudioCore/AudioCore.csproj index f0f4e82..d9f5c1b 100644 --- a/AudioCore/AudioCore.csproj +++ b/AudioCore/AudioCore.csproj @@ -3,9 +3,7 @@ true Alexander Shabarshov - - Audio engine core for ABStemPlayer. Audio pipeline. - + Audio engine core for ABStemPlayer. Audio pipeline. https://github.com/unclshura/ABStemPlayer audio, player, ab, guitar, drums, piano, stem, mixer, c# README.md @@ -34,8 +32,8 @@ 1.0.0 0 - 0000 - $(Version).$(BuildNumber)+$(SourceRevisionId) + + $(Version).$(BuildNumber)$(SourceRevisionId) $(Version) $(Version).$(BuildNumber) diff --git a/AudioCore/Impl/RubberBandTimeStretchEngine.cs b/AudioCore/Impl/RubberBandTimeStretchEngine.cs index e810b32..ec4ca8d 100644 --- a/AudioCore/Impl/RubberBandTimeStretchEngine.cs +++ b/AudioCore/Impl/RubberBandTimeStretchEngine.cs @@ -10,6 +10,15 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp private readonly int _sampleRate; private long[] _sourcePositions; + private sealed class SourceChunkInfo + { + public long SourcePosition; // starting frame index in source stream + public int SourceFrames; // number of frames in this chunk + } + + private readonly Queue[] _pendingInput; + private double[] _fractionalInput; + // One RubberBand/ffmpeg process per stem (each is stereo: 2 channels) private sealed class StemProcess : IDisposable { @@ -51,6 +60,12 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp _stemProcesses.Capacity = stemCount; _stemCount = stemCount; _sourcePositions = new long[_stemCount]; + _fractionalInput = new double[_stemCount]; + + _pendingInput = Enumerable.Range(0, _stemCount) + .Select(_ => new Queue()) + .ToArray(); + } public async Task Configure(PlaybackSpeedSettings settings, CancellationToken token) @@ -75,32 +90,48 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp public Task SubmitStems(IReadOnlyList stemBlocks, CancellationToken token) { - if ( stemBlocks.Count != _stemCount) + if (stemBlocks.Count != _stemCount) throw new ArgumentException($"Expected {_stemCount} stems, but got {stemBlocks.Count}."); + EnsureStemProcesses(stemBlocks.Count); + // No-stretch path: just enqueue into per-stem rings if (Math.Abs(_speed - 1.0f) < 0.01f) { - EnsureStemProcesses(stemBlocks.Count); - for (int i = 0; i < stemBlocks.Count; i++) { - var proc = _stemProcesses[i]; - var bytes = MemoryMarshal.AsBytes(stemBlocks[i].Buffer.Span); - proc.Ring.Write(bytes, bytes.Length, token); + var block = stemBlocks[i]; + + // Track source position for passthrough mode + _pendingInput[i].Enqueue(new SourceChunkInfo + { + SourcePosition = block.Position, + SourceFrames = block.Frames + }); + + var bytes = MemoryMarshal.AsBytes(block.Buffer.Span); + _stemProcesses[i].Ring.Write(bytes, bytes.Length, token); } return Task.CompletedTask; } // Stretch path: one ffmpeg+rubberband per stem - EnsureStemProcesses(stemBlocks.Count); StartProcessesIfNeeded(stemBlocks.Count); for (int i = 0; i < stemBlocks.Count; i++) { + var block = stemBlocks[i]; + + // Track source position for stretched mode + _pendingInput[i].Enqueue(new SourceChunkInfo + { + SourcePosition = block.Position, + SourceFrames = block.Frames + }); + var proc = _stemProcesses[i]; - var span = stemBlocks[i].Buffer.Span; + var span = block.Buffer.Span; var bytes = MemoryMarshal.AsBytes(span); try @@ -108,29 +139,31 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp if (token.IsCancellationRequested) return Task.CompletedTask; + // Only write if ffmpeg is alive if (proc.Stdin != null && !(proc.Ff?.Proc?.HasExited ?? true)) + { proc.Stdin.Write(bytes); - if (token.IsCancellationRequested) - return Task.CompletedTask; - try - { - proc.Stdin?.Flush(); - } - catch (System.ObjectDisposedException) - { - // process has exited + try + { + proc.Stdin.Flush(); + } + catch (ObjectDisposedException) + { + // ffmpeg exited early + } } } catch { - // ignore + // Ignore write errors (ffmpeg may exit early) } } return Task.CompletedTask; } + public async Task ReceiveStems(CancellationToken token) { EnsureStemProcesses(_stemCount); @@ -160,9 +193,9 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp return Array.Empty(); // Determine block size (final block may be smaller) - int bytesToRead = Math.Min(bytesPerBlock, available); - int samplesToRead = bytesToRead / sizeof(float); - int framesToRead = samplesToRead / 2; + int bytesToRead = Math.Min(bytesPerBlock, available); + int samplesToRead = bytesToRead / sizeof(float); + int framesToRead = samplesToRead / 2; // Allocate a temporary byte[] buffer (safe across await) byte[] temp = new byte[bytesToRead]; @@ -195,10 +228,10 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp var outBytes = MemoryMarshal.AsBytes(outBuf.Span); temp.AsSpan().CopyTo(outBytes); - // Compute source position - long sourceFrames = (long)(framesToRead * _speed); - long sourcePos = _sourcePositions[i]; - _sourcePositions[i] += sourceFrames; + // + // *** Correct source-position mapping *** + // + long sourcePos = ComputeSourcePosition(i, framesToRead); result[i] = new TimeStretchedAudioBlock( outBuf, @@ -211,6 +244,54 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp return result; } +private long ComputeSourcePosition(int stemIndex, int outputFrames) + { + // inputFrames = outputFrames / speed + double neededInputFrames = outputFrames / _speed; + + // Add fractional requirement + _fractionalInput[stemIndex] += neededInputFrames; + + long sourcePos = 0; + bool first = true; + + var queue = _pendingInput[stemIndex]; + + // If no input chunks exist (warm-up), return last known position + if (queue.Count == 0) + return _sourcePositions[stemIndex]; + + while (_fractionalInput[stemIndex] >= 1 && queue.Count > 0) + { + var chunk = queue.Peek(); + + int take = (int)Math.Min(chunk.SourceFrames, Math.Floor(_fractionalInput[stemIndex])); + + if (take <= 0) + break; + + if (first) + { + sourcePos = chunk.SourcePosition; + first = false; + } + + chunk.SourceFrames -= take; + _fractionalInput[stemIndex] -= take; + + if (chunk.SourceFrames == 0) + queue.Dequeue(); + } + + // If we consumed nothing (fraction < 1), use last known position + if (first) + sourcePos = _sourcePositions[stemIndex]; + + _sourcePositions[stemIndex] = sourcePos; + return sourcePos; + } + + private void EnsureStemProcesses(int stemCount) { diff --git a/AudioCore/Impl/StemPlaybackEngine.cs b/AudioCore/Impl/StemPlaybackEngine.cs index 3a65b6e..4984f1a 100644 --- a/AudioCore/Impl/StemPlaybackEngine.cs +++ b/AudioCore/Impl/StemPlaybackEngine.cs @@ -8,14 +8,14 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable { private sealed class PipelineState : IDisposable { - public IStemDecoder[] Decoders = Array.Empty(); - public bool OutputStarted; + public IStemDecoder[] Decoders = Array.Empty(); + public bool OutputStarted; public CancellationTokenSource? Cts; - public Task? RenderTask; + public Task? RenderTask; public void Dispose() { - try { Cts?.Cancel(); } catch { } + try { Cts?.Cancel(); } catch { } try { Cts?.Dispose(); } catch { } foreach (var d in Decoders) @@ -39,7 +39,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable private LoopRegion _loopRegion = new(); - private long _decodedFramePosition; private long _loopStartFrames; private long _loopEndFrames; @@ -96,7 +95,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable } _pendingSeekFrames = 0; - _decodedFramePosition = 0; } } @@ -126,7 +124,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable d.Seek(_pendingSeekFrames); } - _decodedFramePosition = _pendingSeekFrames; } _pipeline.RenderTask = Task.Run(() => RenderLoopAsync(_pipeline, _pipeline.Cts.Token)); @@ -163,7 +160,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable if (!IsPlaying && _pipeline is null) return; - _decodedFramePosition = 0; _pendingSeekFrames = 0; pipelineToDispose = _pipeline; @@ -201,12 +197,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable { foreach (var d in _pipeline.Decoders) d.Seek(frameIndex); - - _decodedFramePosition = frameIndex; - } - else - { - _decodedFramePosition = frameIndex; } } @@ -349,15 +339,9 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable try { d.Seek(loopStart); } catch { } } - lock (_stateLock) - _decodedFramePosition = loopStart; - // continue decoding from the loop start continue; } - - lock (_stateLock) - _decodedFramePosition = nextPosition; } } catch { } @@ -430,19 +414,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable _outputDevice.Write(mixed.Buffer.Span); gotFirstBlock = true; - try - { - double progress; - lock (_stateLock) - { - var total = _session?.StemSet.TotalFrames ?? 1L; - progress = (double)mixed.Position / Math.Max(total, 1L); - } - - if (_progressReporter != null) - await _progressReporter.ReportProgress(progress).ConfigureAwait(false); - } - catch { } + await ReportProgress(mixed).ConfigureAwait(false); try { mixed.Dispose(); } catch { } foreach (var b in stretchedBlocks) @@ -459,6 +431,25 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable } } + private async Task ReportProgress(MixedAudioBlock mixed) + { + if (_progressReporter == null) + return; + + try + { + double progress; + lock (_stateLock) + { + var total = _session?.StemSet.TotalFrames ?? 1L; + progress = (double)mixed.Position / Math.Max(total, 1L); + } + + await _progressReporter.ReportProgress(progress).ConfigureAwait(false); + } + catch { } + } + private long TimeToFrames(TimeSpan time) { return (long)(time.TotalSeconds * _outputDevice.SampleRate);