Compare commits

...

2 Commits

29 changed files with 883 additions and 533 deletions

View File

@ -0,0 +1,8 @@
{
"profiles": {
"ABStemPlayer": {
"commandName": "Project",
"commandLineArgs": "C:\\Users\\uncls\\Music\\test_input.mp3"
}
}
}

View File

@ -56,6 +56,8 @@ public sealed partial class PlaybackViewModel : ObservableObject
private TimeSpan? _loopA; private TimeSpan? _loopA;
private TimeSpan? _loopB; private TimeSpan? _loopB;
private static bool _commandLineProcessed = false;
// ----------------------------- // -----------------------------
// Constructor // Constructor
// ----------------------------- // -----------------------------
@ -94,7 +96,24 @@ public sealed partial class PlaybackViewModel : ObservableObject
UpdateLoop(); UpdateLoop();
}); });
if ( !_commandLineProcessed)
{
_commandLineProcessed = true;
ProcessCommandLineArgs();
}
}
private void ProcessCommandLineArgs()
{
var args = Environment.GetCommandLineArgs();
if (args.Length > 1)
{
var filePath = args[1];
if (File.Exists(filePath))
{
Task.Run( () => LoadFile(filePath!));
}
}
} }
private async Task OnPlay() private async Task OnPlay()
@ -107,11 +126,9 @@ public sealed partial class PlaybackViewModel : ObservableObject
Stems = _engine.CurrentSession.StemSet.Stems.Select(GetMixerSettings).ToList() Stems = _engine.CurrentSession.StemSet.Stems.Select(GetMixerSettings).ToList()
}; };
_engine.CurrentSession.Mixer = mixer; await _engine.UpdateMixerAsync(mixer);
_engine.CurrentSession.Speed = new PlaybackSpeedSettings await _engine.UpdatePlaybackSpeedAsync(new PlaybackSpeedSettings { Speed = PlaybackSpeed });
{
Speed = PlaybackSpeed
};
await _engine.PlayAsync(); await _engine.PlayAsync();
} }
@ -133,7 +150,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
partial void OnPlaybackSpeedChanged(float value) partial void OnPlaybackSpeedChanged(float value)
{ {
_engine.CurrentSession?.Speed.Speed = value; Task.Run( async () => await _engine.UpdatePlaybackSpeedAsync(new PlaybackSpeedSettings { Speed = PlaybackSpeed }));
} }
// ----------------------------- // -----------------------------
@ -166,6 +183,12 @@ public sealed partial class PlaybackViewModel : ObservableObject
var file = files[0]; var file = files[0];
await LoadFile(file.Path.LocalPath);
}
private async Task LoadFile(string file)
{
await _engine.StopAsync(); await _engine.StopAsync();
var session = await SplitStems(file); var session = await SplitStems(file);
@ -178,26 +201,27 @@ public sealed partial class PlaybackViewModel : ObservableObject
await UpdateWaveForms(session); await UpdateWaveForms(session);
Bands.Clear(); Bands.Clear();
foreach ( var item in session.StemSet.Stems) foreach (var item in session.StemSet.Stems)
{ {
Bands.Add(new WaveformBandViewModel(item)); var model = new WaveformBandViewModel(item);
Bands.Add(model);
} }
await _engine.LoadSessionAsync(session, new PlaybackProgressReporter(this)); await _engine.LoadSessionAsync(session, new PlaybackProgressReporter(this));
} }
private class PlaybackProgressReporter : IProgressReporter<TimeSpan> private class PlaybackProgressReporter : IProgressReporter<double>
{ {
private readonly PlaybackViewModel _vm; private readonly PlaybackViewModel _vm;
public PlaybackProgressReporter(PlaybackViewModel vm) public PlaybackProgressReporter(PlaybackViewModel vm)
{ {
_vm = vm; _vm = vm;
} }
public Task ReportProgress(TimeSpan progress, CancellationToken ct) public Task ReportProgress(double progress, CancellationToken ct)
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
_vm.CurrentTime = progress; _vm.CurrentTime = TimeSpan.FromMilliseconds(progress * _vm.TotalTime.TotalMilliseconds);
}); });
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -298,7 +322,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
// Stem splitting // Stem splitting
// ----------------------------- // -----------------------------
private async Task<PlaybackSession?> SplitStems(IStorageFile file) private async Task<PlaybackSession?> SplitStems(string file)
{ {
// Enter conversion mode // Enter conversion mode
IsConverting = true; IsConverting = true;
@ -307,7 +331,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
_conversionCts = new CancellationTokenSource(); _conversionCts = new CancellationTokenSource();
var ct = _conversionCts.Token; var ct = _conversionCts.Token;
var outDir = Path.Combine(Path.GetDirectoryName(file.Path.LocalPath)!, "ABStemPlayer"); var outDir = Path.Combine(Path.GetDirectoryName(file)!, "ABStemPlayer");
StemSet? stemSet = null; StemSet? stemSet = null;
@ -321,7 +345,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
return await _separator.SeparateAsync( return await _separator.SeparateAsync(
new StemSeparationRequest new StemSeparationRequest
{ {
SourceFilePath = file.Path.LocalPath, SourceFilePath = file,
OutputDirectory = outDir OutputDirectory = outDir
}, },
new VmProgressReporter(this), new VmProgressReporter(this),

View File

@ -15,5 +15,7 @@ public sealed class RelayCommand : ICommand
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter); public void Execute(object? parameter) => _execute(parameter);
#pragma warning disable CS0067
public event EventHandler? CanExecuteChanged; public event EventHandler? CanExecuteChanged;
#pragma warning restore CS0067
} }

View File

@ -16,13 +16,17 @@ public class BlockingRingBuffer
_ringRead = 0; _ringRead = 0;
} }
public void WriteToOutput(ReadOnlySpan<byte> src, int srcLen, CancellationToken ct) public void Write(ReadOnlySpan<byte> src, int srcLen, CancellationToken ct)
{ {
int written = 0; int written = 0;
while (written < srcLen) while (written < srcLen)
{ {
ct.ThrowIfCancellationRequested(); if ( ct.IsCancellationRequested )
{
Debug.WriteLine("BlockingRingBuffer: Write: operation cancelled.");
return;
}
int remaining = srcLen - written; int remaining = srcLen - written;
@ -33,12 +37,11 @@ public class BlockingRingBuffer
? _ringWrite - _ringRead ? _ringWrite - _ringRead
: _ring.Length - _ringRead + _ringWrite; : _ring.Length - _ringRead + _ringWrite;
free = _ring.Length - used - 1; // leave 1 byte to distinguish full/empty free = _ring.Length - used - 1;
} }
if (free <= 0) if (free <= 0)
{ {
// No room → block until space becomes available
Thread.Sleep(1); Thread.Sleep(1);
continue; continue;
} }
@ -49,7 +52,6 @@ public class BlockingRingBuffer
{ {
int first = Math.Min(toWrite, _ring.Length - _ringWrite); int first = Math.Min(toWrite, _ring.Length - _ringWrite);
// Write first segment
src.Slice(written, first) src.Slice(written, first)
.CopyTo(new Span<byte>(_ring, _ringWrite, first)); .CopyTo(new Span<byte>(_ring, _ringWrite, first));
@ -58,7 +60,6 @@ public class BlockingRingBuffer
int leftover = toWrite - first; int leftover = toWrite - first;
if (leftover > 0) if (leftover > 0)
{ {
// Wrap-around segment
src.Slice(written + first, leftover) src.Slice(written + first, leftover)
.CopyTo(new Span<byte>(_ring, _ringWrite, leftover)); .CopyTo(new Span<byte>(_ring, _ringWrite, leftover));
@ -70,28 +71,56 @@ public class BlockingRingBuffer
} }
} }
public int WaitForOutput(CancellationToken token) public async Task<int> WaitForRoomToWrite(CancellationToken token)
{ {
while (!token.IsCancellationRequested) while (true)
{ {
lock (_ringLock) if (token.IsCancellationRequested)
{ {
var available = (_ringWrite >= _ringRead) Debug.WriteLine("BlockingRingBuffer: WaitForRoomToWrite: operation cancelled.");
? _ringWrite - _ringRead
: _ring.Length - _ringRead + _ringWrite;
if (available > 0)
return available;
}
Thread.Sleep(2);
}
Debug.WriteLine("BlockingRingBuffer: Timeout waiting for output");
return 0; return 0;
} }
public int DrainRing(Span<byte> dest, int maxBytes) lock (_ringLock)
{
var used = (_ringWrite >= _ringRead)
? _ringWrite - _ringRead
: _ring.Length - _ringRead + _ringWrite;
var free = _ring.Length - used - 1;
if (free > 0)
return free;
}
await Task.Delay(2).ConfigureAwait(false);
}
}
public async Task<int> WaitForDataToRead(CancellationToken token)
{
while (true)
{
if (token.IsCancellationRequested)
{
Debug.WriteLine("BlockingRingBuffer: WaitForDataToRead: operation cancelled.");
return 0;
}
lock (_ringLock)
{
var used = (_ringWrite >= _ringRead)
? _ringWrite - _ringRead
: _ring.Length - _ringRead + _ringWrite;
if (used > 0)
return used;
}
await Task.Delay(2).ConfigureAwait(false);
}
}
public int Read(Span<byte> dest, int maxBytes)
{ {
lock (_ringLock) lock (_ringLock)
{ {
@ -120,7 +149,7 @@ public class BlockingRingBuffer
} }
} }
public void ResetRing() public void Reset()
{ {
lock (_ringLock) lock (_ringLock)
{ {

View File

@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
namespace AudioCore.Impl; namespace AudioCore.Impl;
@ -6,12 +7,13 @@ public sealed class FfmpegAudioReader : IAudioReader, IDisposable
{ {
private readonly string _path; private readonly string _path;
// Lazy process wrapper
private Lazy<FfmpegProcess> _process; private Lazy<FfmpegProcess> _process;
// Remember last seek position
private long _pendingSeekSample = 0; private long _pendingSeekSample = 0;
// NEW: internal position tracking (in floats)
private long _pos = 0;
public int SampleRate { get; } public int SampleRate { get; }
public int Channels { get; } public int Channels { get; }
public long TotalSamples { get; } public long TotalSamples { get; }
@ -31,41 +33,55 @@ public sealed class FfmpegAudioReader : IAudioReader, IDisposable
_process = CreateLazyProcess(); _process = CreateLazyProcess();
} }
private Lazy<FfmpegProcess> CreateLazyProcess() => new Lazy<FfmpegProcess>(() => private Lazy<FfmpegProcess> CreateLazyProcess() =>
new Lazy<FfmpegProcess>(() =>
{ {
var startSeconds = (double)_pendingSeekSample / SampleRate; var startSeconds = (double)_pendingSeekSample / SampleRate;
var cmd = var cmd =
"-hide_banner -loglevel error " + "-hide_banner -loglevel error " +
"-nostdin " + "-nostdin " +
$"-ss {startSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} " + $"-i \"{_path}\" " + // input first
$"-i \"{_path}\" " + $"-ss {startSeconds.ToString(CultureInfo.InvariantCulture)} " + // output seek
$"-f f32le -ac {Channels} -ar {SampleRate} pipe:1"; $"-f f32le -ac {Channels} -ar {SampleRate} pipe:1";
var p = new FfmpegProcess( var p = new FfmpegProcess(
name: $"pipe:{_path}", name: $"pipe:{_path}",
commandLine: cmd, commandLine: cmd,
redirectOutput: true, redirectOutput: true,
redirectInput: true); redirectInput: false);
p.StartProcess(); p.StartProcess();
return p; return p;
}); });
public int Read(float[] buffer, int offset, int count) /// <summary>
/// Async float reader using new FfmpegProcess.ReadAsync
/// </summary>
public async Task<int> ReadAsync(Memory<float> buffer, CancellationToken token)
{ {
var proc = _process.Value; // starts process if not started var proc = _process.Value;
if (proc.Stdout is null) if (proc.Stdout is null)
return 0; return 0;
return proc.Read(buffer, offset, count); int readFloats = await proc.ReadAsync(buffer, token).ConfigureAwait(false);
// NEW: update internal position
_pos += readFloats;
return readFloats;
} }
public void Seek(long sampleIndex) public void Seek(long sampleIndex)
{ {
_pendingSeekSample = sampleIndex; _pendingSeekSample = sampleIndex;
// NEW: update internal position (floats)
_pos = sampleIndex * Channels;
DisposeProcessOnly(); DisposeProcessOnly();
_process = CreateLazyProcess(); // new lazy instance _process = CreateLazyProcess();
} }
public void Reset() public void Reset()

View File

@ -1,5 +1,6 @@
using System.Diagnostics; using System.Buffers;
using System.Text.Json; using System.Diagnostics;
using System.Runtime.InteropServices;
namespace AudioCore.Impl; namespace AudioCore.Impl;
@ -9,10 +10,12 @@ public sealed class FfmpegProcess : IDisposable
public Stream? Stdout { get; private set; } public Stream? Stdout { get; private set; }
public Stream? Stdin { get; private set; } public Stream? Stdin { get; private set; }
private string _name; private readonly string _name;
private string _commandLine; private readonly string _commandLine;
private bool _redirectOutput; private readonly bool _redirectOutput;
private bool _redirectInput; private readonly bool _redirectInput;
private Task? _stderrTask;
public FfmpegProcess(string name, string commandLine, bool redirectOutput = true, bool redirectInput = true) public FfmpegProcess(string name, string commandLine, bool redirectOutput = true, bool redirectInput = true)
{ {
@ -41,49 +44,77 @@ public sealed class FfmpegProcess : IDisposable
Proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start ffmpeg process"); Proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start ffmpeg process");
if ( _redirectInput) if (_redirectInput)
Stdin = Proc.StandardInput.BaseStream; Stdin = Proc.StandardInput.BaseStream;
if (_redirectOutput) if (_redirectOutput)
Stdout = Proc.StandardOutput.BaseStream; Stdout = Proc.StandardOutput.BaseStream;
// Start draining stderr immediately _stderrTask = Task.Run(() => DrainStderrAsync(Proc));
_ = Task.Run(() => DrainStderr(Proc));
} }
private void DrainStderr(Process proc) private async Task DrainStderrAsync(Process proc)
{ {
try try
{ {
var reader = proc.StandardError; using var reader = proc.StandardError;
while (true)
// ffmpeg writes short lines, so ReadLine is fine
// If you want zero allocations, use ReadAsync into a rented buffer.
string? line;
while ((line = reader.ReadLine()) != null)
{ {
var line = await reader.ReadLineAsync().ConfigureAwait(false);
if (line == null)
break;
Debug.WriteLine($"{_name}: {line}"); Debug.WriteLine($"{_name}: {line}");
} }
} }
catch catch
{ {
// ignore exceptions during stderr drain, as the process may have exited
} }
} }
public int Read(float[] buffer, int offset, int count) public async Task<int> ReadAsync(Memory<float> buffer, CancellationToken token)
{ {
var bytesNeeded = count * sizeof(float); if (Stdout is null)
var tmp = new byte[bytesNeeded]; return 0;
var readBytes = Stdout!.Read(tmp, 0, bytesNeeded); int maxBytes = buffer.Length * sizeof(float);
byte[] tmp = ArrayPool<byte>.Shared.Rent(maxBytes);
try
{
int readBytes = await Stdout.ReadAsync(tmp.AsMemory(0, maxBytes), token)
.ConfigureAwait(false);
if (readBytes <= 0) if (readBytes <= 0)
return 0; return 0;
Buffer.BlockCopy(tmp, 0, buffer, offset * sizeof(float), readBytes); int floatsRead = readBytes / sizeof(float);
var floatMem = buffer.Slice(0, floatsRead);
return readBytes / sizeof(float); // Copy raw bytes into the caller's float buffer
var floatSpan = floatMem.Span;
var byteSpan = MemoryMarshal.AsBytes(floatSpan);
tmp.AsSpan(0, readBytes).CopyTo(byteSpan);
return floatsRead;
}
finally
{
ArrayPool<byte>.Shared.Return(tmp);
}
} }
public async Task WriteAsync(ReadOnlyMemory<byte> bytes, CancellationToken token)
{
if (Stdin is null)
throw new InvalidOperationException("StdIn is not redirected");
await Stdin.WriteAsync(bytes, token).ConfigureAwait(false);
}
public Task FlushAsync(CancellationToken token)
{
if (Stdin is null)
throw new InvalidOperationException("StdIn is not redirected");
return Stdin.FlushAsync(token);
}
private void DisposeProcessOnly() private void DisposeProcessOnly()
{ {

View File

@ -1,10 +1,9 @@
using System.Collections.Concurrent; using System.Diagnostics;
using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace AudioCore.Impl; namespace AudioCore.Impl;
public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposable public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisposable
{ {
private readonly AudioBufferPool _pool; private readonly AudioBufferPool _pool;
private readonly int _sampleRate; private readonly int _sampleRate;
@ -17,8 +16,9 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
private BlockingRingBuffer _ring; private BlockingRingBuffer _ring;
private float _speed = 1.0f; private float _speed = 1.0f;
private Thread? _readerThread; private Task? _readerTask;
private bool _readerRunning; private CancellationTokenSource? _cts;
private CancellationToken _token;
public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int channels = 2) public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int channels = 2)
{ {
@ -27,35 +27,29 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
_channels = channels; _channels = channels;
var bytesPerSecond = sampleRate * channels * sizeof(float); var bytesPerSecond = sampleRate * channels * sizeof(float);
_ring = new BlockingRingBuffer(1 * bytesPerSecond); _ring = new BlockingRingBuffer( bytesPerSecond * 2);
} }
public void Configure(PlaybackSpeedSettings settings) public async Task Configure(PlaybackSpeedSettings settings, CancellationToken token)
{ {
if (Math.Abs(settings.Speed - _speed) < 0.0001f)
return;
_speed = settings.Speed; _speed = settings.Speed;
if (Math.Abs(_speed - 1.0f) < 0.01f) if ( _cts != null && _ff != null )
{ await DisposeProcess().ConfigureAwait(false);
DisposeProcess();
_ring.ResetRing(); _ring.Reset();
} _token = token;
else
{
RestartProcess();
}
} }
public Task Submit(MixedAudioBlock input) public Task IsReadyToAccept(CancellationToken token) => _ring.WaitForRoomToWrite(token);
public Task Submit(MixedAudioBlock input, CancellationToken token)
{ {
// No-stretch path: enqueue block and signal semaphore // No-stretch path: enqueue block and signal semaphore
if (Math.Abs(_speed - 1.0f) < 0.01f) if (Math.Abs(_speed - 1.0f) < 0.01f)
{ {
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var b = MemoryMarshal.AsBytes(input.Buffer.Span); var b = MemoryMarshal.AsBytes(input.Buffer.Span);
_ring.WriteToOutput(b, b.Length, cts.Token); _ring.Write(b, b.Length, token);
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -72,28 +66,26 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task<TimeStretchedAudioBlock> Receive() public async Task<TimeStretchedAudioBlock> Receive(CancellationToken token)
{ {
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
int available = 0; int available = 0;
while (!cts.IsCancellationRequested) while (!token.IsCancellationRequested)
{ {
available = _ring.WaitForOutput(cts.Token); available = await _ring.WaitForDataToRead(token).ConfigureAwait(false);
if (available > 0) if (available > 0)
break; break;
await Task.Delay(2).ConfigureAwait(false); await Task.Delay(2).ConfigureAwait(false);
} }
if (cts.IsCancellationRequested) if (token.IsCancellationRequested)
return default; return default;
var maxFloats = available / sizeof(float); var maxFloats = available / sizeof(float);
var outBuf = _pool.Rent(maxFloats); var outBuf = _pool.Rent(maxFloats);
var outBytes = MemoryMarshal.AsBytes(outBuf.Span); var outBytes = MemoryMarshal.AsBytes(outBuf.Span);
var readBytes = _ring.DrainRing(outBytes, outBytes.Length); var readBytes = _ring.Read(outBytes, outBytes.Length);
if (readBytes <= 0) if (readBytes <= 0)
{ {
outBuf.Dispose(); outBuf.Dispose();
@ -127,50 +119,48 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
_stdin = _ff.Stdin!; _stdin = _ff.Stdin!;
_stdout = _ff.Stdout!; _stdout = _ff.Stdout!;
_readerRunning = true; Debug.Assert(_cts == null);
_readerThread = new Thread(ReaderLoop) { IsBackground = true };
_readerThread.Start(); _cts = CancellationTokenSource.CreateLinkedTokenSource(_token);
_readerTask = Task.Run(ReaderLoop);
} }
private void RestartProcess() private async Task ReaderLoop()
{ {
DisposeProcess(); Debug.Assert(_cts != null);
_ring.ResetRing();
StartProcess();
}
private void ReaderLoop()
{
var buf = new byte[4096]; var buf = new byte[4096];
try try
{ {
while (_readerRunning) while (!_cts.Token.IsCancellationRequested)
{ {
var read = _stdout!.Read(buf, 0, buf.Length); var read = await _stdout!.ReadAsync(buf, 0, buf.Length, _cts.Token).ConfigureAwait(false);
if (read <= 0) if (read <= 0)
break; break;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); _ring.Write(buf, read, _cts.Token);
_ring.WriteToOutput(buf, read, cts.Token);
} }
} }
catch { } catch { }
} }
private void DisposeProcess() private async Task DisposeProcess()
{ {
_readerRunning = false;
try { _stdout?.Close(); } catch { } try { _stdout?.Close(); } catch { }
try { _stdin?.Close(); } catch { } try { _stdin ?.Close(); } catch { }
try { _ff?.Dispose(); } catch { } try { _ff ?.Dispose(); } catch { }
if (_readerThread != null) if (_readerTask != null)
{ {
try { _readerThread.Join(500); } catch { } Debug.Assert(_cts != null);
_readerThread = null;
_cts.Cancel();
try { await _readerTask.ConfigureAwait(false); } catch { }
_readerTask = null;
_cts.Dispose();
_cts = null;
} }
_ff = null; _ff = null;
@ -178,8 +168,8 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
_stdout = null; _stdout = null;
} }
public void Dispose() public async ValueTask DisposeAsync()
{ {
DisposeProcess(); await DisposeProcess().ConfigureAwait(false);
} }
} }

View File

@ -1,4 +1,4 @@
namespace AudioCore.Impl; using AudioCore.Impl;
public sealed class StemDecoder : IStemDecoder public sealed class StemDecoder : IStemDecoder
{ {
@ -25,28 +25,29 @@ public sealed class StemDecoder : IStemDecoder
Stem.Duration = TimeSpan.FromSeconds((double)reader.TotalSamples / reader.SampleRate); Stem.Duration = TimeSpan.FromSeconds((double)reader.TotalSamples / reader.SampleRate);
} }
public bool TryDecodeNextBlock(out AudioBlock block) public async Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken token)
{ {
var channels = _reader.Channels; int channels = _reader.Channels;
var floatsNeeded = _blockSize * channels; int floatsNeeded = _blockSize * channels;
var buf = _pool.Rent(floatsNeeded); var buf = _pool.Rent(floatsNeeded);
var readFloats = _reader.Read(buf.Samples, 0, floatsNeeded);
// Async read into Memory<float>
int readFloats = await _reader.ReadAsync(buf.Samples.AsMemory(0, floatsNeeded), token)
.ConfigureAwait(false);
if (readFloats <= 0) if (readFloats <= 0)
{ {
buf.Dispose(); buf.Dispose();
block = default; return null;
return false;
} }
buf.Length = readFloats; buf.Length = readFloats;
var pos = _currentSample; long pos = _currentSample;
_currentSample += readFloats / channels; _currentSample += readFloats / channels;
block = new AudioBlock(buf, _reader.SampleRate, channels, pos); return new AudioBlock(buf, _reader.SampleRate, channels, pos);
return true;
} }
public void Seek(long samplePosition) public void Seek(long samplePosition)

View File

@ -1,4 +1,8 @@
namespace AudioCore.Impl; using System.Diagnostics;
using System.Threading;
using NAudio.Wave;
namespace AudioCore.Impl;
public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
{ {
@ -35,18 +39,19 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
private LoopRegion _loopRegion = new(); private LoopRegion _loopRegion = new();
private long _currentFramePosition; private long _decodedFramePosition;
private long _loopStartFrames; private long _loopStartFrames;
private long _loopEndFrames; private long _loopEndFrames;
private bool _isPlaying; private long _outputFramesWritten;
private IProgressReporter<TimeSpan>? _progressReporter; private float _currentSpeed = 1.0f;
private bool IsPlaying => _outputDevice.State == PlaybackState.Playing;
private IProgressReporter<double>? _progressReporter;
private PipelineState? _pipeline; private PipelineState? _pipeline;
private long _pendingSeekFrames; private long _pendingSeekFrames;
private readonly List<AudioBlock> _stemBlocks = new(8);
public StemPlaybackEngine( public StemPlaybackEngine(
IStemDecoderFactory stemDecoderFactory, IStemDecoderFactory stemDecoderFactory,
IAudioOutputDevice outputDevice, IAudioOutputDevice outputDevice,
@ -68,16 +73,18 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
} }
} }
public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter<TimeSpan> progress) public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter<double> progress)
{ {
await StopAsync().ConfigureAwait(false); await StopAsync().ConfigureAwait(false);
await _timeStretchEngine.Configure(session.Speed, CancellationToken.None).ConfigureAwait(false);
lock (_stateLock) lock (_stateLock)
{ {
_session = session; _session = session;
_progressReporter = progress; _progressReporter = progress;
_timeStretchEngine.Configure(session.Speed); _currentSpeed = session.Speed.Speed;
_loopRegion = session.Loop; _loopRegion = session.Loop;
if (_loopRegion.IsEnabled) if (_loopRegion.IsEnabled)
@ -92,7 +99,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
} }
_pendingSeekFrames = 0; _pendingSeekFrames = 0;
_currentFramePosition = 0; _decodedFramePosition = 0;
_outputFramesWritten = 0;
} }
} }
@ -100,9 +108,14 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
{ {
lock (_stateLock) lock (_stateLock)
{ {
if (_isPlaying || _session is null) if (IsPlaying || _session is null)
return Task.CompletedTask; return Task.CompletedTask;
if (_pipeline is not null)
{
return Task.CompletedTask;
}
_pipeline = new PipelineState _pipeline = new PipelineState
{ {
Decoders = _session.StemSet.Stems Decoders = _session.StemSet.Stems
@ -117,12 +130,10 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
d.Seek(_pendingSeekFrames); d.Seek(_pendingSeekFrames);
} }
_currentFramePosition = _pendingSeekFrames; _decodedFramePosition = _pendingSeekFrames;
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
_pipeline.RenderTask = Task.Run(() => _pipeline.RenderTask = Task.Run(() => RenderLoopAsync(_pipeline, _pipeline.Cts.Token));
RenderLoopAsync(_pipeline, _pipeline.Cts!.Token));
_isPlaying = true;
} }
return Task.CompletedTask; return Task.CompletedTask;
@ -132,17 +143,14 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
{ {
lock (_stateLock) lock (_stateLock)
{ {
if (!_isPlaying) if (!IsPlaying)
return Task.CompletedTask; return Task.CompletedTask;
_isPlaying = false; _outputDevice.Pause();
if (_pipeline is not null && _pipeline.OutputStarted) if (_pipeline is not null && _pipeline.OutputStarted)
{
_outputDevice.Stop();
_pipeline.OutputStarted = false; _pipeline.OutputStarted = false;
} }
}
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -153,12 +161,12 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
lock (_stateLock) lock (_stateLock)
{ {
if (!_isPlaying && _pipeline is null) if (!IsPlaying && _pipeline is null)
return; return;
_isPlaying = false; _decodedFramePosition = 0;
_currentFramePosition = 0;
_pendingSeekFrames = 0; _pendingSeekFrames = 0;
_outputFramesWritten = 0;
pipelineToDispose = _pipeline; pipelineToDispose = _pipeline;
_pipeline = null; _pipeline = null;
@ -194,8 +202,37 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
foreach (var d in _pipeline.Decoders) foreach (var d in _pipeline.Decoders)
d.Seek(frameIndex); d.Seek(frameIndex);
_currentFramePosition = frameIndex; _decodedFramePosition = frameIndex;
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
} }
else
{
_decodedFramePosition = frameIndex;
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
}
}
return Task.CompletedTask;
}
public async Task UpdatePlaybackSpeedAsync(PlaybackSpeedSettings settings)
{
lock (_stateLock)
{
_currentSpeed = settings.Speed;
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
}
Debug.Assert(_pipeline?.Cts != null);
await _timeStretchEngine.Configure(settings, _pipeline!.Cts!.Token).ConfigureAwait(false);
}
public Task UpdateMixerAsync(MixerSettings settings)
{
lock (_stateLock)
{
if (_session is not null)
_session.Mixer = settings;
} }
return Task.CompletedTask; return Task.CompletedTask;
@ -233,14 +270,24 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
} }
} }
private async Task RenderLoopAsync(PipelineState pipeline, CancellationToken ct) private bool _decodeCompleted;
private async Task RenderLoopAsync(PipelineState pipeline, CancellationToken token)
{ {
var decodeTask = DecodeLoopAsync(pipeline, ct); if (!pipeline.OutputStarted)
var stretchTask = StretchLoopAsync(pipeline, ct); {
_outputDevice.Start();
pipeline.OutputStarted = true;
}
await Task.WhenAny(decodeTask, stretchTask); _decodeCompleted = false;
var decodeTask = DecodeLoopAsync(pipeline, token);
var stretchTask = StretchLoopAsync(pipeline, token);
// Wait for BOTH to finish naturally
await Task.WhenAll(decodeTask, stretchTask).ConfigureAwait(false);
// When either loop ends, stop output
if (pipeline.OutputStarted) if (pipeline.OutputStarted)
{ {
_outputDevice.Stop(); _outputDevice.Stop();
@ -248,119 +295,162 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
} }
} }
private async Task DecodeLoopAsync(PipelineState pipeline, CancellationToken ct)
private async Task DecodeLoopAsync(PipelineState pipeline, CancellationToken token)
{ {
await Task.Yield();
var stemBlocks = new List<AudioBlock>(6);
try try
{ {
while (!ct.IsCancellationRequested) while (!token.IsCancellationRequested)
{ {
bool playing; bool playing;
MixerSettings? mixerSnapshot; MixerSettings? mixerSnapshot;
IStemDecoder[] decodersSnapshot; IStemDecoder[] decodersSnapshot;
long loopStart, loopEnd; long loopStart, loopEnd;
bool loopEnabled; bool loopEnabled;
IProgressReporter<TimeSpan>? progressReporter;
lock (_stateLock) lock (_stateLock)
{ {
playing = _isPlaying; playing = IsPlaying;
mixerSnapshot = Mixer; mixerSnapshot = Mixer;
decodersSnapshot = pipeline.Decoders; decodersSnapshot = pipeline.Decoders;
loopStart = _loopStartFrames; loopStart = _loopStartFrames;
loopEnd = _loopEndFrames; loopEnd = _loopEndFrames;
loopEnabled = _loopRegion.IsEnabled; loopEnabled = _loopRegion.IsEnabled;
progressReporter = _progressReporter;
} }
if (!playing || mixerSnapshot is null || decodersSnapshot.Length == 0) if (!playing || mixerSnapshot is null || decodersSnapshot.Length == 0)
{ {
await Task.Delay(5, ct); await Task.Delay(5, token).ConfigureAwait(false);
continue; continue;
} }
_stemBlocks.Clear(); if (!await ReadStemsAsync(stemBlocks, decodersSnapshot, token).ConfigureAwait(false))
bool eof = false;
foreach (var decoder in decodersSnapshot)
{ {
if (!decoder.TryDecodeNextBlock(out var block)) DisposeStems(stemBlocks);
{
eof = true;
foreach (var b in _stemBlocks) b.Dispose();
_stemBlocks.Clear();
break; break;
} }
_stemBlocks.Add(block); var mixed = _audioMixer.Mix(stemBlocks, mixerSnapshot);
}
if (eof) DisposeStems(stemBlocks);
{
lock (_stateLock)
_isPlaying = false;
break;
}
var mixed = _audioMixer.Mix(_stemBlocks, mixerSnapshot); await _timeStretchEngine.IsReadyToAccept(token).ConfigureAwait(false);
await _timeStretchEngine.Submit(mixed, token).ConfigureAwait(false);
foreach (var b in _stemBlocks)
b.Dispose();
_stemBlocks.Clear();
await _timeStretchEngine.Submit(mixed);
var progress = TimeSpan.FromSeconds(
(double)_currentFramePosition / _outputDevice.SampleRate);
if (progressReporter != null)
await progressReporter.ReportProgress(progress);
var nextPosition = mixed.SamplePosition + mixed.Frames; var nextPosition = mixed.SamplePosition + mixed.Frames;
if (loopEnabled && loopEnd > loopStart && nextPosition >= loopEnd) if (loopEnabled && loopEnd > loopStart && nextPosition >= loopEnd)
{ {
lock (_stateLock) lock (_stateLock)
{ _decodedFramePosition = loopEnd;
_currentFramePosition = loopEnd;
_isPlaying = false;
}
break; break;
} }
lock (_stateLock) lock (_stateLock)
_currentFramePosition = nextPosition; _decodedFramePosition = nextPosition;
} }
} }
catch { } catch { }
finally
{
_decodeCompleted = true;
}
} }
private async Task StretchLoopAsync(PipelineState pipeline, CancellationToken ct) private static void DisposeStems(List<AudioBlock> stemBlocks)
{ {
foreach (var b in stemBlocks)
b.Dispose();
stemBlocks.Clear();
}
private static async Task<bool> ReadStemsAsync(
List<AudioBlock> stemBlocks,
IStemDecoder[] decodersSnapshot,
CancellationToken ct)
{
DisposeStems(stemBlocks);
foreach (var decoder in decodersSnapshot)
{
var block = await decoder.DecodeNextBlockAsync(ct).ConfigureAwait(false);
if (block is null)
{
DisposeStems(stemBlocks);
return false;
}
stemBlocks.Add(block.Value);
}
return true;
}
private async Task StretchLoopAsync(PipelineState pipeline, CancellationToken token)
{
await Task.Yield();
try try
{ {
while (!ct.IsCancellationRequested) var gotFirstBlock = false;
while (!token.IsCancellationRequested)
{ {
var stretched = await _timeStretchEngine.Receive(); var stretched = await _timeStretchEngine.Receive(token).ConfigureAwait(false);
if (stretched.Buffer == null) if (stretched.Buffer == null)
{ {
await Task.Delay(1, ct); if (_decodeCompleted && gotFirstBlock)
break; // fully drained
await Task.Delay(1, token).ConfigureAwait(false);
continue; continue;
} }
if (!pipeline.OutputStarted) await _outputDevice.IsReadyToAccept(token).ConfigureAwait(false);
{
_outputDevice.Start();
pipeline.OutputStarted = true;
}
_outputDevice.Write(stretched.Buffer.Span); _outputDevice.Write(stretched.Buffer.Span);
} gotFirstBlock = true;
}
catch { } lock (_stateLock)
{
_outputFramesWritten += stretched.Frames;
} }
try
{
long sourceFrames;
lock (_stateLock)
{
sourceFrames = (long)(_outputFramesWritten * _currentSpeed);
}
double progress;
lock (_stateLock)
{
var total = _session?.StemSet.TotalFrames ?? 1L;
progress = (double)sourceFrames / Math.Max(total, 1L);
}
if (_progressReporter != null)
await _progressReporter.ReportProgress(progress).ConfigureAwait(false);
}
catch { }
try { stretched.Dispose(); } catch { }
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Debug.WriteLine($"StemPlaybackEngine: Error in PlaybackLoopAsync: {ex.Message}");
try { pipeline.Cts?.Cancel(); } catch { }
}
}
private long TimeToFrames(TimeSpan time) private long TimeToFrames(TimeSpan time)
{ {
return (long)(time.TotalSeconds * _outputDevice.SampleRate); return (long)(time.TotalSeconds * _outputDevice.SampleRate);

View File

@ -36,11 +36,12 @@ public sealed class StemWaveformService : IStemWaveformService
var count = 0; var count = 0;
// Decode only one block per segment // Decode only one block per segment
if (decoder.TryDecodeNextBlock(out var block)) var block = await decoder.DecodeNextBlockAsync(CancellationToken.None);
if (block != null)
{ {
try try
{ {
var span = block.Span; var span = block.Value.Span;
var channels = decoder.Stem.Channels; var channels = decoder.Stem.Channels;
for (var s = 0; s < span.Length; s++) for (var s = 0; s < span.Length; s++)
@ -53,7 +54,7 @@ public sealed class StemWaveformService : IStemWaveformService
} }
finally finally
{ {
block.Dispose(); block.Value.Dispose();
} }
} }

View File

@ -71,6 +71,8 @@ public sealed class WasapiOutputDevice : IAudioOutputDevice, IDisposable
public void Start() => _out.Play(); public void Start() => _out.Play();
public void Stop() => _out.Stop(); public void Stop() => _out.Stop();
public void Pause() => _out.Pause();
public PlaybackState State => _out.PlaybackState;
public void Write(ReadOnlySpan<float> samples) public void Write(ReadOnlySpan<float> samples)
{ {
@ -101,18 +103,51 @@ public sealed class WasapiOutputDevice : IAudioOutputDevice, IDisposable
} }
private void Send(byte[] bytes) private Lock _lock = new();
public async Task IsReadyToAccept(CancellationToken token)
{ {
// Wait until buffer has enough free space while (!token.IsCancellationRequested)
while (_buffer.BufferedBytes + bytes.Length > _buffer.BufferLength)
{ {
// Sleep a tiny amount to let WASAPI consume data int free = _buffer.BufferLength - _buffer.BufferedBytes;
Thread.Sleep(2); if (free > 0)
return;
await Task.Delay(2, token).ConfigureAwait(false);
}
} }
_buffer.AddSamples(bytes, 0, bytes.Length); private void Send(byte[] bytes)
{
if (_out.PlaybackState != PlaybackState.Playing)
return;
lock (_lock)
{
int offset = 0;
while (offset < bytes.Length)
{
if (_out.PlaybackState != PlaybackState.Playing)
return;
int free = _buffer.BufferLength - _buffer.BufferedBytes;
if (free <= 0)
{
Thread.Sleep(2);
continue;
} }
int toWrite = Math.Min(free, bytes.Length - offset);
_buffer.AddSamples(bytes, offset, toWrite);
offset += toWrite;
}
}
}
public void Dispose() public void Dispose()
{ {
_out.Dispose(); _out.Dispose();

View File

@ -1,3 +1,5 @@
using NAudio.Wave;
namespace AudioCore.Interfaces; namespace AudioCore.Interfaces;
public interface IAudioOutputDevice public interface IAudioOutputDevice
@ -5,8 +7,12 @@ public interface IAudioOutputDevice
int SampleRate { get; } int SampleRate { get; }
int Channels { get; } int Channels { get; }
Task IsReadyToAccept(CancellationToken token);
void Start(); void Start();
void Stop(); void Stop();
void Pause();
PlaybackState State { get; }
// Push interleaved float32 PCM // Push interleaved float32 PCM
void Write(ReadOnlySpan<float> samples); void Write(ReadOnlySpan<float> samples);

View File

@ -8,7 +8,7 @@ public interface IAudioReader : IDisposable
// Read PCM float samples into the provided buffer. // Read PCM float samples into the provided buffer.
// Returns number of samples actually read. // Returns number of samples actually read.
int Read(float[] buffer, int offset, int count); Task<int> ReadAsync(Memory<float> buffer, CancellationToken token);
// Seek to absolute sample index. // Seek to absolute sample index.
void Seek(long sampleIndex); void Seek(long sampleIndex);

View File

@ -4,7 +4,7 @@ public interface IStemDecoder : IDisposable
{ {
StemTrack Stem { get; } StemTrack Stem { get; }
bool TryDecodeNextBlock(out AudioBlock block); Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken token);
void Seek(long samplePosition); void Seek(long samplePosition);

View File

@ -4,7 +4,7 @@ public interface IStemPlaybackEngine
{ {
PlaybackSession? CurrentSession { get; } PlaybackSession? CurrentSession { get; }
Task LoadSessionAsync(PlaybackSession session, IProgressReporter<TimeSpan> progressReporter); Task LoadSessionAsync(PlaybackSession session, IProgressReporter<double> progressReporter);
// Transport // Transport
Task PlayAsync(); Task PlayAsync();
@ -12,6 +12,10 @@ public interface IStemPlaybackEngine
Task StopAsync(); Task StopAsync();
Task SeekAsync(TimeSpan position); Task SeekAsync(TimeSpan position);
// Dynamic controls
Task UpdatePlaybackSpeedAsync(PlaybackSpeedSettings settings);
Task UpdateMixerAsync(MixerSettings settings);
// Loop // Loop
void SetLoop(TimeSpan start, TimeSpan end); void SetLoop(TimeSpan start, TimeSpan end);
void ClearLoop(); void ClearLoop();

View File

@ -8,9 +8,10 @@ public sealed class PlaybackSpeedSettings
public interface ITimeStretchEngine public interface ITimeStretchEngine
{ {
void Configure(PlaybackSpeedSettings settings); Task Configure(PlaybackSpeedSettings settings, CancellationToken token);
// Streaming block processing // Streaming block processing
Task Submit(MixedAudioBlock input); Task IsReadyToAccept(CancellationToken token);
Task<TimeStretchedAudioBlock> Receive(); Task Submit(MixedAudioBlock input, CancellationToken token);
Task<TimeStretchedAudioBlock> Receive(CancellationToken token);
} }

View File

@ -3,6 +3,7 @@ namespace AudioCore.Models;
public sealed class PlaybackSession public sealed class PlaybackSession
{ {
public StemSet StemSet { get; init; } = default!; public StemSet StemSet { get; init; } = default!;
public long TotalFrames => StemSet?.TotalFrames ?? 0;
public MixerSettings Mixer { get; set; } = new() { Stems = [] }; public MixerSettings Mixer { get; set; } = new() { Stems = [] };
public LoopRegion Loop { get; set; } = new(); public LoopRegion Loop { get; set; } = new();
public PlaybackSpeedSettings Speed { get; set; } = new(); public PlaybackSpeedSettings Speed { get; set; } = new();

View File

@ -4,4 +4,6 @@ public sealed class StemSet
{ {
public string OriginalFilePath { get; init; } = string.Empty; public string OriginalFilePath { get; init; } = string.Empty;
public IReadOnlyList<StemTrack> Stems { get; init; } = Array.Empty<StemTrack>(); public IReadOnlyList<StemTrack> Stems { get; init; } = Array.Empty<StemTrack>();
public long TotalFrames => Stems.Max(s => s.TotalFrames);
} }

View File

@ -9,4 +9,6 @@ public sealed class StemTrack
public int SampleRate { get; set; } public int SampleRate { get; set; }
public int Channels { get; set; } public int Channels { get; set; }
public float[] Waveform { get; set; } = []; public float[] Waveform { get; set; } = [];
public long TotalFrames => (long)(Duration.TotalMilliseconds * SampleRate / 1000.0);
} }

View File

@ -15,10 +15,10 @@ public sealed class BlockingRingBuffer_Tests
for (int i = 0; i < src.Length; i++) for (int i = 0; i < src.Length; i++)
src[i] = (byte)i; src[i] = (byte)i;
ring.WriteToOutput(src, src.Length, ct); ring.Write(src, src.Length, ct);
Span<byte> dest = stackalloc byte[100]; Span<byte> dest = stackalloc byte[100];
int read = ring.DrainRing(dest, dest.Length); int read = ring.Read(dest, dest.Length);
Assert.AreEqual(100, read); Assert.AreEqual(100, read);
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)
@ -36,11 +36,11 @@ public sealed class BlockingRingBuffer_Tests
for (int i = 0; i < first.Length; i++) for (int i = 0; i < first.Length; i++)
first[i] = (byte)(i + 1); first[i] = (byte)(i + 1);
ring.WriteToOutput(first, first.Length, ct); ring.Write(first, first.Length, ct);
// Drain a bit to force wrap // Drain a bit to force wrap
Span<byte> tmp = stackalloc byte[10]; Span<byte> tmp = stackalloc byte[10];
int drained = ring.DrainRing(tmp, tmp.Length); int drained = ring.Read(tmp, tmp.Length);
Assert.AreEqual(10, drained); Assert.AreEqual(10, drained);
// Now write again, forcing wrap-around // Now write again, forcing wrap-around
@ -48,11 +48,11 @@ public sealed class BlockingRingBuffer_Tests
for (int i = 0; i < second.Length; i++) for (int i = 0; i < second.Length; i++)
second[i] = (byte)(100 + i); second[i] = (byte)(100 + i);
ring.WriteToOutput(second, second.Length, ct); ring.Write(second, second.Length, ct);
// Drain everything // Drain everything
Span<byte> dest = stackalloc byte[25]; Span<byte> dest = stackalloc byte[25];
int read = ring.DrainRing(dest, dest.Length); int read = ring.Read(dest, dest.Length);
Assert.AreEqual(25, read); Assert.AreEqual(25, read);
@ -71,7 +71,7 @@ public sealed class BlockingRingBuffer_Tests
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
byte[] src = new byte[63]; // fills ring completely (63 bytes free) byte[] src = new byte[63]; // fills ring completely (63 bytes free)
ring.WriteToOutput(src, src.Length, CancellationToken.None); ring.Write(src, src.Length, CancellationToken.None);
bool writeCompleted = false; bool writeCompleted = false;
@ -80,7 +80,7 @@ public sealed class BlockingRingBuffer_Tests
try try
{ {
// This should block until space is freed // This should block until space is freed
ring.WriteToOutput(new byte[10], 10, cts.Token); ring.Write(new byte[10], 10, cts.Token);
writeCompleted = true; writeCompleted = true;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@ -96,7 +96,7 @@ public sealed class BlockingRingBuffer_Tests
// Drain some space // Drain some space
Span<byte> drain = stackalloc byte[20]; Span<byte> drain = stackalloc byte[20];
int drained = ring.DrainRing(drain, drain.Length); int drained = ring.Read(drain, drain.Length);
Assert.IsGreaterThan(0, drained); Assert.IsGreaterThan(0, drained);
// Writer should now complete // Writer should now complete
@ -104,49 +104,49 @@ public sealed class BlockingRingBuffer_Tests
Assert.IsTrue(writeCompleted, "Writer should unblock after draining"); Assert.IsTrue(writeCompleted, "Writer should unblock after draining");
} }
[TestMethod] //[TestMethod]
public void Write_Cancels_WhenFull() //public void Write_Cancels_WhenFull()
{ //{
var ring = new BlockingRingBuffer(32); // var ring = new BlockingRingBuffer(32);
var cts = new CancellationTokenSource(); // var cts = new CancellationTokenSource();
// Fill ring // // Fill ring
ring.WriteToOutput(new byte[31], 31, CancellationToken.None); // ring.WriteToOutput(new byte[31], 31, CancellationToken.None);
bool canceled = false; // bool canceled = false;
var writerThread = new Thread(() => // var writerThread = new Thread(() =>
{ // {
try // try
{ // {
ring.WriteToOutput(new byte[10], 10, cts.Token); // ring.WriteToOutput(new byte[10], 10, cts.Token);
} // }
catch (OperationCanceledException) // catch (OperationCanceledException)
{ // {
canceled = true; // canceled = true;
} // }
}); // });
writerThread.Start(); // writerThread.Start();
Thread.Sleep(50); // Thread.Sleep(50);
cts.Cancel(); // cts.Cancel();
writerThread.Join(); // writerThread.Join();
Assert.IsTrue(canceled, "Writer should throw OperationCanceledException"); // Assert.IsTrue(canceled, "Writer should throw OperationCanceledException");
} //}
[TestMethod] [TestMethod]
public void WaitForOutput_ReturnsAvailable() public async Task WaitForOutput_ReturnsAvailable()
{ {
var ring = new BlockingRingBuffer(128); var ring = new BlockingRingBuffer(128);
var ct = CancellationToken.None; var ct = CancellationToken.None;
byte[] src = new byte[50]; byte[] src = new byte[50];
ring.WriteToOutput(src, src.Length, ct); ring.Write(src, src.Length, ct);
int available = ring.WaitForOutput(ct); int available = await ring.WaitForDataToRead(ct);
Assert.AreEqual(50, available); Assert.AreEqual(50, available);
} }
@ -157,12 +157,12 @@ public sealed class BlockingRingBuffer_Tests
var ring = new BlockingRingBuffer(128); var ring = new BlockingRingBuffer(128);
var ct = CancellationToken.None; var ct = CancellationToken.None;
ring.WriteToOutput(new byte[60], 60, ct); ring.Write(new byte[60], 60, ct);
ring.ResetRing(); ring.Reset();
Span<byte> dest = stackalloc byte[128]; Span<byte> dest = stackalloc byte[128];
int read = ring.DrainRing(dest, dest.Length); int read = ring.Read(dest, dest.Length);
Assert.AreEqual(0, read); Assert.AreEqual(0, read);
} }

View File

@ -1,4 +1,5 @@
using AudioCore.Interfaces; using System.Buffers;
using AudioCore.Interfaces;
namespace AudioCore_Tests; namespace AudioCore_Tests;
@ -20,19 +21,29 @@ public sealed class FakeAudioReader : IAudioReader
_pos = 0; _pos = 0;
} }
public int Read(float[] buffer, int offset, int count) public Task<int> ReadAsync(Memory<float> buffer, CancellationToken token)
{ {
if (_disposed) if (_disposed)
throw new ObjectDisposedException(nameof(FakeAudioReader)); throw new ObjectDisposedException(nameof(FakeAudioReader));
var remaining = _data.Length - _pos; if (token.IsCancellationRequested)
if (remaining <= 0) return Task.FromCanceled<int>(token);
return 0;
var toRead = (int)Math.Min(count, remaining); // How many floats remain?
Array.Copy(_data, _pos, buffer, offset, toRead); long remaining = _data.Length - _pos;
_pos += toRead; if (remaining <= 0)
return toRead; return Task.FromResult(0);
// How many floats can we copy?
int toCopy = (int)Math.Min(buffer.Length, remaining);
// Copy from backing array into caller's buffer
_data.AsMemory((int)_pos, toCopy).CopyTo(buffer);
// Advance position
_pos += toCopy;
return Task.FromResult(toCopy);
} }
public void Seek(long samplePosition) public void Seek(long samplePosition)

View File

@ -28,37 +28,34 @@ public sealed class FfmpegAudioReader_Tests
} }
[TestMethod] [TestMethod]
public void Reader_Reads_Some_Samples() public async Task Reader_Reads_Some_Samples()
{ {
using var reader = new FfmpegAudioReader(_inputPath); using var reader = new FfmpegAudioReader(_inputPath);
var buf = new float[44100]; // 0.5 sec stereo = 22050 frames var buf = new float[44100];
var read = reader.Read(buf, 0, buf.Length); var read = await reader.ReadAsync(buf.AsMemory(), CancellationToken.None);
Assert.IsGreaterThan(0, read, "Reader returned no samples"); Assert.IsGreaterThan(0, read, "Reader returned no samples");
Assert.IsLessThanOrEqualTo(buf.Length, read); Assert.IsLessThanOrEqualTo(buf.Length, read);
} }
[TestMethod] [TestMethod]
public void Reader_Seek_Works() public async Task Reader_Seek_Works()
{ {
using var reader = new FfmpegAudioReader(_inputPath); using var reader = new FfmpegAudioReader(_inputPath);
var buf1 = new float[44100]; var buf1 = new float[44100];
var buf2 = new float[44100]; var buf2 = new float[44100];
// Read from start var r1 = await reader.ReadAsync(buf1.AsMemory(), CancellationToken.None);
var r1 = reader.Read(buf1, 0, buf1.Length);
Assert.IsGreaterThan(0, r1); Assert.IsGreaterThan(0, r1);
// Seek to 1 second reader.Seek(reader.SampleRate*5);
reader.Seek(reader.SampleRate);
var r2 = reader.Read(buf2, 0, buf2.Length); var r2 = await reader.ReadAsync(buf2.AsMemory(), CancellationToken.None);
Assert.IsGreaterThan(0, r2); Assert.IsGreaterThan(0, r2);
// Buffers should differ bool identical = true;
var identical = true;
for (var i = 0; i < Math.Min(r1, r2); i++) for (var i = 0; i < Math.Min(r1, r2); i++)
{ {
if (buf1[i] != buf2[i]) if (buf1[i] != buf2[i])
@ -72,23 +69,22 @@ public sealed class FfmpegAudioReader_Tests
} }
[TestMethod] [TestMethod]
public void Reader_Reset_Works() public async Task Reader_Reset_Works()
{ {
using var reader = new FfmpegAudioReader(_inputPath); using var reader = new FfmpegAudioReader(_inputPath);
var buf1 = new float[44100]; var buf1 = new float[44100];
var buf2 = new float[44100]; var buf2 = new float[44100];
var r1 = reader.Read(buf1, 0, buf1.Length); var r1 = await reader.ReadAsync(buf1.AsMemory(), CancellationToken.None);
Assert.IsGreaterThan(0, r1); Assert.IsGreaterThan(0, r1);
reader.Reset(); reader.Reset();
var r2 = reader.Read(buf2, 0, buf2.Length); var r2 = await reader.ReadAsync(buf2.AsMemory(), CancellationToken.None);
Assert.IsGreaterThan(0, r2); Assert.IsGreaterThan(0, r2);
// After reset, buffers should match again bool identical = true;
var identical = true;
for (var i = 0; i < Math.Min(r1, r2); i++) for (var i = 0; i < Math.Min(r1, r2); i++)
{ {
if (buf1[i] != buf2[i]) if (buf1[i] != buf2[i])
@ -109,15 +105,13 @@ public sealed class FfmpegAudioReader_Tests
} }
[TestMethod] [TestMethod]
public void Reader_Can_Read_Flac_File() public async Task Reader_Can_Read_Flac_File()
{ {
// Arrange
var baseDir = AppContext.BaseDirectory; var baseDir = AppContext.BaseDirectory;
var flacPath = Path.Combine(baseDir, "Data", "test_input_converted.flac"); var flacPath = Path.Combine(baseDir, "Data", "test_input_converted.flac");
try try
{ {
// Convert MP3 → FLAC using FFmpeg
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
FileName = "ffmpeg", FileName = "ffmpeg",
@ -131,39 +125,33 @@ public sealed class FfmpegAudioReader_Tests
using (var p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start FFmpeg process")) using (var p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start FFmpeg process"))
{ {
// Start draining stderr immediately
_ = Task.Run(() => DrainStderr(p)); _ = Task.Run(() => DrainStderr(p));
p.WaitForExit();
p!.WaitForExit();
Assert.AreEqual(0, p.ExitCode, "FFmpeg failed to convert MP3 to FLAC"); Assert.AreEqual(0, p.ExitCode, "FFmpeg failed to convert MP3 to FLAC");
} }
Assert.IsTrue(File.Exists(flacPath), "FLAC file was not created"); Assert.IsTrue(File.Exists(flacPath), "FLAC file was not created");
// Act
using var reader = new FfmpegAudioReader(flacPath); using var reader = new FfmpegAudioReader(flacPath);
// Assert basic properties
Assert.AreEqual(44100, reader.SampleRate); Assert.AreEqual(44100, reader.SampleRate);
Assert.AreEqual(2, reader.Channels); Assert.AreEqual(2, reader.Channels);
Assert.IsGreaterThan(0, reader.TotalSamples); Assert.IsGreaterThan(0, reader.TotalSamples);
// Read some samples
var buf = new float[44100]; var buf = new float[44100];
var read = reader.Read(buf, 0, buf.Length); var read = await reader.ReadAsync(buf.AsMemory(), CancellationToken.None);
Assert.IsGreaterThan(0, read, "FLAC reader returned no samples"); Assert.IsGreaterThan(0, read);
Assert.IsLessThanOrEqualTo(buf.Length, read); Assert.IsLessThanOrEqualTo(buf.Length, read);
// Seek test reader.Seek(reader.SampleRate);
reader.Seek(reader.SampleRate); // 1 second
var buf2 = new float[44100]; var buf2 = new float[44100];
var read2 = reader.Read(buf2, 0, buf2.Length); var read2 = await reader.ReadAsync(buf2.AsMemory(), CancellationToken.None);
Assert.IsGreaterThan(0, read2); Assert.IsGreaterThan(0, read2);
// Buffers should differ after seek bool identical = true;
var identical = true;
for (var i = 0; i < Math.Min(read, read2); i++) for (var i = 0; i < Math.Min(read, read2); i++)
{ {
if (buf[i] != buf2[i]) if (buf[i] != buf2[i])
@ -175,15 +163,14 @@ public sealed class FfmpegAudioReader_Tests
Assert.IsFalse(identical, "Seek did not change decoded FLAC samples"); Assert.IsFalse(identical, "Seek did not change decoded FLAC samples");
// Reset test
reader.Reset(); reader.Reset();
var buf3 = new float[44100]; var buf3 = new float[44100];
var read3 = reader.Read(buf3, 0, buf3.Length); var read3 = await reader.ReadAsync(buf3.AsMemory(), CancellationToken.None);
Assert.IsGreaterThan(0, read3); Assert.IsGreaterThan(0, read3);
// After reset, buf3 should match buf bool matchAfterReset = true;
var matchAfterReset = true;
for (var i = 0; i < Math.Min(read, read3); i++) for (var i = 0; i < Math.Min(read, read3); i++)
{ {
if (buf[i] != buf3[i]) if (buf[i] != buf3[i])
@ -197,13 +184,12 @@ public sealed class FfmpegAudioReader_Tests
} }
finally finally
{ {
// Cleanup even if test fails
try try
{ {
if (File.Exists(flacPath)) if (File.Exists(flacPath))
File.Delete(flacPath); File.Delete(flacPath);
} }
catch { /* swallow */ } catch { }
} }
} }
@ -212,9 +198,6 @@ public sealed class FfmpegAudioReader_Tests
try try
{ {
var reader = proc.StandardError; var reader = proc.StandardError;
// ffmpeg writes short lines, so ReadLine is fine
// If you want zero allocations, use ReadAsync into a rented buffer.
string? line; string? line;
while ((line = reader.ReadLine()) != null) while ((line = reader.ReadLine()) != null)
{ {
@ -226,5 +209,4 @@ public sealed class FfmpegAudioReader_Tests
Debug.WriteLine(ex.ToString()); Debug.WriteLine(ex.ToString());
} }
} }
} }

View File

@ -35,7 +35,7 @@ public sealed class Pipeline_Integration_Tests
} }
[TestMethod] [TestMethod]
public void FullPipeline_Decoder_Mixer_Encoder_Works() public async Task FullPipeline_Decoder_Mixer_Encoder_Works()
{ {
var pool = new AudioBufferPool(); var pool = new AudioBufferPool();
var readerFactory = new FfmpegAudioReaderFactory(); var readerFactory = new FfmpegAudioReaderFactory();
@ -79,7 +79,7 @@ public sealed class Pipeline_Integration_Tests
using var ff = Process.Start(psi); using var ff = Process.Start(psi);
var stdin = ff!.StandardInput.BaseStream; var stdin = ff!.StandardInput.BaseStream;
// Start draining stderr immediately
_ = Task.Run(() => DrainStderr(ff)); _ = Task.Run(() => DrainStderr(ff));
var running = true; var running = true;
@ -90,7 +90,8 @@ public sealed class Pipeline_Integration_Tests
foreach (var d in decoders) foreach (var d in decoders)
{ {
if (!d.TryDecodeNextBlock(out var block)) var block = await d.DecodeNextBlockAsync(CancellationToken.None);
if (block is null)
{ {
foreach (var b in blocks) foreach (var b in blocks)
b.Dispose(); b.Dispose();
@ -99,7 +100,7 @@ public sealed class Pipeline_Integration_Tests
break; break;
} }
blocks.Add(block); blocks.Add(block.Value);
} }
if (!running) if (!running)
@ -107,9 +108,12 @@ public sealed class Pipeline_Integration_Tests
var mixed = mixer.Mix(blocks, settings); var mixed = mixer.Mix(blocks, settings);
var span = mixed.Buffer.Span; // Convert float → bytes
var bytes = MemoryMarshal.AsBytes(span); ReadOnlySpan<float> span = mixed.Buffer.Span;
stdin.Write(bytes); ReadOnlyMemory<byte> bytes = MemoryMarshal.AsBytes(span).ToArray();
await stdin.WriteAsync(bytes, CancellationToken.None);
await stdin.FlushAsync(CancellationToken.None);
mixed.Dispose(); mixed.Dispose();
foreach (var b in blocks) foreach (var b in blocks)
@ -126,7 +130,7 @@ public sealed class Pipeline_Integration_Tests
using var verify = new FfmpegAudioReader(outFlac); using var verify = new FfmpegAudioReader(outFlac);
var buf = new float[4096]; var buf = new float[4096];
var read = verify.Read(buf, 0, buf.Length); var read = await verify.ReadAsync(buf.AsMemory(), CancellationToken.None);
Assert.IsGreaterThan(0, read, "FLAC output is not decodable"); Assert.IsGreaterThan(0, read, "FLAC output is not decodable");
} }
@ -136,9 +140,6 @@ public sealed class Pipeline_Integration_Tests
try try
{ {
var reader = proc.StandardError; var reader = proc.StandardError;
// ffmpeg writes short lines, so ReadLine is fine
// If you want zero allocations, use ReadAsync into a rented buffer.
string? line; string? line;
while ((line = reader.ReadLine()) != null) while ((line = reader.ReadLine()) != null)
{ {

View File

@ -7,18 +7,21 @@ namespace AudioCore_Tests;
public sealed class StemDecoder_Tests public sealed class StemDecoder_Tests
{ {
[TestMethod] [TestMethod]
public void TryDecodeNextBlock_ReturnsBlock() public async Task DecodeNextBlockAsync_ReturnsBlock()
{ {
var pool = new AudioBufferPool(); var pool = new AudioBufferPool();
var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray(); // 1 sec stereo var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray();
var reader = new FakeAudioReader(samples, 48000, 2); var reader = new FakeAudioReader(samples, 48000, 2);
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" }; var stem = new StemTrack { Name = "test", FilePath = "file.wav" };
var decoder = new StemDecoder(reader, pool, stem, blockSize: 1024); var decoder = new StemDecoder(reader, pool, stem, blockSize: 1024);
var ok = decoder.TryDecodeNextBlock(out var block); var nullableBlock = await decoder.DecodeNextBlockAsync(CancellationToken.None);
Assert.IsNotNull(nullableBlock);
var block = nullableBlock.Value;
Assert.IsTrue(ok);
Assert.AreEqual(1024, block.Frames); Assert.AreEqual(1024, block.Frames);
Assert.AreEqual(0, block.Position); Assert.AreEqual(0, block.Position);
Assert.AreEqual(48000, block.SampleRate); Assert.AreEqual(48000, block.SampleRate);
@ -28,101 +31,99 @@ public sealed class StemDecoder_Tests
} }
[TestMethod] [TestMethod]
public void TryDecodeNextBlock_AdvancesPosition() public async Task DecodeNextBlockAsync_AdvancesPosition()
{ {
var pool = new AudioBufferPool(); var pool = new AudioBufferPool();
var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray(); var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray();
var reader = new FakeAudioReader(samples); var reader = new FakeAudioReader(samples);
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" }; var stem = new StemTrack { Name = "test", FilePath = "file.wav" };
var decoder = new StemDecoder(reader, pool, stem, blockSize: 1000); var decoder = new StemDecoder(reader, pool, stem, blockSize: 1000);
decoder.TryDecodeNextBlock(out var b1); var b1 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
decoder.TryDecodeNextBlock(out var b2); var b2 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
Assert.AreEqual(0, b1.Position); Assert.IsNotNull(b1);
Assert.AreEqual(1000, b2.Position); Assert.IsNotNull(b2);
b1.Dispose(); Assert.AreEqual(0, b1!.Value.Position);
b2.Dispose(); Assert.AreEqual(1000, b2!.Value.Position);
b1.Value.Dispose();
b2.Value.Dispose();
} }
[TestMethod] [TestMethod]
public void Seek_MovesReaderAndDecoderPosition() public async Task Seek_MovesReaderAndDecoderPosition()
{ {
var pool = new AudioBufferPool(); var pool = new AudioBufferPool();
var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray(); var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray();
var reader = new FakeAudioReader(samples); var reader = new FakeAudioReader(samples);
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" }; var stem = new StemTrack { Name = "test", FilePath = "file.wav" };
var decoder = new StemDecoder(reader, pool, stem, blockSize: 500); var decoder = new StemDecoder(reader, pool, stem, blockSize: 500);
decoder.Seek(2000); // sample position decoder.Seek(2000);
decoder.TryDecodeNextBlock(out var block); var block = await decoder.DecodeNextBlockAsync(CancellationToken.None);
Assert.AreEqual(2000, block.Position); Assert.IsNotNull(block);
Assert.AreEqual(500, block.Frames); Assert.AreEqual(2000, block!.Value.Position);
Assert.AreEqual(500, block.Value.Frames);
block.Dispose(); block.Value.Dispose();
} }
[TestMethod] [TestMethod]
public void Reset_ReturnsToStart() public async Task Reset_ReturnsToStart()
{ {
var pool = new AudioBufferPool(); var pool = new AudioBufferPool();
var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray(); var samples = Enumerable.Range(0, 48000).Select(i => (float)i).ToArray();
var reader = new FakeAudioReader(samples); var reader = new FakeAudioReader(samples);
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" }; var stem = new StemTrack { Name = "test", FilePath = "file.wav" };
var decoder = new StemDecoder(reader, pool, stem, blockSize: 500); var decoder = new StemDecoder(reader, pool, stem, blockSize: 500);
decoder.TryDecodeNextBlock(out var b1); var b1 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
decoder.Reset(); decoder.Reset();
decoder.TryDecodeNextBlock(out var b2); var b2 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
Assert.AreEqual(0, b2.Position); Assert.IsNotNull(b2);
Assert.AreEqual(0, b2!.Value.Position);
b1.Dispose(); b1!.Value.Dispose();
b2.Dispose(); b2!.Value.Dispose();
} }
[TestMethod] [TestMethod]
public void TryDecodeNextBlock_ReturnsFalseAtEnd() public async Task DecodeNextBlockAsync_ReturnsNullAtEnd()
{ {
var pool = new AudioBufferPool(); var pool = new AudioBufferPool();
var samples = new float[2000]; // small buffer var samples = new float[2000];
var reader = new FakeAudioReader(samples, 48000, 2); var reader = new FakeAudioReader(samples, 48000, 2);
var stem = new StemTrack { Name = "test", FilePath = "file.wav" }; var stem = new StemTrack { Name = "test", FilePath = "file.wav" };
var decoder = new StemDecoder(reader, pool, stem, blockSize: 1024); var decoder = new StemDecoder(reader, pool, stem, blockSize: 1024);
// First block: should succeed var b1 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
Assert.IsTrue(decoder.TryDecodeNextBlock(out var b1)); Assert.IsNotNull(b1);
Assert.IsNotNull(b1.Buffer); b1!.Value.Dispose();
b1.Dispose();
// Second block: may succeed or partially succeed var b2 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
decoder.TryDecodeNextBlock(out var b2); if (b2 != null)
if (b2.Buffer != null) b2!.Value.Dispose();
b2.Dispose();
// Third block: MUST fail var b3 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
var ok = decoder.TryDecodeNextBlock(out var b3); Assert.IsNull(b3, "Decoder should return null at end of stream");
Assert.IsFalse(ok, "Decoder should return false at end of stream");
// IMPORTANT: do NOT touch b3.Buffer — it is null
} }
[TestMethod] [TestMethod]
public void Dispose_DisposesReader() public void Dispose_DisposesReader()
{ {
var pool = new AudioBufferPool(); var pool = new AudioBufferPool();
var samples = new float[1000]; var samples = new float[1000];
var reader = new FakeAudioReader(samples); var reader = new FakeAudioReader(samples);
var stem = new StemTrack{ Name = "test", FilePath = "file.wav" }; var stem = new StemTrack { Name = "test", FilePath = "file.wav" };
var decoder = new StemDecoder(reader, pool, stem); var decoder = new StemDecoder(reader, pool, stem);
@ -130,11 +131,11 @@ public sealed class StemDecoder_Tests
try try
{ {
// This must throw // FakeAudioReader throws ObjectDisposedException when used after Dispose
reader.Read(new float[10], 0, 10); var _ = reader.ReadAsync(new float[10].AsMemory(), CancellationToken.None).Result;
Assert.Fail("Expected ObjectDisposedException"); Assert.Fail("Expected ObjectDisposedException");
} }
catch(AssertFailedException ) catch (AssertFailedException)
{ {
throw; throw;
} }
@ -143,6 +144,4 @@ public sealed class StemDecoder_Tests
Assert.IsInstanceOfType(ex, typeof(ObjectDisposedException)); Assert.IsInstanceOfType(ex, typeof(ObjectDisposedException));
} }
} }
} }

View File

@ -1,6 +1,7 @@
using AudioCore.Interfaces; using AudioCore.Impl;
using AudioCore.Interfaces;
using AudioCore.Models; using AudioCore.Models;
using AudioCore.Impl; using NAudio.Wave;
namespace AudioCore_Tests; namespace AudioCore_Tests;
@ -38,16 +39,16 @@ public sealed class StemPlaybackEngine_Tests
} }
} }
public bool TryDecodeNextBlock(out AudioBlock block) public Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken token)
{ {
AudioBlock? block;
if (_blocks.Count == 0) if (_blocks.Count == 0)
{ {
block = default; return Task.FromResult<AudioBlock?>(null);
return false;
} }
block = _blocks.Dequeue(); block = _blocks.Dequeue();
return true; return Task.FromResult<AudioBlock?>(block);
} }
public void Seek(long samplePosition) public void Seek(long samplePosition)
@ -105,18 +106,19 @@ public sealed class StemPlaybackEngine_Tests
{ {
private MixedAudioBlock _lastInput; private MixedAudioBlock _lastInput;
public void Configure(PlaybackSpeedSettings settings) public Task Configure(PlaybackSpeedSettings settings, CancellationToken token)
{ {
// no-op for tests // no-op for tests
return Task.CompletedTask;
} }
public Task Submit(MixedAudioBlock input) public Task Submit(MixedAudioBlock input, CancellationToken token)
{ {
_lastInput = input; _lastInput = input;
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<TimeStretchedAudioBlock> Receive() public Task<TimeStretchedAudioBlock> Receive(CancellationToken token)
{ {
if (_lastInput.Buffer == null) if (_lastInput.Buffer == null)
return Task.FromResult(default(TimeStretchedAudioBlock)); return Task.FromResult(default(TimeStretchedAudioBlock));
@ -130,6 +132,8 @@ public sealed class StemPlaybackEngine_Tests
_lastInput = default; _lastInput = default;
return Task.FromResult(block); return Task.FromResult(block);
} }
Task ITimeStretchEngine.IsReadyToAccept(CancellationToken token) => Task.CompletedTask;
} }
private sealed class MockOutput : IAudioOutputDevice private sealed class MockOutput : IAudioOutputDevice
@ -141,6 +145,8 @@ public sealed class StemPlaybackEngine_Tests
public int LastWriteSamples { get; private set; } public int LastWriteSamples { get; private set; }
public bool Started { get; private set; } public bool Started { get; private set; }
public Task IsReadyToAccept(CancellationToken token) => Task.CompletedTask;
public void Start() public void Start()
{ {
Started = true; Started = true;
@ -150,6 +156,8 @@ public sealed class StemPlaybackEngine_Tests
{ {
Started = false; Started = false;
} }
public void Pause() => Started = false;
public PlaybackState State => Started ? PlaybackState.Playing : PlaybackState.Stopped;
public void Write(ReadOnlySpan<float> samples) public void Write(ReadOnlySpan<float> samples)
{ {
@ -203,9 +211,9 @@ public sealed class StemPlaybackEngine_Tests
}; };
} }
private class DummyProgressReporter : IProgressReporter<TimeSpan> private class DummyProgressReporter : IProgressReporter<double>
{ {
public Task ReportProgress(TimeSpan value, CancellationToken ct) public Task ReportProgress(double value, CancellationToken ct)
=> Task.CompletedTask; => Task.CompletedTask;
} }
@ -330,7 +338,7 @@ public sealed class StemPlaybackEngine_Tests
await engine.LoadSessionAsync(session, new DummyProgressReporter()); await engine.LoadSessionAsync(session, new DummyProgressReporter());
await engine.PlayAsync(); await engine.PlayAsync();
await Task.Delay(50); await Task.Delay(TimeSpan.FromSeconds(3));
await engine.StopAsync(); await engine.StopAsync();

View File

@ -48,16 +48,16 @@ public sealed class StemWaveformService_Tests
} }
} }
public bool TryDecodeNextBlock(out AudioBlock block) public Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken ct)
{ {
if (_blocks.Count == 0) if (ct.IsCancellationRequested)
{ return Task.FromCanceled<AudioBlock?>(ct);
block = default;
return false;
}
block = _blocks.Dequeue(); if (_blocks.Count == 0)
return true; return Task.FromResult<AudioBlock?>(null);
var block = _blocks.Dequeue();
return Task.FromResult<AudioBlock?>(block);
} }
public void Seek(long samplePosition) public void Seek(long samplePosition)
@ -75,6 +75,7 @@ public sealed class StemWaveformService_Tests
} }
} }
private string GetTestInputPath() private string GetTestInputPath()
{ {
var baseDir = AppDomain.CurrentDomain.BaseDirectory; var baseDir = AppDomain.CurrentDomain.BaseDirectory;

View File

@ -30,12 +30,12 @@ public sealed class TimeStretchEngine_Tests
[TestMethod] [TestMethod]
public async Task Process_Returns_Output_For_Speed_1() public async Task Process_Returns_Output_For_Speed_1()
{ {
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2); await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
var input = MakeBlock(5000); var input = MakeBlock(5000);
await engine.Submit(input); await engine.Submit(input, CancellationToken.None);
var output = await engine.Receive(); var output = await engine.Receive(CancellationToken.None);
Assert.IsGreaterThan(0, output.Frames); Assert.IsGreaterThan(0, output.Frames);
Assert.AreEqual(2, output.Channels); Assert.AreEqual(2, output.Channels);
@ -55,101 +55,204 @@ public sealed class TimeStretchEngine_Tests
[TestMethod] [TestMethod]
public async Task Process_Respects_Speed_Increase() public async Task Process_Respects_Speed_Increase()
{ {
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2); await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
var input = MakeBlock(1000); using var input = MakeBlock(44100);
using var cts = new CancellationTokenSource();
for (var i = 0; i < 25; i++)
await engine.Submit(input);
// -----------------------------
// Phase 1: speed = 1.0
// -----------------------------
var normalFrames = 0; var normalFrames = 0;
const int NumberOfIterations = 5;
var submitTask1 = Task.Run(async () =>
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
for (var i = 0; i < NumberOfIterations; i++)
await engine.Submit(input, ts.Token);
});
var receiveTask1 = Task.Run(async () =>
{
while (true) while (true)
{ {
using var data = await engine.Receive(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
normalFrames += data.Frames; using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
using var data = await engine.Receive(ts.Token);
if (data.Buffer == null) if (data.Buffer == null)
break; break;
normalFrames += data.Frames;
} }
});
await Task.WhenAll(submitTask1, receiveTask1);
engine.Configure(new PlaybackSpeedSettings { Speed = 1.5f }); Debug.WriteLine($"Normal frames: {normalFrames}");
// -----------------------------
for (var i = 0; i < 25; i++) // Phase 2: speed = 1.5
await engine.Submit(input); // -----------------------------
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.5f }, cts.Token);
var fasterFrames = 0; var fasterFrames = 0;
var submitTask2 = Task.Run(async () =>
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
for (var i = 0; i < NumberOfIterations; i++)
await engine.Submit(input, ts.Token);
});
var receiveTask2 = Task.Run(async () =>
{
while (true) while (true)
{ {
using var data = await engine.Receive(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
fasterFrames += data.Frames; using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
using var data = await engine.Receive(ts.Token);
if (data.Buffer == null) if (data.Buffer == null)
break; break;
fasterFrames += data.Frames;
}
});
await Task.WhenAll(submitTask2, receiveTask2);
Debug.WriteLine($"Faster frames: {fasterFrames}");
// -----------------------------
// Assertion
// -----------------------------
Assert.IsLessThan(fasterFrames, normalFrames);
cts.Cancel();
} }
Assert.IsLessThanOrEqualTo(normalFrames, fasterFrames);
input.Dispose();
}
[TestMethod] [TestMethod]
public async Task Process_Respects_Speed_Decrease() public async Task Process_Respects_Speed_Decrease()
{ {
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2); await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
var input = MakeBlock(1000); using var input = MakeBlock(44100);
using var cts = new CancellationTokenSource();
for (var i = 0; i < 25; i++)
await engine.Submit(input);
// -----------------------------
// Phase 1: speed = 1.0
// -----------------------------
var normalFrames = 0; var normalFrames = 0;
const int NumberOfIterations = 5;
var submitTask1 = Task.Run(async () =>
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
for (var i = 0; i < NumberOfIterations; i++)
await engine.Submit(input, ts.Token);
});
var receiveTask1 = Task.Run(async () =>
{
while (true) while (true)
{ {
using var data = await engine.Receive(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
normalFrames += data.Frames; using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
using var data = await engine.Receive(ts.Token);
if (data.Buffer == null) if (data.Buffer == null)
break; break;
normalFrames += data.Frames;
} }
});
engine.Configure(new PlaybackSpeedSettings { Speed = 0.5f }); await Task.WhenAll(submitTask1, receiveTask1);
for (var i = 0; i < 25; i++) Debug.WriteLine($"Normal frames: {normalFrames}");
await engine.Submit(input);
// -----------------------------
// Phase 2: speed = 0.5
// -----------------------------
await engine.Configure(new PlaybackSpeedSettings { Speed = 0.5f }, cts.Token);
var slowerFrames = 0; var slowerFrames = 0;
var submitTask2 = Task.Run(async () =>
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
for (var i = 0; i < NumberOfIterations; i++)
await engine.Submit(input, ts.Token);
});
var receiveTask2 = Task.Run(async () =>
{
while (true) while (true)
{ {
using var data = await engine.Receive(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
slowerFrames += data.Frames; using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
using var data = await engine.Receive(ts.Token);
if (data.Buffer == null) if (data.Buffer == null)
break; break;
slowerFrames += data.Frames;
} }
});
Assert.IsGreaterThanOrEqualTo(normalFrames, slowerFrames); await Task.WhenAll(submitTask2, receiveTask2);
input.Dispose(); Debug.WriteLine($"Slower frames: {slowerFrames}");
// -----------------------------
// Assertion
// -----------------------------
Assert.IsLessThan(slowerFrames, normalFrames);
cts.Cancel();
} }
[TestMethod] [TestMethod]
public async Task Engine_Restarts_On_Speed_Change() public async Task Engine_Restarts_On_Speed_Change()
{ {
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2); await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
using var cts = new CancellationTokenSource();
var input = MakeBlock(100); await engine.Configure(new PlaybackSpeedSettings { Speed = 1f }, cts.Token);
await engine.Submit(input); var input = MakeBlock(44100);
var before = await engine.Receive();
engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f }); await engine.Submit(input, cts.Token);
var before = await engine.Receive(cts.Token);
await engine.Submit(input); await engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f }, cts.Token);
var after = await engine.Receive();
Assert.AreEqual(0, after.Frames); await engine.Submit(input, cts.Token);
var after = await engine.Receive(cts.Token);
Assert.AreNotEqual(0, before.Frames);
Assert.AreNotEqual(0, after.Frames);
Assert.AreNotEqual(before.Frames, after.Frames);
cts.Cancel();
} }
[TestMethod] [TestMethod]
public void Dispose_Kills_FFmpeg() public async Task Dispose_Kills_FFmpeg()
{ {
var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2); var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
using var cts = new CancellationTokenSource();
var ffField = typeof(RubberBandTimeStretchEngine) var ffField = typeof(RubberBandTimeStretchEngine)
.GetField("_ff", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); .GetField("_ff", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
@ -157,7 +260,7 @@ public sealed class TimeStretchEngine_Tests
var ff = (Process?)ffField!.GetValue(engine); var ff = (Process?)ffField!.GetValue(engine);
var pid = ff?.Id ?? -1; var pid = ff?.Id ?? -1;
engine.Dispose(); await engine.DisposeAsync();
var exists = Process.GetProcesses().Any(p => var exists = Process.GetProcesses().Any(p =>
{ {
@ -166,5 +269,6 @@ public sealed class TimeStretchEngine_Tests
}); });
Assert.IsFalse(exists); Assert.IsFalse(exists);
cts.Cancel();
} }
} }

View File

@ -46,6 +46,7 @@ public sealed class WasapiOutputDevice_Tests
public void Write_Adds_Bytes_To_Buffer() public void Write_Adds_Bytes_To_Buffer()
{ {
var fake = new FakeWasapiOut(); var fake = new FakeWasapiOut();
fake.Play();
var dev = new WasapiOutputDevice(new ByteBufferPool(), fake); var dev = new WasapiOutputDevice(new ByteBufferPool(), fake);
float[] samples = { 1f, -1f, 0.5f, -0.5f }; float[] samples = { 1f, -1f, 0.5f, -0.5f };

View File

@ -3,19 +3,19 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="12.0.3" /> <PackageVersion Include="Avalonia" Version="12.1.0" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" /> <PackageVersion Include="Avalonia.Desktop" Version="12.1.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1"/> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="BunLabs.NAudio.Flac" Version="2.0.1" /> <PackageVersion Include="BunLabs.NAudio.Flac" Version="2.0.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.ML.OnnxRuntime" Version="1.27.0" /> <PackageVersion Include="Microsoft.ML.OnnxRuntime" Version="1.27.0" />
<PackageVersion Include="Microsoft.ML.OnnxRuntime.DirectML" Version="1.24.4" /> <PackageVersion Include="Microsoft.ML.OnnxRuntime.DirectML" Version="1.24.4" />
<PackageVersion Include="NAudio" Version="2.3.0" /> <PackageVersion Include="NAudio" Version="2.3.0" />
<PackageVersion Include="NAudio.Flac" Version="1.0.5702.29018" /> <PackageVersion Include="NAudio.Flac" Version="1.0.5702.29018" />
<PackageVersion Include="System.Text.Json" Version="10.0.5" /> <PackageVersion Include="System.Text.Json" Version="10.0.5" />
<PackageVersion Include="MSTest" Version="4.0.2" /> <PackageVersion Include="MSTest" Version="4.3.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>