mirror of
https://github.com/unclshura/ABStemPlayer.git
synced 2026-08-07 00:43:38 +00:00
Compare commits
No commits in common. "5142bb61f21ff982f45bb9aba5dfd0795e864fb6" and "82167921706087b29979407483538669417217a2" have entirely different histories.
5142bb61f2
...
8216792170
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"profiles": {
|
|
||||||
"ABStemPlayer": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"commandLineArgs": "C:\\Users\\uncls\\Music\\test_input.mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -56,8 +56,6 @@ 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
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
@ -96,24 +94,7 @@ 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()
|
||||||
@ -126,9 +107,11 @@ public sealed partial class PlaybackViewModel : ObservableObject
|
|||||||
Stems = _engine.CurrentSession.StemSet.Stems.Select(GetMixerSettings).ToList()
|
Stems = _engine.CurrentSession.StemSet.Stems.Select(GetMixerSettings).ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
await _engine.UpdateMixerAsync(mixer);
|
_engine.CurrentSession.Mixer = mixer;
|
||||||
await _engine.UpdatePlaybackSpeedAsync(new PlaybackSpeedSettings { Speed = PlaybackSpeed });
|
_engine.CurrentSession.Speed = new PlaybackSpeedSettings
|
||||||
|
{
|
||||||
|
Speed = PlaybackSpeed
|
||||||
|
};
|
||||||
await _engine.PlayAsync();
|
await _engine.PlayAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +133,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnPlaybackSpeedChanged(float value)
|
partial void OnPlaybackSpeedChanged(float value)
|
||||||
{
|
{
|
||||||
Task.Run( async () => await _engine.UpdatePlaybackSpeedAsync(new PlaybackSpeedSettings { Speed = PlaybackSpeed }));
|
_engine.CurrentSession?.Speed.Speed = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
@ -183,12 +166,6 @@ 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);
|
||||||
@ -199,29 +176,28 @@ public sealed partial class PlaybackViewModel : ObservableObject
|
|||||||
CurrentTime = TimeSpan.Zero;
|
CurrentTime = TimeSpan.Zero;
|
||||||
|
|
||||||
await UpdateWaveForms(session);
|
await UpdateWaveForms(session);
|
||||||
|
|
||||||
Bands.Clear();
|
Bands.Clear();
|
||||||
foreach (var item in session.StemSet.Stems)
|
foreach ( var item in session.StemSet.Stems)
|
||||||
{
|
{
|
||||||
var model = new WaveformBandViewModel(item);
|
Bands.Add(new WaveformBandViewModel(item));
|
||||||
Bands.Add(model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _engine.LoadSessionAsync(session, new PlaybackProgressReporter(this));
|
await _engine.LoadSessionAsync(session, new PlaybackProgressReporter(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
private class PlaybackProgressReporter : IProgressReporter<double>
|
private class PlaybackProgressReporter : IProgressReporter<TimeSpan>
|
||||||
{
|
{
|
||||||
private readonly PlaybackViewModel _vm;
|
private readonly PlaybackViewModel _vm;
|
||||||
public PlaybackProgressReporter(PlaybackViewModel vm)
|
public PlaybackProgressReporter(PlaybackViewModel vm)
|
||||||
{
|
{
|
||||||
_vm = vm;
|
_vm = vm;
|
||||||
}
|
}
|
||||||
public Task ReportProgress(double progress, CancellationToken ct)
|
public Task ReportProgress(TimeSpan progress, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
_vm.CurrentTime = TimeSpan.FromMilliseconds(progress * _vm.TotalTime.TotalMilliseconds);
|
_vm.CurrentTime = progress;
|
||||||
});
|
});
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@ -322,7 +298,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
|
|||||||
// Stem splitting
|
// Stem splitting
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
|
|
||||||
private async Task<PlaybackSession?> SplitStems(string file)
|
private async Task<PlaybackSession?> SplitStems(IStorageFile file)
|
||||||
{
|
{
|
||||||
// Enter conversion mode
|
// Enter conversion mode
|
||||||
IsConverting = true;
|
IsConverting = true;
|
||||||
@ -331,7 +307,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)!, "ABStemPlayer");
|
var outDir = Path.Combine(Path.GetDirectoryName(file.Path.LocalPath)!, "ABStemPlayer");
|
||||||
|
|
||||||
StemSet? stemSet = null;
|
StemSet? stemSet = null;
|
||||||
|
|
||||||
@ -345,7 +321,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
|
|||||||
return await _separator.SeparateAsync(
|
return await _separator.SeparateAsync(
|
||||||
new StemSeparationRequest
|
new StemSeparationRequest
|
||||||
{
|
{
|
||||||
SourceFilePath = file,
|
SourceFilePath = file.Path.LocalPath,
|
||||||
OutputDirectory = outDir
|
OutputDirectory = outDir
|
||||||
},
|
},
|
||||||
new VmProgressReporter(this),
|
new VmProgressReporter(this),
|
||||||
|
|||||||
@ -15,7 +15,5 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,22 +11,18 @@ public class BlockingRingBuffer
|
|||||||
|
|
||||||
public BlockingRingBuffer(int size)
|
public BlockingRingBuffer(int size)
|
||||||
{
|
{
|
||||||
_ring = new byte[size];
|
_ring = new byte[size];
|
||||||
_ringWrite = 0;
|
_ringWrite = 0;
|
||||||
_ringRead = 0;
|
_ringRead = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Write(ReadOnlySpan<byte> src, int srcLen, CancellationToken ct)
|
public void WriteToOutput(ReadOnlySpan<byte> src, int srcLen, CancellationToken ct)
|
||||||
{
|
{
|
||||||
int written = 0;
|
int written = 0;
|
||||||
|
|
||||||
while (written < srcLen)
|
while (written < srcLen)
|
||||||
{
|
{
|
||||||
if ( ct.IsCancellationRequested )
|
ct.ThrowIfCancellationRequested();
|
||||||
{
|
|
||||||
Debug.WriteLine("BlockingRingBuffer: Write: operation cancelled.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int remaining = srcLen - written;
|
int remaining = srcLen - written;
|
||||||
|
|
||||||
@ -37,11 +33,12 @@ public class BlockingRingBuffer
|
|||||||
? _ringWrite - _ringRead
|
? _ringWrite - _ringRead
|
||||||
: _ring.Length - _ringRead + _ringWrite;
|
: _ring.Length - _ringRead + _ringWrite;
|
||||||
|
|
||||||
free = _ring.Length - used - 1;
|
free = _ring.Length - used - 1; // leave 1 byte to distinguish full/empty
|
||||||
}
|
}
|
||||||
|
|
||||||
if (free <= 0)
|
if (free <= 0)
|
||||||
{
|
{
|
||||||
|
// No room → block until space becomes available
|
||||||
Thread.Sleep(1);
|
Thread.Sleep(1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -52,6 +49,7 @@ 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));
|
||||||
|
|
||||||
@ -60,6 +58,7 @@ 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));
|
||||||
|
|
||||||
@ -71,56 +70,28 @@ public class BlockingRingBuffer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> WaitForRoomToWrite(CancellationToken token)
|
public int WaitForOutput(CancellationToken token)
|
||||||
{
|
{
|
||||||
while (true)
|
while (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
Debug.WriteLine("BlockingRingBuffer: WaitForRoomToWrite: operation cancelled.");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_ringLock)
|
lock (_ringLock)
|
||||||
{
|
{
|
||||||
var used = (_ringWrite >= _ringRead)
|
var available = (_ringWrite >= _ringRead)
|
||||||
? _ringWrite - _ringRead
|
? _ringWrite - _ringRead
|
||||||
: _ring.Length - _ringRead + _ringWrite;
|
: _ring.Length - _ringRead + _ringWrite;
|
||||||
|
|
||||||
var free = _ring.Length - used - 1;
|
if (available > 0)
|
||||||
|
return available;
|
||||||
if (free > 0)
|
|
||||||
return free;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(2).ConfigureAwait(false);
|
Thread.Sleep(2);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
public async Task<int> WaitForDataToRead(CancellationToken token)
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
Debug.WriteLine("BlockingRingBuffer: WaitForDataToRead: operation cancelled.");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_ringLock)
|
Debug.WriteLine("BlockingRingBuffer: Timeout waiting for output");
|
||||||
{
|
return 0;
|
||||||
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)
|
public int DrainRing(Span<byte> dest, int maxBytes)
|
||||||
{
|
{
|
||||||
lock (_ringLock)
|
lock (_ringLock)
|
||||||
{
|
{
|
||||||
@ -149,7 +120,7 @@ public class BlockingRingBuffer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Reset()
|
public void ResetRing()
|
||||||
{
|
{
|
||||||
lock (_ringLock)
|
lock (_ringLock)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace AudioCore.Impl;
|
namespace AudioCore.Impl;
|
||||||
|
|
||||||
@ -7,13 +6,12 @@ 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; }
|
||||||
@ -33,55 +31,41 @@ public sealed class FfmpegAudioReader : IAudioReader, IDisposable
|
|||||||
_process = CreateLazyProcess();
|
_process = CreateLazyProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Lazy<FfmpegProcess> CreateLazyProcess() =>
|
private Lazy<FfmpegProcess> CreateLazyProcess() => new Lazy<FfmpegProcess>(() =>
|
||||||
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 " +
|
||||||
$"-i \"{_path}\" " + // input first
|
$"-ss {startSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} " +
|
||||||
$"-ss {startSeconds.ToString(CultureInfo.InvariantCulture)} " + // output seek
|
$"-i \"{_path}\" " +
|
||||||
$"-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: false);
|
redirectInput: true);
|
||||||
|
|
||||||
p.StartProcess();
|
p.StartProcess();
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <summary>
|
public int Read(float[] buffer, int offset, int count)
|
||||||
/// Async float reader using new FfmpegProcess.ReadAsync
|
|
||||||
/// </summary>
|
|
||||||
public async Task<int> ReadAsync(Memory<float> buffer, CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var proc = _process.Value;
|
var proc = _process.Value; // starts process if not started
|
||||||
if (proc.Stdout is null)
|
if (proc.Stdout is null)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
int readFloats = await proc.ReadAsync(buffer, token).ConfigureAwait(false);
|
return proc.Read(buffer, offset, count);
|
||||||
|
|
||||||
// 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();
|
_process = CreateLazyProcess(); // new lazy instance
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Reset()
|
public void Reset()
|
||||||
|
|||||||
@ -1,28 +1,25 @@
|
|||||||
using System.Buffers;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics;
|
using System.Text.Json;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace AudioCore.Impl;
|
namespace AudioCore.Impl;
|
||||||
|
|
||||||
public sealed class FfmpegProcess : IDisposable
|
public sealed class FfmpegProcess : IDisposable
|
||||||
{
|
{
|
||||||
public Process? Proc { get; private set; }
|
public Process? Proc { get; private set; }
|
||||||
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 readonly string _name;
|
private string _name;
|
||||||
private readonly string _commandLine;
|
private string _commandLine;
|
||||||
private readonly bool _redirectOutput;
|
private bool _redirectOutput;
|
||||||
private readonly bool _redirectInput;
|
private 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)
|
||||||
{
|
{
|
||||||
_name = name;
|
_name = name;
|
||||||
_commandLine = commandLine;
|
_commandLine = commandLine;
|
||||||
_redirectOutput = redirectOutput;
|
_redirectOutput = redirectOutput;
|
||||||
_redirectInput = redirectInput;
|
_redirectInput = redirectInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StartProcess()
|
public void StartProcess()
|
||||||
@ -44,88 +41,60 @@ 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;
|
||||||
|
|
||||||
_stderrTask = Task.Run(() => DrainStderrAsync(Proc));
|
// Start draining stderr immediately
|
||||||
|
_ = Task.Run(() => DrainStderr(Proc));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DrainStderrAsync(Process proc)
|
private void DrainStderr(Process proc)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var reader = proc.StandardError;
|
var reader = proc.StandardError;
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var line = await reader.ReadLineAsync().ConfigureAwait(false);
|
|
||||||
if (line == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
Debug.WriteLine($"{_name}: {line}");
|
Debug.WriteLine($"{_name}: {line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
// ignore exceptions during stderr drain, as the process may have exited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> ReadAsync(Memory<float> buffer, CancellationToken token)
|
public int Read(float[] buffer, int offset, int count)
|
||||||
{
|
{
|
||||||
if (Stdout is null)
|
var bytesNeeded = count * sizeof(float);
|
||||||
|
var tmp = new byte[bytesNeeded];
|
||||||
|
|
||||||
|
var readBytes = Stdout!.Read(tmp, 0, bytesNeeded);
|
||||||
|
if (readBytes <= 0)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
int maxBytes = buffer.Length * sizeof(float);
|
Buffer.BlockCopy(tmp, 0, buffer, offset * sizeof(float), readBytes);
|
||||||
byte[] tmp = ArrayPool<byte>.Shared.Rent(maxBytes);
|
|
||||||
|
|
||||||
try
|
return readBytes / sizeof(float);
|
||||||
{
|
|
||||||
int readBytes = await Stdout.ReadAsync(tmp.AsMemory(0, maxBytes), token)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (readBytes <= 0)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
int floatsRead = readBytes / sizeof(float);
|
|
||||||
var floatMem = buffer.Slice(0, floatsRead);
|
|
||||||
|
|
||||||
// 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()
|
||||||
{
|
{
|
||||||
if (Proc != null)
|
if (Proc != null)
|
||||||
Debug.WriteLine($"{_name}: Disposing ffmpeg process");
|
Debug.WriteLine($"{_name}: Disposing ffmpeg process");
|
||||||
|
|
||||||
try { Stdout?.Dispose(); Stdout = null; } catch { }
|
try { Stdout?.Dispose(); Stdout = null; } catch { }
|
||||||
try { Stdin?.Dispose(); Stdin = null; } catch { }
|
try { Stdin?.Dispose(); Stdin = null; } catch { }
|
||||||
try { Proc?.StandardError.BaseStream?.Dispose(); } catch { }
|
try { Proc?.StandardError.BaseStream?.Dispose(); } catch { }
|
||||||
try { if (Proc != null && !Proc.HasExited) Proc.Kill(); } catch { }
|
try { if (Proc != null && !Proc.HasExited) Proc.Kill(); } catch { }
|
||||||
try { Proc?.Dispose(); Proc = null; } catch { }
|
try { Proc?.Dispose(); Proc = null; } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
using System.Diagnostics;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace AudioCore.Impl;
|
namespace AudioCore.Impl;
|
||||||
|
|
||||||
public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisposable
|
public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposable
|
||||||
{
|
{
|
||||||
private readonly AudioBufferPool _pool;
|
private readonly AudioBufferPool _pool;
|
||||||
private readonly int _sampleRate;
|
private readonly int _sampleRate;
|
||||||
@ -16,9 +17,8 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp
|
|||||||
private BlockingRingBuffer _ring;
|
private BlockingRingBuffer _ring;
|
||||||
private float _speed = 1.0f;
|
private float _speed = 1.0f;
|
||||||
|
|
||||||
private Task? _readerTask;
|
private Thread? _readerThread;
|
||||||
private CancellationTokenSource? _cts;
|
private bool _readerRunning;
|
||||||
private CancellationToken _token;
|
|
||||||
|
|
||||||
public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int channels = 2)
|
public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int channels = 2)
|
||||||
{
|
{
|
||||||
@ -27,29 +27,35 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp
|
|||||||
_channels = channels;
|
_channels = channels;
|
||||||
|
|
||||||
var bytesPerSecond = sampleRate * channels * sizeof(float);
|
var bytesPerSecond = sampleRate * channels * sizeof(float);
|
||||||
_ring = new BlockingRingBuffer( bytesPerSecond * 2);
|
_ring = new BlockingRingBuffer(1 * bytesPerSecond);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Configure(PlaybackSpeedSettings settings, CancellationToken token)
|
public void Configure(PlaybackSpeedSettings settings)
|
||||||
{
|
{
|
||||||
_speed = settings.Speed;
|
if (Math.Abs(settings.Speed - _speed) < 0.0001f)
|
||||||
|
return;
|
||||||
if ( _cts != null && _ff != null )
|
|
||||||
await DisposeProcess().ConfigureAwait(false);
|
|
||||||
|
|
||||||
_ring.Reset();
|
_speed = settings.Speed;
|
||||||
_token = token;
|
|
||||||
|
if (Math.Abs(_speed - 1.0f) < 0.01f)
|
||||||
|
{
|
||||||
|
DisposeProcess();
|
||||||
|
_ring.ResetRing();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RestartProcess();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task IsReadyToAccept(CancellationToken token) => _ring.WaitForRoomToWrite(token);
|
public Task Submit(MixedAudioBlock input)
|
||||||
|
|
||||||
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.Write(b, b.Length, token);
|
_ring.WriteToOutput(b, b.Length, cts.Token);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@ -66,26 +72,28 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TimeStretchedAudioBlock> Receive(CancellationToken token)
|
public async Task<TimeStretchedAudioBlock> Receive()
|
||||||
{
|
{
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
int available = 0;
|
int available = 0;
|
||||||
while (!token.IsCancellationRequested)
|
while (!cts.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
available = await _ring.WaitForDataToRead(token).ConfigureAwait(false);
|
available = _ring.WaitForOutput(cts.Token);
|
||||||
if (available > 0)
|
if (available > 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
await Task.Delay(2).ConfigureAwait(false);
|
await Task.Delay(2).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
if (cts.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.Read(outBytes, outBytes.Length);
|
var readBytes = _ring.DrainRing(outBytes, outBytes.Length);
|
||||||
if (readBytes <= 0)
|
if (readBytes <= 0)
|
||||||
{
|
{
|
||||||
outBuf.Dispose();
|
outBuf.Dispose();
|
||||||
@ -119,57 +127,59 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp
|
|||||||
_stdin = _ff.Stdin!;
|
_stdin = _ff.Stdin!;
|
||||||
_stdout = _ff.Stdout!;
|
_stdout = _ff.Stdout!;
|
||||||
|
|
||||||
Debug.Assert(_cts == null);
|
_readerRunning = true;
|
||||||
|
_readerThread = new Thread(ReaderLoop) { IsBackground = true };
|
||||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(_token);
|
_readerThread.Start();
|
||||||
_readerTask = Task.Run(ReaderLoop);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ReaderLoop()
|
private void RestartProcess()
|
||||||
{
|
{
|
||||||
Debug.Assert(_cts != null);
|
DisposeProcess();
|
||||||
|
_ring.ResetRing();
|
||||||
|
StartProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReaderLoop()
|
||||||
|
{
|
||||||
var buf = new byte[4096];
|
var buf = new byte[4096];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!_cts.Token.IsCancellationRequested)
|
while (_readerRunning)
|
||||||
{
|
{
|
||||||
var read = await _stdout!.ReadAsync(buf, 0, buf.Length, _cts.Token).ConfigureAwait(false);
|
var read = _stdout!.Read(buf, 0, buf.Length);
|
||||||
if (read <= 0)
|
if (read <= 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
_ring.Write(buf, read, _cts.Token);
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
_ring.WriteToOutput(buf, read, cts.Token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task DisposeProcess()
|
private void DisposeProcess()
|
||||||
{
|
{
|
||||||
try { _stdout?.Close(); } catch { }
|
_readerRunning = false;
|
||||||
try { _stdin ?.Close(); } catch { }
|
|
||||||
try { _ff ?.Dispose(); } catch { }
|
|
||||||
|
|
||||||
if (_readerTask != null)
|
try { _stdout?.Close(); } catch { }
|
||||||
|
try { _stdin?.Close(); } catch { }
|
||||||
|
try { _ff?.Dispose(); } catch { }
|
||||||
|
|
||||||
|
if (_readerThread != null)
|
||||||
{
|
{
|
||||||
Debug.Assert(_cts != null);
|
try { _readerThread.Join(500); } catch { }
|
||||||
|
_readerThread = null;
|
||||||
_cts.Cancel();
|
|
||||||
try { await _readerTask.ConfigureAwait(false); } catch { }
|
|
||||||
_readerTask = null;
|
|
||||||
_cts.Dispose();
|
|
||||||
_cts = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ff = null;
|
_ff = null;
|
||||||
_stdin = null;
|
_stdin = null;
|
||||||
_stdout = null;
|
_stdout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
await DisposeProcess().ConfigureAwait(false);
|
DisposeProcess();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using AudioCore.Impl;
|
namespace AudioCore.Impl;
|
||||||
|
|
||||||
public sealed class StemDecoder : IStemDecoder
|
public sealed class StemDecoder : IStemDecoder
|
||||||
{
|
{
|
||||||
@ -15,39 +15,38 @@ public sealed class StemDecoder : IStemDecoder
|
|||||||
StemTrack stem,
|
StemTrack stem,
|
||||||
int blockSize = 4096)
|
int blockSize = 4096)
|
||||||
{
|
{
|
||||||
_reader = reader;
|
_reader = reader;
|
||||||
_pool = pool;
|
_pool = pool;
|
||||||
_blockSize = blockSize;
|
_blockSize = blockSize;
|
||||||
Stem = stem;
|
Stem = stem;
|
||||||
|
|
||||||
Stem.Channels = reader.Channels;
|
Stem.Channels = reader.Channels;
|
||||||
Stem.SampleRate = reader.SampleRate;
|
Stem.SampleRate = reader.SampleRate;
|
||||||
Stem.Duration = TimeSpan.FromSeconds((double)reader.TotalSamples / reader.SampleRate);
|
Stem.Duration = TimeSpan.FromSeconds((double)reader.TotalSamples / reader.SampleRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken token)
|
public bool TryDecodeNextBlock(out AudioBlock block)
|
||||||
{
|
{
|
||||||
int channels = _reader.Channels;
|
var channels = _reader.Channels;
|
||||||
int floatsNeeded = _blockSize * channels;
|
var 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();
|
||||||
return null;
|
block = default;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.Length = readFloats;
|
buf.Length = readFloats;
|
||||||
|
|
||||||
long pos = _currentSample;
|
var pos = _currentSample;
|
||||||
_currentSample += readFloats / channels;
|
_currentSample += readFloats / channels;
|
||||||
|
|
||||||
return new AudioBlock(buf, _reader.SampleRate, channels, pos);
|
block = new AudioBlock(buf, _reader.SampleRate, channels, pos);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Seek(long samplePosition)
|
public void Seek(long samplePosition)
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
using System.Diagnostics;
|
namespace AudioCore.Impl;
|
||||||
using System.Threading;
|
|
||||||
using NAudio.Wave;
|
|
||||||
|
|
||||||
namespace AudioCore.Impl;
|
|
||||||
|
|
||||||
public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||||
{
|
{
|
||||||
@ -39,19 +35,18 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
|
|
||||||
private LoopRegion _loopRegion = new();
|
private LoopRegion _loopRegion = new();
|
||||||
|
|
||||||
private long _decodedFramePosition;
|
private long _currentFramePosition;
|
||||||
private long _loopStartFrames;
|
private long _loopStartFrames;
|
||||||
private long _loopEndFrames;
|
private long _loopEndFrames;
|
||||||
|
|
||||||
private long _outputFramesWritten;
|
private bool _isPlaying;
|
||||||
private float _currentSpeed = 1.0f;
|
private IProgressReporter<TimeSpan>? _progressReporter;
|
||||||
|
|
||||||
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,
|
||||||
@ -59,9 +54,9 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
ITimeStretchEngine timeStretchEngine)
|
ITimeStretchEngine timeStretchEngine)
|
||||||
{
|
{
|
||||||
_stemDecoderFactory = stemDecoderFactory;
|
_stemDecoderFactory = stemDecoderFactory;
|
||||||
_outputDevice = outputDevice;
|
_outputDevice = outputDevice;
|
||||||
_audioMixer = audioMixer;
|
_audioMixer = audioMixer;
|
||||||
_timeStretchEngine = timeStretchEngine;
|
_timeStretchEngine = timeStretchEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlaybackSession? CurrentSession
|
public PlaybackSession? CurrentSession
|
||||||
@ -73,18 +68,16 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter<double> progress)
|
public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter<TimeSpan> 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;
|
||||||
|
|
||||||
_currentSpeed = session.Speed.Speed;
|
_timeStretchEngine.Configure(session.Speed);
|
||||||
|
|
||||||
_loopRegion = session.Loop;
|
_loopRegion = session.Loop;
|
||||||
if (_loopRegion.IsEnabled)
|
if (_loopRegion.IsEnabled)
|
||||||
@ -99,8 +92,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_pendingSeekFrames = 0;
|
_pendingSeekFrames = 0;
|
||||||
_decodedFramePosition = 0;
|
_currentFramePosition = 0;
|
||||||
_outputFramesWritten = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,14 +100,9 @@ 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
|
||||||
@ -130,10 +117,12 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
d.Seek(_pendingSeekFrames);
|
d.Seek(_pendingSeekFrames);
|
||||||
}
|
}
|
||||||
|
|
||||||
_decodedFramePosition = _pendingSeekFrames;
|
_currentFramePosition = _pendingSeekFrames;
|
||||||
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
|
|
||||||
|
|
||||||
_pipeline.RenderTask = Task.Run(() => RenderLoopAsync(_pipeline, _pipeline.Cts.Token));
|
_pipeline.RenderTask = Task.Run(() =>
|
||||||
|
RenderLoopAsync(_pipeline, _pipeline.Cts!.Token));
|
||||||
|
|
||||||
|
_isPlaying = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@ -143,13 +132,16 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
{
|
{
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
if (!IsPlaying)
|
if (!_isPlaying)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
_outputDevice.Pause();
|
_isPlaying = false;
|
||||||
|
|
||||||
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;
|
||||||
@ -161,12 +153,12 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
if (!IsPlaying && _pipeline is null)
|
if (!_isPlaying && _pipeline is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_decodedFramePosition = 0;
|
_isPlaying = false;
|
||||||
|
_currentFramePosition = 0;
|
||||||
_pendingSeekFrames = 0;
|
_pendingSeekFrames = 0;
|
||||||
_outputFramesWritten = 0;
|
|
||||||
|
|
||||||
pipelineToDispose = _pipeline;
|
pipelineToDispose = _pipeline;
|
||||||
_pipeline = null;
|
_pipeline = null;
|
||||||
@ -202,37 +194,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
foreach (var d in _pipeline.Decoders)
|
foreach (var d in _pipeline.Decoders)
|
||||||
d.Seek(frameIndex);
|
d.Seek(frameIndex);
|
||||||
|
|
||||||
_decodedFramePosition = frameIndex;
|
_currentFramePosition = 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;
|
||||||
@ -270,24 +233,14 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool _decodeCompleted;
|
private async Task RenderLoopAsync(PipelineState pipeline, CancellationToken ct)
|
||||||
|
|
||||||
private async Task RenderLoopAsync(PipelineState pipeline, CancellationToken token)
|
|
||||||
{
|
{
|
||||||
if (!pipeline.OutputStarted)
|
var decodeTask = DecodeLoopAsync(pipeline, ct);
|
||||||
{
|
var stretchTask = StretchLoopAsync(pipeline, ct);
|
||||||
_outputDevice.Start();
|
|
||||||
pipeline.OutputStarted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_decodeCompleted = false;
|
await Task.WhenAny(decodeTask, stretchTask);
|
||||||
|
|
||||||
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();
|
||||||
@ -295,162 +248,119 @@ 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 (!token.IsCancellationRequested)
|
while (!ct.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, token).ConfigureAwait(false);
|
await Task.Delay(5, ct);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await ReadStemsAsync(stemBlocks, decodersSnapshot, token).ConfigureAwait(false))
|
_stemBlocks.Clear();
|
||||||
|
bool eof = false;
|
||||||
|
|
||||||
|
foreach (var decoder in decodersSnapshot)
|
||||||
{
|
{
|
||||||
DisposeStems(stemBlocks);
|
if (!decoder.TryDecodeNextBlock(out var block))
|
||||||
|
{
|
||||||
|
eof = true;
|
||||||
|
foreach (var b in _stemBlocks) b.Dispose();
|
||||||
|
_stemBlocks.Clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stemBlocks.Add(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eof)
|
||||||
|
{
|
||||||
|
lock (_stateLock)
|
||||||
|
_isPlaying = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mixed = _audioMixer.Mix(stemBlocks, mixerSnapshot);
|
var mixed = _audioMixer.Mix(_stemBlocks, mixerSnapshot);
|
||||||
|
|
||||||
DisposeStems(stemBlocks);
|
foreach (var b in _stemBlocks)
|
||||||
|
b.Dispose();
|
||||||
|
_stemBlocks.Clear();
|
||||||
|
|
||||||
await _timeStretchEngine.IsReadyToAccept(token).ConfigureAwait(false);
|
await _timeStretchEngine.Submit(mixed);
|
||||||
await _timeStretchEngine.Submit(mixed, token).ConfigureAwait(false);
|
|
||||||
|
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)
|
||||||
_decodedFramePosition = nextPosition;
|
_currentFramePosition = nextPosition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
finally
|
|
||||||
{
|
|
||||||
_decodeCompleted = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DisposeStems(List<AudioBlock> stemBlocks)
|
private async Task StretchLoopAsync(PipelineState pipeline, CancellationToken ct)
|
||||||
{
|
{
|
||||||
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
|
||||||
{
|
{
|
||||||
var gotFirstBlock = false;
|
while (!ct.IsCancellationRequested)
|
||||||
while (!token.IsCancellationRequested)
|
|
||||||
{
|
{
|
||||||
var stretched = await _timeStretchEngine.Receive(token).ConfigureAwait(false);
|
var stretched = await _timeStretchEngine.Receive();
|
||||||
|
|
||||||
if (stretched.Buffer == null)
|
if (stretched.Buffer == null)
|
||||||
{
|
{
|
||||||
if (_decodeCompleted && gotFirstBlock)
|
await Task.Delay(1, ct);
|
||||||
break; // fully drained
|
|
||||||
|
|
||||||
await Task.Delay(1, token).ConfigureAwait(false);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _outputDevice.IsReadyToAccept(token).ConfigureAwait(false);
|
if (!pipeline.OutputStarted)
|
||||||
|
{
|
||||||
|
_outputDevice.Start();
|
||||||
|
pipeline.OutputStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
_outputDevice.Write(stretched.Buffer.Span);
|
_outputDevice.Write(stretched.Buffer.Span);
|
||||||
gotFirstBlock = true;
|
|
||||||
|
|
||||||
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 { }
|
||||||
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);
|
||||||
|
|||||||
@ -36,12 +36,11 @@ public sealed class StemWaveformService : IStemWaveformService
|
|||||||
var count = 0;
|
var count = 0;
|
||||||
|
|
||||||
// Decode only one block per segment
|
// Decode only one block per segment
|
||||||
var block = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
if (decoder.TryDecodeNextBlock(out var block))
|
||||||
if (block != null)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var span = block.Value.Span;
|
var span = block.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++)
|
||||||
@ -54,7 +53,7 @@ public sealed class StemWaveformService : IStemWaveformService
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
block.Value.Dispose();
|
block.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,10 +69,8 @@ public sealed class WasapiOutputDevice : IAudioOutputDevice, IDisposable
|
|||||||
_out.Init(_buffer);
|
_out.Init(_buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
@ -103,50 +101,17 @@ public sealed class WasapiOutputDevice : IAudioOutputDevice, IDisposable
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Lock _lock = new();
|
|
||||||
|
|
||||||
public async Task IsReadyToAccept(CancellationToken token)
|
|
||||||
{
|
|
||||||
while (!token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
int free = _buffer.BufferLength - _buffer.BufferedBytes;
|
|
||||||
if (free > 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await Task.Delay(2, token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Send(byte[] bytes)
|
private void Send(byte[] bytes)
|
||||||
{
|
{
|
||||||
if (_out.PlaybackState != PlaybackState.Playing)
|
// Wait until buffer has enough free space
|
||||||
return;
|
while (_buffer.BufferedBytes + bytes.Length > _buffer.BufferLength)
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
{
|
||||||
|
// Sleep a tiny amount to let WASAPI consume data
|
||||||
int offset = 0;
|
Thread.Sleep(2);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
_buffer.AddSamples(bytes, 0, bytes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
using NAudio.Wave;
|
|
||||||
|
|
||||||
namespace AudioCore.Interfaces;
|
namespace AudioCore.Interfaces;
|
||||||
|
|
||||||
public interface IAudioOutputDevice
|
public interface IAudioOutputDevice
|
||||||
@ -7,12 +5,8 @@ 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);
|
||||||
|
|||||||
@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
public interface IAudioReader : IDisposable
|
public interface IAudioReader : IDisposable
|
||||||
{
|
{
|
||||||
int SampleRate { get; }
|
int SampleRate { get; }
|
||||||
int Channels { get; }
|
int Channels { get; }
|
||||||
long TotalSamples { get; }
|
long TotalSamples { get; }
|
||||||
|
|
||||||
// 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.
|
||||||
Task<int> ReadAsync(Memory<float> buffer, CancellationToken token);
|
int Read(float[] buffer, int offset, int count);
|
||||||
|
|
||||||
// Seek to absolute sample index.
|
// Seek to absolute sample index.
|
||||||
void Seek(long sampleIndex);
|
void Seek(long sampleIndex);
|
||||||
|
|||||||
@ -4,7 +4,7 @@ public interface IStemDecoder : IDisposable
|
|||||||
{
|
{
|
||||||
StemTrack Stem { get; }
|
StemTrack Stem { get; }
|
||||||
|
|
||||||
Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken token);
|
bool TryDecodeNextBlock(out AudioBlock block);
|
||||||
|
|
||||||
void Seek(long samplePosition);
|
void Seek(long samplePosition);
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ public interface IStemPlaybackEngine
|
|||||||
{
|
{
|
||||||
PlaybackSession? CurrentSession { get; }
|
PlaybackSession? CurrentSession { get; }
|
||||||
|
|
||||||
Task LoadSessionAsync(PlaybackSession session, IProgressReporter<double> progressReporter);
|
Task LoadSessionAsync(PlaybackSession session, IProgressReporter<TimeSpan> progressReporter);
|
||||||
|
|
||||||
// Transport
|
// Transport
|
||||||
Task PlayAsync();
|
Task PlayAsync();
|
||||||
@ -12,10 +12,6 @@ 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();
|
||||||
|
|||||||
@ -8,10 +8,9 @@ public sealed class PlaybackSpeedSettings
|
|||||||
|
|
||||||
public interface ITimeStretchEngine
|
public interface ITimeStretchEngine
|
||||||
{
|
{
|
||||||
Task Configure(PlaybackSpeedSettings settings, CancellationToken token);
|
void Configure(PlaybackSpeedSettings settings);
|
||||||
|
|
||||||
// Streaming block processing
|
// Streaming block processing
|
||||||
Task IsReadyToAccept(CancellationToken token);
|
Task Submit(MixedAudioBlock input);
|
||||||
Task Submit(MixedAudioBlock input, CancellationToken token);
|
Task<TimeStretchedAudioBlock> Receive();
|
||||||
Task<TimeStretchedAudioBlock> Receive(CancellationToken token);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ 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();
|
||||||
|
|||||||
@ -4,6 +4,4 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,4 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.Write(src, src.Length, ct);
|
ring.WriteToOutput(src, src.Length, ct);
|
||||||
|
|
||||||
Span<byte> dest = stackalloc byte[100];
|
Span<byte> dest = stackalloc byte[100];
|
||||||
int read = ring.Read(dest, dest.Length);
|
int read = ring.DrainRing(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.Write(first, first.Length, ct);
|
ring.WriteToOutput(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.Read(tmp, tmp.Length);
|
int drained = ring.DrainRing(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.Write(second, second.Length, ct);
|
ring.WriteToOutput(second, second.Length, ct);
|
||||||
|
|
||||||
// Drain everything
|
// Drain everything
|
||||||
Span<byte> dest = stackalloc byte[25];
|
Span<byte> dest = stackalloc byte[25];
|
||||||
int read = ring.Read(dest, dest.Length);
|
int read = ring.DrainRing(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.Write(src, src.Length, CancellationToken.None);
|
ring.WriteToOutput(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.Write(new byte[10], 10, cts.Token);
|
ring.WriteToOutput(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.Read(drain, drain.Length);
|
int drained = ring.DrainRing(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 async Task WaitForOutput_ReturnsAvailable()
|
public void 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.Write(src, src.Length, ct);
|
ring.WriteToOutput(src, src.Length, ct);
|
||||||
|
|
||||||
int available = await ring.WaitForDataToRead(ct);
|
int available = ring.WaitForOutput(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.Write(new byte[60], 60, ct);
|
ring.WriteToOutput(new byte[60], 60, ct);
|
||||||
|
|
||||||
ring.Reset();
|
ring.ResetRing();
|
||||||
|
|
||||||
Span<byte> dest = stackalloc byte[128];
|
Span<byte> dest = stackalloc byte[128];
|
||||||
int read = ring.Read(dest, dest.Length);
|
int read = ring.DrainRing(dest, dest.Length);
|
||||||
|
|
||||||
Assert.AreEqual(0, read);
|
Assert.AreEqual(0, read);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
using System.Buffers;
|
using AudioCore.Interfaces;
|
||||||
using AudioCore.Interfaces;
|
|
||||||
|
|
||||||
namespace AudioCore_Tests;
|
namespace AudioCore_Tests;
|
||||||
|
|
||||||
@ -21,29 +20,19 @@ public sealed class FakeAudioReader : IAudioReader
|
|||||||
_pos = 0;
|
_pos = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<int> ReadAsync(Memory<float> buffer, CancellationToken token)
|
public int Read(float[] buffer, int offset, int count)
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
throw new ObjectDisposedException(nameof(FakeAudioReader));
|
throw new ObjectDisposedException(nameof(FakeAudioReader));
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
var remaining = _data.Length - _pos;
|
||||||
return Task.FromCanceled<int>(token);
|
|
||||||
|
|
||||||
// How many floats remain?
|
|
||||||
long remaining = _data.Length - _pos;
|
|
||||||
if (remaining <= 0)
|
if (remaining <= 0)
|
||||||
return Task.FromResult(0);
|
return 0;
|
||||||
|
|
||||||
// How many floats can we copy?
|
var toRead = (int)Math.Min(count, remaining);
|
||||||
int toCopy = (int)Math.Min(buffer.Length, remaining);
|
Array.Copy(_data, _pos, buffer, offset, toRead);
|
||||||
|
_pos += toRead;
|
||||||
// Copy from backing array into caller's buffer
|
return toRead;
|
||||||
_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)
|
||||||
|
|||||||
@ -28,34 +28,37 @@ public sealed class FfmpegAudioReader_Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task Reader_Reads_Some_Samples()
|
public void Reader_Reads_Some_Samples()
|
||||||
{
|
{
|
||||||
using var reader = new FfmpegAudioReader(_inputPath);
|
using var reader = new FfmpegAudioReader(_inputPath);
|
||||||
|
|
||||||
var buf = new float[44100];
|
var buf = new float[44100]; // 0.5 sec stereo = 22050 frames
|
||||||
var read = await reader.ReadAsync(buf.AsMemory(), CancellationToken.None);
|
var read = reader.Read(buf, 0, buf.Length);
|
||||||
|
|
||||||
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 async Task Reader_Seek_Works()
|
public void 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];
|
||||||
|
|
||||||
var r1 = await reader.ReadAsync(buf1.AsMemory(), CancellationToken.None);
|
// Read from start
|
||||||
|
var r1 = reader.Read(buf1, 0, buf1.Length);
|
||||||
Assert.IsGreaterThan(0, r1);
|
Assert.IsGreaterThan(0, r1);
|
||||||
|
|
||||||
reader.Seek(reader.SampleRate*5);
|
// Seek to 1 second
|
||||||
|
reader.Seek(reader.SampleRate);
|
||||||
|
|
||||||
var r2 = await reader.ReadAsync(buf2.AsMemory(), CancellationToken.None);
|
var r2 = reader.Read(buf2, 0, buf2.Length);
|
||||||
Assert.IsGreaterThan(0, r2);
|
Assert.IsGreaterThan(0, r2);
|
||||||
|
|
||||||
bool identical = true;
|
// Buffers should differ
|
||||||
|
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])
|
||||||
@ -69,22 +72,23 @@ public sealed class FfmpegAudioReader_Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task Reader_Reset_Works()
|
public void 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 = await reader.ReadAsync(buf1.AsMemory(), CancellationToken.None);
|
var r1 = reader.Read(buf1, 0, buf1.Length);
|
||||||
Assert.IsGreaterThan(0, r1);
|
Assert.IsGreaterThan(0, r1);
|
||||||
|
|
||||||
reader.Reset();
|
reader.Reset();
|
||||||
|
|
||||||
var r2 = await reader.ReadAsync(buf2.AsMemory(), CancellationToken.None);
|
var r2 = reader.Read(buf2, 0, buf2.Length);
|
||||||
Assert.IsGreaterThan(0, r2);
|
Assert.IsGreaterThan(0, r2);
|
||||||
|
|
||||||
bool identical = true;
|
// After reset, buffers should match again
|
||||||
|
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])
|
||||||
@ -105,13 +109,15 @@ public sealed class FfmpegAudioReader_Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task Reader_Can_Read_Flac_File()
|
public void 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",
|
||||||
@ -125,33 +131,39 @@ 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 = await reader.ReadAsync(buf.AsMemory(), CancellationToken.None);
|
var read = reader.Read(buf, 0, buf.Length);
|
||||||
|
|
||||||
Assert.IsGreaterThan(0, read);
|
Assert.IsGreaterThan(0, read, "FLAC reader returned no samples");
|
||||||
Assert.IsLessThanOrEqualTo(buf.Length, read);
|
Assert.IsLessThanOrEqualTo(buf.Length, read);
|
||||||
|
|
||||||
reader.Seek(reader.SampleRate);
|
// Seek test
|
||||||
|
reader.Seek(reader.SampleRate); // 1 second
|
||||||
var buf2 = new float[44100];
|
var buf2 = new float[44100];
|
||||||
var read2 = await reader.ReadAsync(buf2.AsMemory(), CancellationToken.None);
|
var read2 = reader.Read(buf2, 0, buf2.Length);
|
||||||
|
|
||||||
Assert.IsGreaterThan(0, read2);
|
Assert.IsGreaterThan(0, read2);
|
||||||
|
|
||||||
bool identical = true;
|
// Buffers should differ after seek
|
||||||
|
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])
|
||||||
@ -163,14 +175,15 @@ 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 = await reader.ReadAsync(buf3.AsMemory(), CancellationToken.None);
|
var read3 = reader.Read(buf3, 0, buf3.Length);
|
||||||
|
|
||||||
Assert.IsGreaterThan(0, read3);
|
Assert.IsGreaterThan(0, read3);
|
||||||
|
|
||||||
bool matchAfterReset = true;
|
// After reset, buf3 should match buf
|
||||||
|
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])
|
||||||
@ -184,12 +197,13 @@ 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 { }
|
catch { /* swallow */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,6 +212,9 @@ 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)
|
||||||
{
|
{
|
||||||
@ -209,4 +226,5 @@ public sealed class FfmpegAudioReader_Tests
|
|||||||
Debug.WriteLine(ex.ToString());
|
Debug.WriteLine(ex.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,30 +35,30 @@ public sealed class Pipeline_Integration_Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task FullPipeline_Decoder_Mixer_Encoder_Works()
|
public void FullPipeline_Decoder_Mixer_Encoder_Works()
|
||||||
{
|
{
|
||||||
var pool = new AudioBufferPool();
|
var pool = new AudioBufferPool();
|
||||||
var readerFactory = new FfmpegAudioReaderFactory();
|
var readerFactory = new FfmpegAudioReaderFactory();
|
||||||
var decoderFactory = new StemDecoderFactory(readerFactory, pool);
|
var decoderFactory = new StemDecoderFactory(readerFactory, pool);
|
||||||
var mixer = new AudioMixer(pool);
|
var mixer = new AudioMixer(pool);
|
||||||
|
|
||||||
var stems = new[]
|
var stems = new[]
|
||||||
{
|
{
|
||||||
new StemTrack { FilePath = _inputPath, Name = "stem1" },
|
new StemTrack { FilePath = _inputPath, Name = "stem1" },
|
||||||
new StemTrack { FilePath = _inputPath, Name = "stem2" }
|
new StemTrack { FilePath = _inputPath, Name = "stem2" }
|
||||||
};
|
};
|
||||||
|
|
||||||
var decoders = stems
|
var decoders = stems
|
||||||
.Select(s => decoderFactory.Create(s))
|
.Select(s => decoderFactory.Create(s))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var settings = new MixerSettings
|
var settings = new MixerSettings
|
||||||
{
|
{
|
||||||
Stems = new[]
|
Stems = new[]
|
||||||
{
|
{
|
||||||
new StemMixSettings { Enabled = true, GainDb = 0, Pan = 0 },
|
new StemMixSettings { Enabled = true, GainDb = 0, Pan = 0 },
|
||||||
new StemMixSettings { Enabled = true, GainDb = -3, Pan = 0.2f }
|
new StemMixSettings { Enabled = true, GainDb = -3, Pan = 0.2f }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var outFlac = Path.Combine(_outputDir, "mixed.flac");
|
var outFlac = Path.Combine(_outputDir, "mixed.flac");
|
||||||
@ -67,9 +67,9 @@ public sealed class Pipeline_Integration_Tests
|
|||||||
{
|
{
|
||||||
FileName = "ffmpeg",
|
FileName = "ffmpeg",
|
||||||
Arguments =
|
Arguments =
|
||||||
"-y -f f32le -ar 44100 -ac 2 -i pipe:0 " +
|
"-y -f f32le -ar 44100 -ac 2 -i pipe:0 " +
|
||||||
"-compression_level 12 " +
|
"-compression_level 12 " +
|
||||||
$"\"{outFlac}\"",
|
$"\"{outFlac}\"",
|
||||||
RedirectStandardInput = true,
|
RedirectStandardInput = true,
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
@ -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,8 +90,7 @@ public sealed class Pipeline_Integration_Tests
|
|||||||
|
|
||||||
foreach (var d in decoders)
|
foreach (var d in decoders)
|
||||||
{
|
{
|
||||||
var block = await d.DecodeNextBlockAsync(CancellationToken.None);
|
if (!d.TryDecodeNextBlock(out var block))
|
||||||
if (block is null)
|
|
||||||
{
|
{
|
||||||
foreach (var b in blocks)
|
foreach (var b in blocks)
|
||||||
b.Dispose();
|
b.Dispose();
|
||||||
@ -100,7 +99,7 @@ public sealed class Pipeline_Integration_Tests
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks.Add(block.Value);
|
blocks.Add(block);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!running)
|
if (!running)
|
||||||
@ -108,12 +107,9 @@ public sealed class Pipeline_Integration_Tests
|
|||||||
|
|
||||||
var mixed = mixer.Mix(blocks, settings);
|
var mixed = mixer.Mix(blocks, settings);
|
||||||
|
|
||||||
// Convert float → bytes
|
var span = mixed.Buffer.Span;
|
||||||
ReadOnlySpan<float> span = mixed.Buffer.Span;
|
var bytes = MemoryMarshal.AsBytes(span);
|
||||||
ReadOnlyMemory<byte> bytes = MemoryMarshal.AsBytes(span).ToArray();
|
stdin.Write(bytes);
|
||||||
|
|
||||||
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)
|
||||||
@ -130,7 +126,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 = await verify.ReadAsync(buf.AsMemory(), CancellationToken.None);
|
var read = verify.Read(buf, 0, buf.Length);
|
||||||
|
|
||||||
Assert.IsGreaterThan(0, read, "FLAC output is not decodable");
|
Assert.IsGreaterThan(0, read, "FLAC output is not decodable");
|
||||||
}
|
}
|
||||||
@ -140,6 +136,9 @@ 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -7,21 +7,18 @@ namespace AudioCore_Tests;
|
|||||||
public sealed class StemDecoder_Tests
|
public sealed class StemDecoder_Tests
|
||||||
{
|
{
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task DecodeNextBlockAsync_ReturnsBlock()
|
public void TryDecodeNextBlock_ReturnsBlock()
|
||||||
{
|
{
|
||||||
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(); // 1 sec stereo
|
||||||
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 nullableBlock = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
var ok = decoder.TryDecodeNextBlock(out var block);
|
||||||
|
|
||||||
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);
|
||||||
@ -31,99 +28,101 @@ public sealed class StemDecoder_Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task DecodeNextBlockAsync_AdvancesPosition()
|
public void TryDecodeNextBlock_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);
|
||||||
|
|
||||||
var b1 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
decoder.TryDecodeNextBlock(out var b1);
|
||||||
var b2 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
decoder.TryDecodeNextBlock(out var b2);
|
||||||
|
|
||||||
Assert.IsNotNull(b1);
|
Assert.AreEqual(0, b1.Position);
|
||||||
Assert.IsNotNull(b2);
|
Assert.AreEqual(1000, b2.Position);
|
||||||
|
|
||||||
Assert.AreEqual(0, b1!.Value.Position);
|
b1.Dispose();
|
||||||
Assert.AreEqual(1000, b2!.Value.Position);
|
b2.Dispose();
|
||||||
|
|
||||||
b1.Value.Dispose();
|
|
||||||
b2.Value.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task Seek_MovesReaderAndDecoderPosition()
|
public void 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);
|
decoder.Seek(2000); // sample position
|
||||||
|
|
||||||
var block = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
decoder.TryDecodeNextBlock(out var block);
|
||||||
|
|
||||||
Assert.IsNotNull(block);
|
Assert.AreEqual(2000, block.Position);
|
||||||
Assert.AreEqual(2000, block!.Value.Position);
|
Assert.AreEqual(500, block.Frames);
|
||||||
Assert.AreEqual(500, block.Value.Frames);
|
|
||||||
|
|
||||||
block.Value.Dispose();
|
block.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task Reset_ReturnsToStart()
|
public void 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);
|
||||||
|
|
||||||
var b1 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
decoder.TryDecodeNextBlock(out var b1);
|
||||||
decoder.Reset();
|
decoder.Reset();
|
||||||
var b2 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
decoder.TryDecodeNextBlock(out var b2);
|
||||||
|
|
||||||
Assert.IsNotNull(b2);
|
Assert.AreEqual(0, b2.Position);
|
||||||
Assert.AreEqual(0, b2!.Value.Position);
|
|
||||||
|
|
||||||
b1!.Value.Dispose();
|
b1.Dispose();
|
||||||
b2!.Value.Dispose();
|
b2.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task DecodeNextBlockAsync_ReturnsNullAtEnd()
|
public void TryDecodeNextBlock_ReturnsFalseAtEnd()
|
||||||
{
|
{
|
||||||
var pool = new AudioBufferPool();
|
var pool = new AudioBufferPool();
|
||||||
var samples = new float[2000];
|
var samples = new float[2000]; // small buffer
|
||||||
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 b1 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
// First block: should succeed
|
||||||
Assert.IsNotNull(b1);
|
Assert.IsTrue(decoder.TryDecodeNextBlock(out var b1));
|
||||||
b1!.Value.Dispose();
|
Assert.IsNotNull(b1.Buffer);
|
||||||
|
b1.Dispose();
|
||||||
|
|
||||||
var b2 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
// Second block: may succeed or partially succeed
|
||||||
if (b2 != null)
|
decoder.TryDecodeNextBlock(out var b2);
|
||||||
b2!.Value.Dispose();
|
if (b2.Buffer != null)
|
||||||
|
b2.Dispose();
|
||||||
|
|
||||||
var b3 = await decoder.DecodeNextBlockAsync(CancellationToken.None);
|
// Third block: MUST fail
|
||||||
Assert.IsNull(b3, "Decoder should return null at end of stream");
|
var ok = decoder.TryDecodeNextBlock(out var b3);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
@ -131,11 +130,11 @@ public sealed class StemDecoder_Tests
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// FakeAudioReader throws ObjectDisposedException when used after Dispose
|
// This must throw
|
||||||
var _ = reader.ReadAsync(new float[10].AsMemory(), CancellationToken.None).Result;
|
reader.Read(new float[10], 0, 10);
|
||||||
Assert.Fail("Expected ObjectDisposedException");
|
Assert.Fail("Expected ObjectDisposedException");
|
||||||
}
|
}
|
||||||
catch (AssertFailedException)
|
catch(AssertFailedException )
|
||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
@ -144,4 +143,6 @@ public sealed class StemDecoder_Tests
|
|||||||
Assert.IsInstanceOfType(ex, typeof(ObjectDisposedException));
|
Assert.IsInstanceOfType(ex, typeof(ObjectDisposedException));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
using AudioCore.Impl;
|
using AudioCore.Interfaces;
|
||||||
using AudioCore.Interfaces;
|
|
||||||
using AudioCore.Models;
|
using AudioCore.Models;
|
||||||
using NAudio.Wave;
|
using AudioCore.Impl;
|
||||||
|
|
||||||
namespace AudioCore_Tests;
|
namespace AudioCore_Tests;
|
||||||
|
|
||||||
@ -39,16 +38,16 @@ public sealed class StemPlaybackEngine_Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken token)
|
public bool TryDecodeNextBlock(out AudioBlock block)
|
||||||
{
|
{
|
||||||
AudioBlock? block;
|
|
||||||
if (_blocks.Count == 0)
|
if (_blocks.Count == 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult<AudioBlock?>(null);
|
block = default;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
block = _blocks.Dequeue();
|
block = _blocks.Dequeue();
|
||||||
return Task.FromResult<AudioBlock?>(block);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Seek(long samplePosition)
|
public void Seek(long samplePosition)
|
||||||
@ -106,19 +105,18 @@ public sealed class StemPlaybackEngine_Tests
|
|||||||
{
|
{
|
||||||
private MixedAudioBlock _lastInput;
|
private MixedAudioBlock _lastInput;
|
||||||
|
|
||||||
public Task Configure(PlaybackSpeedSettings settings, CancellationToken token)
|
public void Configure(PlaybackSpeedSettings settings)
|
||||||
{
|
{
|
||||||
// no-op for tests
|
// no-op for tests
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Submit(MixedAudioBlock input, CancellationToken token)
|
public Task Submit(MixedAudioBlock input)
|
||||||
{
|
{
|
||||||
_lastInput = input;
|
_lastInput = input;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<TimeStretchedAudioBlock> Receive(CancellationToken token)
|
public Task<TimeStretchedAudioBlock> Receive()
|
||||||
{
|
{
|
||||||
if (_lastInput.Buffer == null)
|
if (_lastInput.Buffer == null)
|
||||||
return Task.FromResult(default(TimeStretchedAudioBlock));
|
return Task.FromResult(default(TimeStretchedAudioBlock));
|
||||||
@ -132,8 +130,6 @@ 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
|
||||||
@ -145,8 +141,6 @@ 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;
|
||||||
@ -156,8 +150,6 @@ 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)
|
||||||
{
|
{
|
||||||
@ -211,9 +203,9 @@ public sealed class StemPlaybackEngine_Tests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private class DummyProgressReporter : IProgressReporter<double>
|
private class DummyProgressReporter : IProgressReporter<TimeSpan>
|
||||||
{
|
{
|
||||||
public Task ReportProgress(double value, CancellationToken ct)
|
public Task ReportProgress(TimeSpan value, CancellationToken ct)
|
||||||
=> Task.CompletedTask;
|
=> Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,11 +248,11 @@ public sealed class StemPlaybackEngine_Tests
|
|||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task PauseAsync_StopsOutputDevice()
|
public async Task PauseAsync_StopsOutputDevice()
|
||||||
{
|
{
|
||||||
var pool = new AudioBufferPool();
|
var pool = new AudioBufferPool();
|
||||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||||
var output = new MockOutput();
|
var output = new MockOutput();
|
||||||
var mixer = new MockMixer(pool);
|
var mixer = new MockMixer(pool);
|
||||||
var stretch = new MockTimeStretch();
|
var stretch = new MockTimeStretch();
|
||||||
|
|
||||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||||
|
|
||||||
@ -276,11 +268,11 @@ public sealed class StemPlaybackEngine_Tests
|
|||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task RenderLoop_WritesAudioBlocks()
|
public async Task RenderLoop_WritesAudioBlocks()
|
||||||
{
|
{
|
||||||
var pool = new AudioBufferPool();
|
var pool = new AudioBufferPool();
|
||||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 3);
|
var decoderFactory = new MockDecoderFactory(pool, 1024, 3);
|
||||||
var output = new MockOutput();
|
var output = new MockOutput();
|
||||||
var mixer = new MockMixer(pool);
|
var mixer = new MockMixer(pool);
|
||||||
var stretch = new MockTimeStretch();
|
var stretch = new MockTimeStretch();
|
||||||
|
|
||||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||||
|
|
||||||
@ -300,11 +292,11 @@ public sealed class StemPlaybackEngine_Tests
|
|||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task SeekAsync_MovesDecoders()
|
public async Task SeekAsync_MovesDecoders()
|
||||||
{
|
{
|
||||||
var pool = new AudioBufferPool();
|
var pool = new AudioBufferPool();
|
||||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||||
var output = new MockOutput();
|
var output = new MockOutput();
|
||||||
var mixer = new MockMixer(pool);
|
var mixer = new MockMixer(pool);
|
||||||
var stretch = new MockTimeStretch();
|
var stretch = new MockTimeStretch();
|
||||||
|
|
||||||
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||||
|
|
||||||
@ -338,7 +330,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(TimeSpan.FromSeconds(3));
|
await Task.Delay(50);
|
||||||
|
|
||||||
await engine.StopAsync();
|
await engine.StopAsync();
|
||||||
|
|
||||||
|
|||||||
@ -48,16 +48,16 @@ public sealed class StemWaveformService_Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken ct)
|
public bool TryDecodeNextBlock(out AudioBlock block)
|
||||||
{
|
{
|
||||||
if (ct.IsCancellationRequested)
|
|
||||||
return Task.FromCanceled<AudioBlock?>(ct);
|
|
||||||
|
|
||||||
if (_blocks.Count == 0)
|
if (_blocks.Count == 0)
|
||||||
return Task.FromResult<AudioBlock?>(null);
|
{
|
||||||
|
block = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var block = _blocks.Dequeue();
|
block = _blocks.Dequeue();
|
||||||
return Task.FromResult<AudioBlock?>(block);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Seek(long samplePosition)
|
public void Seek(long samplePosition)
|
||||||
@ -75,7 +75,6 @@ public sealed class StemWaveformService_Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private string GetTestInputPath()
|
private string GetTestInputPath()
|
||||||
{
|
{
|
||||||
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
|||||||
@ -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()
|
||||||
{
|
{
|
||||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||||
|
|
||||||
var input = MakeBlock(5000);
|
var input = MakeBlock(5000);
|
||||||
|
|
||||||
await engine.Submit(input, CancellationToken.None);
|
await engine.Submit(input);
|
||||||
var output = await engine.Receive(CancellationToken.None);
|
var output = await engine.Receive();
|
||||||
|
|
||||||
Assert.IsGreaterThan(0, output.Frames);
|
Assert.IsGreaterThan(0, output.Frames);
|
||||||
Assert.AreEqual(2, output.Channels);
|
Assert.AreEqual(2, output.Channels);
|
||||||
@ -55,204 +55,101 @@ public sealed class TimeStretchEngine_Tests
|
|||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task Process_Respects_Speed_Increase()
|
public async Task Process_Respects_Speed_Increase()
|
||||||
{
|
{
|
||||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||||
using var input = MakeBlock(44100);
|
var input = MakeBlock(1000);
|
||||||
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;
|
||||||
|
while (true)
|
||||||
const int NumberOfIterations = 5;
|
|
||||||
|
|
||||||
var submitTask1 = Task.Run(async () =>
|
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
using var data = await engine.Receive();
|
||||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
normalFrames += data.Frames;
|
||||||
|
if (data.Buffer == null)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < NumberOfIterations; i++)
|
|
||||||
await engine.Submit(input, ts.Token);
|
|
||||||
});
|
|
||||||
|
|
||||||
var receiveTask1 = Task.Run(async () =>
|
engine.Configure(new PlaybackSpeedSettings { Speed = 1.5f });
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
|
||||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
|
||||||
|
|
||||||
using var data = await engine.Receive(ts.Token);
|
|
||||||
if (data.Buffer == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
normalFrames += data.Frames;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.WhenAll(submitTask1, receiveTask1);
|
for (var i = 0; i < 25; i++)
|
||||||
|
await engine.Submit(input);
|
||||||
Debug.WriteLine($"Normal frames: {normalFrames}");
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// Phase 2: speed = 1.5
|
|
||||||
// -----------------------------
|
|
||||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.5f }, cts.Token);
|
|
||||||
|
|
||||||
var fasterFrames = 0;
|
var fasterFrames = 0;
|
||||||
|
while (true)
|
||||||
var submitTask2 = Task.Run(async () =>
|
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
using var data = await engine.Receive();
|
||||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
fasterFrames += data.Frames;
|
||||||
|
if (data.Buffer == null)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < NumberOfIterations; i++)
|
Assert.IsLessThanOrEqualTo(normalFrames, fasterFrames);
|
||||||
await engine.Submit(input, ts.Token);
|
|
||||||
});
|
|
||||||
|
|
||||||
var receiveTask2 = Task.Run(async () =>
|
input.Dispose();
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
|
||||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
|
||||||
|
|
||||||
using var data = await engine.Receive(ts.Token);
|
|
||||||
if (data.Buffer == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
fasterFrames += data.Frames;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.WhenAll(submitTask2, receiveTask2);
|
|
||||||
|
|
||||||
Debug.WriteLine($"Faster frames: {fasterFrames}");
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// Assertion
|
|
||||||
// -----------------------------
|
|
||||||
Assert.IsLessThan(fasterFrames, normalFrames);
|
|
||||||
|
|
||||||
cts.Cancel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task Process_Respects_Speed_Decrease()
|
public async Task Process_Respects_Speed_Decrease()
|
||||||
{
|
{
|
||||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||||
using var input = MakeBlock(44100);
|
var input = MakeBlock(1000);
|
||||||
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;
|
||||||
|
while (true)
|
||||||
const int NumberOfIterations = 5;
|
|
||||||
|
|
||||||
var submitTask1 = Task.Run(async () =>
|
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
using var data = await engine.Receive();
|
||||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
normalFrames += data.Frames;
|
||||||
|
if (data.Buffer == null)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < NumberOfIterations; i++)
|
engine.Configure(new PlaybackSpeedSettings { Speed = 0.5f });
|
||||||
await engine.Submit(input, ts.Token);
|
|
||||||
});
|
|
||||||
|
|
||||||
var receiveTask1 = Task.Run(async () =>
|
for (var i = 0; i < 25; i++)
|
||||||
{
|
await engine.Submit(input);
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
|
||||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
|
||||||
|
|
||||||
using var data = await engine.Receive(ts.Token);
|
|
||||||
if (data.Buffer == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
normalFrames += data.Frames;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.WhenAll(submitTask1, receiveTask1);
|
|
||||||
|
|
||||||
Debug.WriteLine($"Normal frames: {normalFrames}");
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// Phase 2: speed = 0.5
|
|
||||||
// -----------------------------
|
|
||||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 0.5f }, cts.Token);
|
|
||||||
|
|
||||||
var slowerFrames = 0;
|
var slowerFrames = 0;
|
||||||
|
while (true)
|
||||||
var submitTask2 = Task.Run(async () =>
|
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
using var data = await engine.Receive();
|
||||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
slowerFrames += data.Frames;
|
||||||
|
if (data.Buffer == null)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < NumberOfIterations; i++)
|
Assert.IsGreaterThanOrEqualTo(normalFrames, slowerFrames);
|
||||||
await engine.Submit(input, ts.Token);
|
|
||||||
});
|
|
||||||
|
|
||||||
var receiveTask2 = Task.Run(async () =>
|
input.Dispose();
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
|
||||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
|
||||||
|
|
||||||
using var data = await engine.Receive(ts.Token);
|
|
||||||
if (data.Buffer == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
slowerFrames += data.Frames;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.WhenAll(submitTask2, receiveTask2);
|
|
||||||
|
|
||||||
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()
|
||||||
{
|
{
|
||||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1f }, cts.Token);
|
var input = MakeBlock(100);
|
||||||
|
|
||||||
var input = MakeBlock(44100);
|
await engine.Submit(input);
|
||||||
|
var before = await engine.Receive();
|
||||||
|
|
||||||
await engine.Submit(input, cts.Token);
|
engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f });
|
||||||
var before = await engine.Receive(cts.Token);
|
|
||||||
|
|
||||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f }, cts.Token);
|
await engine.Submit(input);
|
||||||
|
var after = await engine.Receive();
|
||||||
|
|
||||||
await engine.Submit(input, cts.Token);
|
Assert.AreEqual(0, after.Frames);
|
||||||
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 async Task Dispose_Kills_FFmpeg()
|
public void 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);
|
||||||
@ -260,7 +157,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;
|
||||||
|
|
||||||
await engine.DisposeAsync();
|
engine.Dispose();
|
||||||
|
|
||||||
var exists = Process.GetProcesses().Any(p =>
|
var exists = Process.GetProcesses().Any(p =>
|
||||||
{
|
{
|
||||||
@ -269,6 +166,5 @@ public sealed class TimeStretchEngine_Tests
|
|||||||
});
|
});
|
||||||
|
|
||||||
Assert.IsFalse(exists);
|
Assert.IsFalse(exists);
|
||||||
cts.Cancel();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,6 @@ 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 };
|
||||||
|
|||||||
@ -3,19 +3,19 @@
|
|||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="12.1.0" />
|
<PackageVersion Include="Avalonia" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="12.1.0" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1"/>
|
||||||
<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.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<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.3.0" />
|
<PackageVersion Include="MSTest" Version="4.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
Loading…
x
Reference in New Issue
Block a user