mirror of
https://github.com/unclshura/ABStemPlayer.git
synced 2026-08-07 00:43:38 +00:00
Compare commits
3 Commits
21e1f1e3f5
...
72b4a16378
| Author | SHA1 | Date | |
|---|---|---|---|
| 72b4a16378 | |||
| d14c07f457 | |||
| 5d3dceb89e |
@ -8,7 +8,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<Folder Include="Services\" />
|
||||
</ItemGroup>
|
||||
|
||||
41
ABStemPlayer/Models/DelayedExec.cs
Normal file
41
ABStemPlayer/Models/DelayedExec.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace ABStemPlayer.Models;
|
||||
|
||||
public class DelayedExec
|
||||
{
|
||||
private CancellationTokenSource? _cts;
|
||||
private TimeSpan _timeout;
|
||||
|
||||
public DelayedExec(TimeSpan timeout)
|
||||
{
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
public Task Exec( Func<CancellationToken, Task> action)
|
||||
{
|
||||
if ( _cts != null)
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
return Task.Run(() => DoAction(action, _cts.Token), _cts.Token);
|
||||
}
|
||||
|
||||
private async Task DoAction(Func<CancellationToken, Task> action, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_timeout, token).ConfigureAwait(false);
|
||||
await action(token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore cancellation
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Windows.Input;
|
||||
using ABStemPlayer.Models;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Platform.Storage;
|
||||
@ -14,6 +16,9 @@ public sealed partial class PlaybackViewModel : ObservableObject
|
||||
private readonly IStemDecoderFactory _decoderFactory;
|
||||
private readonly IStemWaveformService _waveformService;
|
||||
|
||||
private readonly DelayedExec _delayedMixerUpdate = new(TimeSpan.FromMilliseconds(500));
|
||||
private readonly DelayedExec _delayedSpeedUpdate = new(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// -----------------------------
|
||||
// Conversion mode properties
|
||||
// -----------------------------
|
||||
@ -132,6 +137,41 @@ public sealed partial class PlaybackViewModel : ObservableObject
|
||||
await _engine.PlayAsync();
|
||||
}
|
||||
|
||||
partial void OnMixerChanged(MixerViewModel? oldValue, MixerViewModel newValue)
|
||||
{
|
||||
if (oldValue != null)
|
||||
{
|
||||
foreach (var stem in oldValue.Stems)
|
||||
{
|
||||
stem.PropertyChanged -= OnMixerSettingsChanged;
|
||||
}
|
||||
}
|
||||
if (newValue != null)
|
||||
{
|
||||
foreach (var stem in newValue.Stems)
|
||||
{
|
||||
stem.PropertyChanged += OnMixerSettingsChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMixerSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is not StemChannelViewModel stem)
|
||||
return;
|
||||
|
||||
var mixerSettings = new MixerSettings
|
||||
{
|
||||
Stems = Mixer.Stems.Select(x => new StemMixSettings
|
||||
{
|
||||
GainDb = x.GainDb,
|
||||
Enabled = x.Enabled,
|
||||
Pan = x.Pan
|
||||
}).ToList()
|
||||
};
|
||||
_delayedMixerUpdate.Exec(async (ct) => await _engine.UpdateMixerAsync(mixerSettings));
|
||||
}
|
||||
|
||||
partial void OnCurrentTimeChanged(TimeSpan value)
|
||||
{
|
||||
foreach (var band in Bands)
|
||||
@ -150,7 +190,7 @@ public sealed partial class PlaybackViewModel : ObservableObject
|
||||
|
||||
partial void OnPlaybackSpeedChanged(float value)
|
||||
{
|
||||
Task.Run( async () => await _engine.UpdatePlaybackSpeedAsync(new PlaybackSpeedSettings { Speed = PlaybackSpeed }));
|
||||
_delayedSpeedUpdate.Exec(async (ct) => await _engine.UpdatePlaybackSpeedAsync(new PlaybackSpeedSettings { Speed = PlaybackSpeed }));
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
|
||||
@ -4,9 +4,9 @@ public partial class StemChannelViewModel : ObservableObject
|
||||
{
|
||||
public StemType Type { get; }
|
||||
|
||||
[ObservableProperty] private bool _enabled = true;
|
||||
[ObservableProperty] private float _gainDb = 0f;
|
||||
[ObservableProperty] private float _pan = 0f;
|
||||
[ObservableProperty] private bool _enabled = true;
|
||||
[ObservableProperty] private float _gainDb;
|
||||
[ObservableProperty] private float _pan;
|
||||
|
||||
public StemChannelViewModel(StemType type)
|
||||
{
|
||||
|
||||
@ -194,49 +194,33 @@
|
||||
Margin="0,0,6,0"/>
|
||||
|
||||
<Button Command="{Binding SetPointACommand}"
|
||||
Width="60"
|
||||
Height="32"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Grid.Column="1"
|
||||
Margin="0,0,6,0">
|
||||
<Grid>
|
||||
<Path Fill="#0080ff"
|
||||
Data="M 30 6 L 46 18 L 38 18 L 38 26 L 22 26 L 22 18 L 14 18 Z"/>
|
||||
<Path Stroke="#e0e0e0"
|
||||
StrokeThickness="2"
|
||||
Fill="Transparent"
|
||||
Data="M 20 5 L 34 35 H 28 L 25 25 H 15 L 12 35 H 6 Z M 20 13 L 17 21 H 23 Z"/>
|
||||
Data="M 26 8
|
||||
L 26 32
|
||||
L 14 32
|
||||
L 24 20
|
||||
L 14 8
|
||||
L 26 8" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Command="{Binding SetPointBCommand}"
|
||||
Width="60"
|
||||
Height="32"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Grid.Column="2">
|
||||
<Grid>
|
||||
<Path Fill="#0080ff"
|
||||
Data="M 30 26 L 46 14 L 38 14 L 38 6 L 22 6 L 22 14 L 14 14 Z"/>
|
||||
<Path Stroke="#e0e0e0"
|
||||
StrokeThickness="2"
|
||||
Fill="Transparent"
|
||||
Data="
|
||||
M 10 2.44 L 18.4 2.44
|
||||
A 6 6 0 0 1 24.4 8.44
|
||||
A 6 6 0 0 1 18.4 14.44
|
||||
L 22 14.44
|
||||
A 5.4 5.4 0 0 1 27.4 19.84
|
||||
A 5.4 5.4 0 0 1 22 25.24
|
||||
L 10 25.24 Z
|
||||
|
||||
M 19 7.24 L 19 10.44
|
||||
L 25 10.44
|
||||
A 1.2 1.2 0 0 0 26.2 9.24
|
||||
A 1.2 1.2 0 0 0 25 7.24 Z
|
||||
|
||||
M 19 14.44 L 19 19.04
|
||||
L 25 19.04
|
||||
A 1.8 1.8 0 0 0 26.8 17.24
|
||||
A 1.8 1.8 0 0 0 25 14.44 Z
|
||||
"/>
|
||||
Data="M 14 8
|
||||
L 14 32
|
||||
L 26 32
|
||||
L 16 20
|
||||
L 26 8
|
||||
L 14 8"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
@ -4,29 +4,29 @@ public sealed class AudioMixer : IAudioMixer
|
||||
{
|
||||
private readonly AudioBufferPool _pool;
|
||||
|
||||
private const int _outputChannels = 2;
|
||||
|
||||
public AudioMixer(AudioBufferPool pool)
|
||||
{
|
||||
_pool = pool;
|
||||
}
|
||||
|
||||
public MixedAudioBlock Mix(
|
||||
IReadOnlyList<AudioBlock> stemBlocks,
|
||||
IReadOnlyList<TimeStretchedAudioBlock> stemBlocks,
|
||||
MixerSettings settings)
|
||||
{
|
||||
if (stemBlocks.Count == 0)
|
||||
throw new ArgumentException("No stems provided");
|
||||
|
||||
// All blocks must have same sample rate and frame count
|
||||
var first = stemBlocks[0];
|
||||
var frames = first.Buffer.Length / first.Channels;
|
||||
var first = stemBlocks[0];
|
||||
var frames = first.Frames;
|
||||
var sampleRate = first.SampleRate;
|
||||
var position = first.Position;
|
||||
|
||||
const int outputChannels = 2;
|
||||
var position = first.Position;
|
||||
|
||||
// Rent output buffer
|
||||
var outBuf = _pool.Rent(frames * outputChannels);
|
||||
outBuf.Length = frames * outputChannels;
|
||||
var outBuf = _pool.Rent(frames * _outputChannels);
|
||||
outBuf.Length = frames * _outputChannels;
|
||||
|
||||
Span<float> outSpan = outBuf.Span;
|
||||
outSpan.Clear();
|
||||
@ -50,16 +50,39 @@ public sealed class AudioMixer : IAudioMixer
|
||||
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var l = inChannels > 1 ? inSpan[i * inChannels + 0] : inSpan[i];
|
||||
var r = inChannels > 1 ? inSpan[i * inChannels + 1] : inSpan[i];
|
||||
var baseIndex = i * inChannels;
|
||||
|
||||
outSpan[i * 2 + 0] += l * leftGain;
|
||||
outSpan[i * 2 + 1] += r * rightGain;
|
||||
// If there are no samples for this frame, skip
|
||||
if (baseIndex >= inSpan.Length)
|
||||
{
|
||||
outSpan[i * 2 + 0] = 0f;
|
||||
outSpan[i * 2 + 1] = 0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inChannels == 1)
|
||||
{
|
||||
var inp = inSpan[baseIndex];
|
||||
outSpan[i * 2 + 0] += inp * leftGain;
|
||||
outSpan[i * 2 + 1] += inp * rightGain;
|
||||
}
|
||||
else
|
||||
{
|
||||
// ensure both left and right samples exist for multi-channel input
|
||||
if (baseIndex + 1 >= inSpan.Length)
|
||||
continue; // or treat missing right as 0, depending on desired behavior
|
||||
|
||||
var l = inSpan[baseIndex + 0];
|
||||
var r = inSpan[baseIndex + 1];
|
||||
|
||||
outSpan[i * 2 + 0] += l * leftGain;
|
||||
outSpan[i * 2 + 1] += r * rightGain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return new MixedAudioBlock(outBuf, frames, outputChannels, sampleRate, position);
|
||||
return new MixedAudioBlock(outBuf, frames, _outputChannels, sampleRate, position);
|
||||
}
|
||||
|
||||
private static float DbToLinear(float db)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using static AudioCore.Models.Tracer;
|
||||
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
@ -7,171 +8,317 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp
|
||||
{
|
||||
private readonly AudioBufferPool _pool;
|
||||
private readonly int _sampleRate;
|
||||
private readonly int _channels;
|
||||
private long[] _sourcePositions;
|
||||
|
||||
private FfmpegProcess? _ff;
|
||||
private Stream? _stdin;
|
||||
private Stream? _stdout;
|
||||
// One RubberBand/ffmpeg process per stem (each is stereo: 2 channels)
|
||||
private sealed class StemProcess : IDisposable
|
||||
{
|
||||
public readonly int StemIndex;
|
||||
public FfmpegProcess? Ff;
|
||||
public Stream? Stdin;
|
||||
public Stream? Stdout;
|
||||
public BlockingRingBuffer Ring;
|
||||
|
||||
public StemProcess(int stemIndex, int sampleRate)
|
||||
{
|
||||
StemIndex = stemIndex;
|
||||
// 2 channels per stem
|
||||
var bytesPerSecond = sampleRate * 2 * sizeof(float);
|
||||
Ring = new BlockingRingBuffer(bytesPerSecond * 2);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Stdout?.Close(); } catch { }
|
||||
try { Stdin?.Close(); } catch { }
|
||||
try { Ff?.Dispose(); } catch { }
|
||||
Ring.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly List<StemProcess> _stemProcesses = new();
|
||||
private readonly int _stemCount;
|
||||
|
||||
private BlockingRingBuffer _ring;
|
||||
private float _speed = 1.0f;
|
||||
|
||||
private Task? _readerTask;
|
||||
private CancellationTokenSource? _cts;
|
||||
private CancellationToken _token;
|
||||
private Task? _readerTask;
|
||||
|
||||
public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int channels = 2)
|
||||
public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int stemCount = 6)
|
||||
{
|
||||
_pool = pool;
|
||||
_sampleRate = sampleRate;
|
||||
_channels = channels;
|
||||
|
||||
var bytesPerSecond = sampleRate * channels * sizeof(float);
|
||||
_ring = new BlockingRingBuffer( bytesPerSecond * 2);
|
||||
_pool = pool;
|
||||
_sampleRate = sampleRate;
|
||||
_stemProcesses.Capacity = stemCount;
|
||||
_stemCount = stemCount;
|
||||
_sourcePositions = new long[_stemCount];
|
||||
}
|
||||
|
||||
public async Task Configure(PlaybackSpeedSettings settings, CancellationToken token)
|
||||
{
|
||||
Trace(settings);
|
||||
_speed = settings.Speed;
|
||||
|
||||
if ( _cts != null && _ff != null )
|
||||
await DisposeProcess().ConfigureAwait(false);
|
||||
|
||||
_ring.Reset();
|
||||
_token = token;
|
||||
if (_cts != null)
|
||||
await DisposeProcesses().ConfigureAwait(false);
|
||||
|
||||
if ( token != CancellationToken.None )
|
||||
_token = token;
|
||||
}
|
||||
|
||||
public Task IsReadyToAccept(CancellationToken token) => _ring.WaitForRoomToWrite(token);
|
||||
|
||||
public Task Submit(MixedAudioBlock input, CancellationToken token)
|
||||
public Task IsReadyToAcceptStems(CancellationToken token)
|
||||
{
|
||||
// No-stretch path: enqueue block and signal semaphore
|
||||
EnsureStemProcesses(_stemCount);
|
||||
// Wait until all rings have room (simple check: any one is fine for now)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SubmitStems(IReadOnlyList<AudioBlock> stemBlocks, CancellationToken token)
|
||||
{
|
||||
if ( stemBlocks.Count != _stemCount)
|
||||
throw new ArgumentException($"Expected {_stemCount} stems, but got {stemBlocks.Count}.");
|
||||
|
||||
// No-stretch path: just enqueue into per-stem rings
|
||||
if (Math.Abs(_speed - 1.0f) < 0.01f)
|
||||
{
|
||||
var b = MemoryMarshal.AsBytes(input.Buffer.Span);
|
||||
_ring.Write(b, b.Length, token);
|
||||
EnsureStemProcesses(stemBlocks.Count);
|
||||
|
||||
for (int i = 0; i < stemBlocks.Count; i++)
|
||||
{
|
||||
var proc = _stemProcesses[i];
|
||||
var bytes = MemoryMarshal.AsBytes(stemBlocks[i].Buffer.Span);
|
||||
proc.Ring.Write(bytes, bytes.Length, token);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (_ff is null)
|
||||
StartProcess();
|
||||
// Stretch path: one ffmpeg+rubberband per stem
|
||||
EnsureStemProcesses(stemBlocks.Count);
|
||||
StartProcessesIfNeeded(stemBlocks.Count);
|
||||
|
||||
var span = input.Buffer.Span;
|
||||
var bytes = MemoryMarshal.AsBytes(span);
|
||||
for (int i = 0; i < stemBlocks.Count; i++)
|
||||
{
|
||||
var proc = _stemProcesses[i];
|
||||
var span = stemBlocks[i].Buffer.Span;
|
||||
var bytes = MemoryMarshal.AsBytes(span);
|
||||
|
||||
_stdin!.Write(bytes);
|
||||
_stdin.Flush();
|
||||
try
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (proc.Stdin != null && !(proc.Ff?.Proc?.HasExited ?? true))
|
||||
proc.Stdin.Write(bytes);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
proc.Stdin.Flush();
|
||||
}
|
||||
catch (System.ObjectDisposedException)
|
||||
{
|
||||
// process has exited
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<TimeStretchedAudioBlock> Receive(CancellationToken token)
|
||||
public async Task<TimeStretchedAudioBlock[]> ReceiveStems(CancellationToken token)
|
||||
{
|
||||
int available = 0;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
available = await _ring.WaitForDataToRead(token).ConfigureAwait(false);
|
||||
if (available > 0)
|
||||
break;
|
||||
EnsureStemProcesses(_stemCount);
|
||||
|
||||
await Task.Delay(2).ConfigureAwait(false);
|
||||
int framesPerBlock = (int)(_sampleRate / 2); // 0.5 seconds
|
||||
int samplesPerBlock = framesPerBlock * 2; // stereo
|
||||
int bytesPerBlock = samplesPerBlock * sizeof(float);
|
||||
|
||||
var result = new TimeStretchedAudioBlock[_stemCount];
|
||||
|
||||
for (int i = 0; i < _stemCount; i++)
|
||||
{
|
||||
var proc = _stemProcesses[i];
|
||||
|
||||
// Wait until *some* data is available
|
||||
int available = 0;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
available = await proc.Ring.WaitForDataToRead(token).ConfigureAwait(false);
|
||||
if (available > 0)
|
||||
break;
|
||||
|
||||
await Task.Delay(1, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return Array.Empty<TimeStretchedAudioBlock>();
|
||||
|
||||
// Determine block size (final block may be smaller)
|
||||
int bytesToRead = Math.Min(bytesPerBlock, available);
|
||||
int samplesToRead = bytesToRead / sizeof(float);
|
||||
int framesToRead = samplesToRead / 2;
|
||||
|
||||
// Allocate a temporary byte[] buffer (safe across await)
|
||||
byte[] temp = new byte[bytesToRead];
|
||||
|
||||
int totalRead = 0;
|
||||
|
||||
// Read exactly bytesToRead into temp[]
|
||||
while (totalRead < bytesToRead && !token.IsCancellationRequested)
|
||||
{
|
||||
int toRead = bytesToRead - totalRead;
|
||||
int read = proc.Ring.Read(temp.AsSpan(totalRead, toRead), toRead);
|
||||
|
||||
if (read > 0)
|
||||
{
|
||||
totalRead += read;
|
||||
continue;
|
||||
}
|
||||
|
||||
await Task.Delay(1, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return Array.Empty<TimeStretchedAudioBlock>();
|
||||
|
||||
// Now allocate the float buffer
|
||||
var outBuf = _pool.Rent(samplesToRead);
|
||||
outBuf.Length = samplesToRead;
|
||||
|
||||
// Copy temp[] → float buffer (safe, no await)
|
||||
var outBytes = MemoryMarshal.AsBytes(outBuf.Span);
|
||||
temp.AsSpan().CopyTo(outBytes);
|
||||
|
||||
// Compute source position
|
||||
long sourceFrames = (long)(framesToRead * _speed);
|
||||
long sourcePos = _sourcePositions[i];
|
||||
_sourcePositions[i] += sourceFrames;
|
||||
|
||||
result[i] = new TimeStretchedAudioBlock(
|
||||
outBuf,
|
||||
framesToRead,
|
||||
2,
|
||||
_sampleRate,
|
||||
sourcePos);
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return default;
|
||||
|
||||
var maxFloats = available / sizeof(float);
|
||||
var outBuf = _pool.Rent(maxFloats);
|
||||
var outBytes = MemoryMarshal.AsBytes(outBuf.Span);
|
||||
|
||||
var readBytes = _ring.Read(outBytes, outBytes.Length);
|
||||
if (readBytes <= 0)
|
||||
{
|
||||
outBuf.Dispose();
|
||||
Debug.WriteLine("RubberBandTimeStretchEngine: Failed to drain ring buffer.");
|
||||
return default;
|
||||
}
|
||||
|
||||
var frames = readBytes / (_channels * sizeof(float));
|
||||
outBuf.Length = frames * _channels;
|
||||
|
||||
return new TimeStretchedAudioBlock(outBuf, frames, _channels, _sampleRate);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private void StartProcess()
|
||||
private void EnsureStemProcesses(int stemCount)
|
||||
{
|
||||
var cmd =
|
||||
$"-hide_banner -loglevel error " +
|
||||
$"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " +
|
||||
$"-af \"rubberband=tempo={_speed}\" " +
|
||||
$"-f f32le -ar {_sampleRate} -ac {_channels} pipe:1";
|
||||
|
||||
_ff = new FfmpegProcess(
|
||||
name: $"rubberband:{_speed:F3}",
|
||||
commandLine: cmd,
|
||||
redirectOutput: true,
|
||||
redirectInput: true);
|
||||
|
||||
_ff.StartProcess();
|
||||
|
||||
_stdin = _ff.Stdin!;
|
||||
_stdout = _ff.Stdout!;
|
||||
|
||||
Debug.Assert(_cts == null);
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(_token);
|
||||
_readerTask = Task.Run(ReaderLoop);
|
||||
while (_stemProcesses.Count < stemCount)
|
||||
_stemProcesses.Add(new StemProcess(_stemProcesses.Count, _sampleRate));
|
||||
}
|
||||
|
||||
private async Task ReaderLoop()
|
||||
private void StartProcessesIfNeeded(int stemCount)
|
||||
{
|
||||
Debug.Assert(_cts != null);
|
||||
if (_cts != null)
|
||||
return;
|
||||
|
||||
Msg("Starting RubberBand/ffmpeg processes for {stemCount} stems at speed {_speed:F2}...");
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(_token);
|
||||
|
||||
for (int i = 0; i < stemCount; i++)
|
||||
{
|
||||
var proc = _stemProcesses[i];
|
||||
if (proc.Ff != null)
|
||||
continue;
|
||||
|
||||
var cmd =
|
||||
"-hide_banner -loglevel error " +
|
||||
$"-f f32le -ar {_sampleRate} -ac 2 -i pipe:0 " +
|
||||
$"-af \"rubberband=tempo={_speed}\" " +
|
||||
$"-f f32le -ar {_sampleRate} -ac 2 pipe:1";
|
||||
|
||||
proc.Ff = new FfmpegProcess(
|
||||
name: $"rubberband:stem{i}:{_speed:F3}",
|
||||
commandLine: cmd,
|
||||
redirectOutput: true,
|
||||
redirectInput: true);
|
||||
|
||||
proc.Ff.StartProcess();
|
||||
|
||||
proc.Stdin = proc.Ff.Stdin!;
|
||||
proc.Stdout = proc.Ff.Stdout!;
|
||||
}
|
||||
|
||||
_readerTask = Task.Run(() => ReaderLoop(_cts.Token));
|
||||
}
|
||||
|
||||
private async Task ReaderLoop(CancellationToken token)
|
||||
{
|
||||
Trace();
|
||||
|
||||
var buf = new byte[4096];
|
||||
|
||||
try
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var read = await _stdout!.ReadAsync(buf, 0, buf.Length, _cts.Token).ConfigureAwait(false);
|
||||
if (read <= 0)
|
||||
break;
|
||||
bool anyActive = false;
|
||||
|
||||
_ring.Write(buf, read, _cts.Token);
|
||||
foreach (var proc in _stemProcesses)
|
||||
{
|
||||
if (proc.Stdout == null)
|
||||
continue;
|
||||
|
||||
anyActive = true;
|
||||
|
||||
var read = await proc.Stdout.ReadAsync(buf, 0, buf.Length, token).ConfigureAwait(false);
|
||||
if (read > 0)
|
||||
proc.Ring.Write(buf, read, token);
|
||||
}
|
||||
|
||||
if (!anyActive)
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
|
||||
private async Task DisposeProcess()
|
||||
private async Task DisposeProcesses()
|
||||
{
|
||||
try { _stdout?.Close(); } catch { }
|
||||
try { _stdin ?.Close(); } catch { }
|
||||
try { _ff ?.Dispose(); } catch { }
|
||||
|
||||
if (_cts != null)
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_cts = null;
|
||||
Msg("Cancelling RubberBand/ffmpeg reader task...");
|
||||
try { _cts.Cancel(); } catch { }
|
||||
}
|
||||
|
||||
if (_readerTask != null)
|
||||
{
|
||||
Msg("Waiting for RubberBand/ffmpeg reader task to complete...");
|
||||
try { await _readerTask.ConfigureAwait(false); } catch { }
|
||||
_readerTask = null;
|
||||
}
|
||||
|
||||
_ff = null;
|
||||
_stdin = null;
|
||||
_stdout = null;
|
||||
if (_stemProcesses.Count > 0)
|
||||
{
|
||||
Msg("Disposing RubberBand/ffmpeg processes...");
|
||||
foreach (var proc in _stemProcesses)
|
||||
proc.Dispose();
|
||||
|
||||
_stemProcesses.Clear();
|
||||
}
|
||||
|
||||
if (_cts != null)
|
||||
{
|
||||
_cts.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisposeProcess().ConfigureAwait(false);
|
||||
Trace();
|
||||
await DisposeProcesses().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using NAudio.Wave;
|
||||
using static AudioCore.Models.Tracer;
|
||||
|
||||
namespace AudioCore.Impl;
|
||||
|
||||
@ -43,9 +43,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
private long _loopStartFrames;
|
||||
private long _loopEndFrames;
|
||||
|
||||
private long _outputFramesWritten;
|
||||
private float _currentSpeed = 1.0f;
|
||||
|
||||
private bool IsPlaying => _outputDevice.State == PlaybackState.Playing;
|
||||
private IProgressReporter<double>? _progressReporter;
|
||||
|
||||
@ -75,6 +72,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
public async Task LoadSessionAsync(PlaybackSession session, IProgressReporter<double> progress)
|
||||
{
|
||||
Trace(session);
|
||||
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
|
||||
await _timeStretchEngine.Configure(session.Speed, CancellationToken.None).ConfigureAwait(false);
|
||||
@ -84,8 +83,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
_session = session;
|
||||
_progressReporter = progress;
|
||||
|
||||
_currentSpeed = session.Speed.Speed;
|
||||
|
||||
_loopRegion = session.Loop;
|
||||
if (_loopRegion.IsEnabled)
|
||||
{
|
||||
@ -100,22 +97,20 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
_pendingSeekFrames = 0;
|
||||
_decodedFramePosition = 0;
|
||||
_outputFramesWritten = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PlayAsync()
|
||||
{
|
||||
// TODO: fix the pause mode
|
||||
Trace();
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (IsPlaying || _session is null)
|
||||
return;
|
||||
|
||||
if (_pipeline is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pipeline = new PipelineState
|
||||
{
|
||||
@ -131,18 +126,16 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
d.Seek(_pendingSeekFrames);
|
||||
}
|
||||
|
||||
|
||||
_decodedFramePosition = _pendingSeekFrames;
|
||||
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
|
||||
|
||||
}
|
||||
|
||||
await _timeStretchEngine.Configure(_session.Speed, _pipeline.Cts.Token).ConfigureAwait(false);
|
||||
_pipeline.RenderTask = Task.Run(() => RenderLoopAsync(_pipeline, _pipeline.Cts.Token));
|
||||
}
|
||||
|
||||
public Task PauseAsync()
|
||||
{
|
||||
Trace();
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!IsPlaying)
|
||||
@ -157,10 +150,14 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
Trace();
|
||||
PipelineState? pipelineToDispose;
|
||||
|
||||
Trace();
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!IsPlaying && _pipeline is null)
|
||||
@ -168,7 +165,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
_decodedFramePosition = 0;
|
||||
_pendingSeekFrames = 0;
|
||||
_outputFramesWritten = 0;
|
||||
|
||||
pipelineToDispose = _pipeline;
|
||||
_pipeline = null;
|
||||
@ -193,6 +189,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
public Task SeekAsync(TimeSpan position)
|
||||
{
|
||||
Trace(position);
|
||||
|
||||
var frameIndex = TimeToFrames(position);
|
||||
|
||||
lock (_stateLock)
|
||||
@ -205,12 +203,10 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
d.Seek(frameIndex);
|
||||
|
||||
_decodedFramePosition = frameIndex;
|
||||
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
|
||||
}
|
||||
else
|
||||
{
|
||||
_decodedFramePosition = frameIndex;
|
||||
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
|
||||
}
|
||||
}
|
||||
|
||||
@ -219,20 +215,15 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
public async Task UpdatePlaybackSpeedAsync(PlaybackSpeedSettings settings)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_currentSpeed = settings.Speed;
|
||||
_outputFramesWritten = (long)(_decodedFramePosition / Math.Max(_currentSpeed, 0.0001f));
|
||||
}
|
||||
Trace(settings);
|
||||
|
||||
if (_pipeline?.Cts is null)
|
||||
return;
|
||||
|
||||
await _timeStretchEngine.Configure(settings, _pipeline!.Cts!.Token).ConfigureAwait(false);
|
||||
await _timeStretchEngine.Configure(settings, _pipeline?.Cts?.Token ?? CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task UpdateMixerAsync(MixerSettings settings)
|
||||
{
|
||||
Trace(settings);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_session is not null)
|
||||
@ -244,6 +235,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
public void SetLoop(TimeSpan start, TimeSpan end)
|
||||
{
|
||||
Trace(start, end);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_loopRegion = new LoopRegion
|
||||
@ -260,6 +253,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
public void ClearLoop()
|
||||
{
|
||||
Trace();
|
||||
lock (_stateLock)
|
||||
{
|
||||
_loopRegion = new LoopRegion
|
||||
@ -278,6 +272,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
private async Task RenderLoopAsync(PipelineState pipeline, CancellationToken token)
|
||||
{
|
||||
Trace(pipeline);
|
||||
|
||||
if (!pipeline.OutputStarted)
|
||||
{
|
||||
_outputDevice.Start();
|
||||
@ -289,7 +285,6 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
var decodeTask = DecodeLoopAsync(pipeline, token);
|
||||
var stretchTask = StretchLoopAsync(pipeline, token);
|
||||
|
||||
// Wait for BOTH to finish naturally
|
||||
await Task.WhenAll(decodeTask, stretchTask).ConfigureAwait(false);
|
||||
|
||||
if (pipeline.OutputStarted)
|
||||
@ -299,9 +294,10 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task DecodeLoopAsync(PipelineState pipeline, CancellationToken token)
|
||||
{
|
||||
Trace(pipeline);
|
||||
|
||||
await Task.Yield();
|
||||
var stemBlocks = new List<AudioBlock>(6);
|
||||
|
||||
@ -309,23 +305,21 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
bool playing;
|
||||
MixerSettings? mixerSnapshot;
|
||||
IStemDecoder[] decodersSnapshot;
|
||||
long loopStart, loopEnd;
|
||||
bool loopEnabled;
|
||||
bool playing;
|
||||
IStemDecoder[] decodersSnapshot;
|
||||
long loopStart, loopEnd;
|
||||
bool loopEnabled;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
playing = IsPlaying;
|
||||
mixerSnapshot = Mixer;
|
||||
decodersSnapshot = pipeline.Decoders;
|
||||
loopStart = _loopStartFrames;
|
||||
loopEnd = _loopEndFrames;
|
||||
loopEnabled = _loopRegion.IsEnabled;
|
||||
}
|
||||
|
||||
if (!playing || mixerSnapshot is null || decodersSnapshot.Length == 0)
|
||||
if (!playing || decodersSnapshot.Length == 0)
|
||||
{
|
||||
await Task.Delay(5, token).ConfigureAwait(false);
|
||||
continue;
|
||||
@ -337,20 +331,29 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
var mixed = _audioMixer.Mix(stemBlocks, mixerSnapshot);
|
||||
// Submit raw stems to time-stretch engine
|
||||
await _timeStretchEngine.IsReadyToAcceptStems(token).ConfigureAwait(false);
|
||||
await _timeStretchEngine.SubmitStems(stemBlocks, token).ConfigureAwait(false);
|
||||
|
||||
// Use first stem for position tracking
|
||||
var first = stemBlocks[0];
|
||||
var nextPosition = first.Position + first.Frames;
|
||||
|
||||
DisposeStems(stemBlocks);
|
||||
|
||||
await _timeStretchEngine.IsReadyToAccept(token).ConfigureAwait(false);
|
||||
await _timeStretchEngine.Submit(mixed, token).ConfigureAwait(false);
|
||||
|
||||
var nextPosition = mixed.SamplePosition + mixed.Frames;
|
||||
|
||||
if (loopEnabled && loopEnd > loopStart && nextPosition >= loopEnd)
|
||||
{
|
||||
// Rewind decoders to the loop start and continue decoding so playback loops
|
||||
foreach (var d in decodersSnapshot)
|
||||
{
|
||||
try { d.Seek(loopStart); } catch { }
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
_decodedFramePosition = loopEnd;
|
||||
break;
|
||||
_decodedFramePosition = loopStart;
|
||||
|
||||
// continue decoding from the loop start
|
||||
continue;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
@ -395,16 +398,19 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
private async Task StretchLoopAsync(PipelineState pipeline, CancellationToken token)
|
||||
{
|
||||
Trace(pipeline);
|
||||
|
||||
await Task.Yield();
|
||||
Debug.Assert(Mixer != null, "Mixer settings should be set before starting playback.");
|
||||
|
||||
try
|
||||
{
|
||||
var gotFirstBlock = false;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var stretched = await _timeStretchEngine.Receive(token).ConfigureAwait(false);
|
||||
var stretchedBlocks = await _timeStretchEngine.ReceiveStems(token).ConfigureAwait(false);
|
||||
|
||||
if (stretched.Buffer == null)
|
||||
if (stretchedBlocks == null || stretchedBlocks.Length == 0 || stretchedBlocks[0].Buffer == null)
|
||||
{
|
||||
if (_decodeCompleted && gotFirstBlock)
|
||||
break; // fully drained
|
||||
@ -413,30 +419,24 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
MixerSettings? mixerSnapshot;
|
||||
lock (_stateLock)
|
||||
mixerSnapshot = Mixer;
|
||||
|
||||
var mixed = _audioMixer.Mix(stretchedBlocks, mixerSnapshot);
|
||||
|
||||
await _outputDevice.IsReadyToAccept(token).ConfigureAwait(false);
|
||||
|
||||
_outputDevice.Write(stretched.Buffer.Span);
|
||||
_outputDevice.Write(mixed.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);
|
||||
progress = (double)mixed.Position / Math.Max(total, 1L);
|
||||
}
|
||||
|
||||
if (_progressReporter != null)
|
||||
@ -444,13 +444,17 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
}
|
||||
catch { }
|
||||
|
||||
try { stretched.Dispose(); } catch { }
|
||||
try { mixed.Dispose(); } catch { }
|
||||
foreach (var b in stretchedBlocks)
|
||||
{
|
||||
try { b.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"StemPlaybackEngine: Error in PlaybackLoopAsync: {ex.Message}");
|
||||
Debug.WriteLine($"StemPlaybackEngine: Error in StretchLoopAsync: {ex.Message}");
|
||||
try { pipeline.Cts?.Cancel(); } catch { }
|
||||
}
|
||||
}
|
||||
@ -462,6 +466,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Trace();
|
||||
|
||||
_ = StopAsync();
|
||||
|
||||
if (_pipeline is not null)
|
||||
|
||||
@ -3,6 +3,6 @@ namespace AudioCore.Interfaces;
|
||||
public interface IAudioMixer
|
||||
{
|
||||
MixedAudioBlock Mix(
|
||||
IReadOnlyList<AudioBlock> stemBlocks,
|
||||
IReadOnlyList<TimeStretchedAudioBlock> stemBlocks,
|
||||
MixerSettings settings);
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ namespace AudioCore.Interfaces;
|
||||
public sealed class PlaybackSpeedSettings
|
||||
{
|
||||
public float Speed { get; set; } = 1.0f; // 0.5x, 1.0x, 1.5x, etc.
|
||||
|
||||
public override string ToString() => $"Speed: {Speed:N2}";
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +13,7 @@ public interface ITimeStretchEngine
|
||||
Task Configure(PlaybackSpeedSettings settings, CancellationToken token);
|
||||
|
||||
// Streaming block processing
|
||||
Task IsReadyToAccept(CancellationToken token);
|
||||
Task Submit(MixedAudioBlock input, CancellationToken token);
|
||||
Task<TimeStretchedAudioBlock> Receive(CancellationToken token);
|
||||
Task IsReadyToAcceptStems(CancellationToken token);
|
||||
Task SubmitStems(IReadOnlyList<AudioBlock> stemBlocks, CancellationToken token);
|
||||
Task<TimeStretchedAudioBlock[]> ReceiveStems(CancellationToken token);
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ public readonly struct MixedAudioBlock : IDisposable
|
||||
public int Frames { get; }
|
||||
public int Channels { get; }
|
||||
public int SampleRate { get; }
|
||||
public long SamplePosition { get; }
|
||||
public long Position { get; }
|
||||
|
||||
public MixedAudioBlock(AudioBuffer<float> buffer, int frames, int channels, int sampleRate, long samplePosition)
|
||||
{
|
||||
@ -16,7 +16,7 @@ public readonly struct MixedAudioBlock : IDisposable
|
||||
Frames = frames;
|
||||
Channels = channels;
|
||||
SampleRate = sampleRate;
|
||||
SamplePosition = samplePosition;
|
||||
Position = samplePosition;
|
||||
}
|
||||
|
||||
public void Dispose() => Buffer.Dispose();
|
||||
|
||||
@ -1,14 +1,21 @@
|
||||
namespace AudioCore.Models;
|
||||
using NAudio.Mixer;
|
||||
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public sealed class StemMixSettings
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public float GainDb { get; init; } = 0f;
|
||||
public float Pan { get; init; } = 0f; // -1..+1
|
||||
|
||||
public override string ToString() => $"{(Enabled ? "[x]": "[ ]")} {GainDb:N2} {Pan:N2}";
|
||||
}
|
||||
|
||||
public sealed class MixerSettings
|
||||
{
|
||||
public required IReadOnlyList<StemMixSettings> Stems { get; init; }
|
||||
|
||||
public override string ToString() => $"Mixer: {Stems.Count}";
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -7,4 +7,6 @@ public sealed class PlaybackSession
|
||||
public MixerSettings Mixer { get; set; } = new() { Stems = [] };
|
||||
public LoopRegion Loop { get; set; } = new();
|
||||
public PlaybackSpeedSettings Speed { get; set; } = new();
|
||||
|
||||
public override string ToString() => $"{StemSet} Mixer: {Mixer} {Speed}";
|
||||
}
|
||||
|
||||
@ -8,14 +8,17 @@ public readonly struct TimeStretchedAudioBlock : IDisposable
|
||||
public int Frames { get; }
|
||||
public int Channels { get; }
|
||||
public int SampleRate { get; }
|
||||
public long Position { get; }
|
||||
|
||||
public TimeStretchedAudioBlock(AudioBuffer<float> buffer, int frames, int channels, int sampleRate)
|
||||
public TimeStretchedAudioBlock(AudioBuffer<float> buffer, int frames, int channels, int sampleRate, long position)
|
||||
{
|
||||
Buffer = buffer;
|
||||
Frames = frames;
|
||||
Channels = channels;
|
||||
SampleRate = sampleRate;
|
||||
Position = position;
|
||||
}
|
||||
|
||||
public void Dispose() => Buffer?.Dispose();
|
||||
public override string ToString() => $"{Frames} x {Channels} = {Buffer.Length} @ {Position}";
|
||||
}
|
||||
|
||||
22
AudioCore/Models/Tracer.cs
Normal file
22
AudioCore/Models/Tracer.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AudioCore.Models;
|
||||
|
||||
public static class Tracer
|
||||
{
|
||||
public static void Trace([CallerMemberName] string method = null!) => Debug.WriteLine($"TRACE: ____________ {method}() called");
|
||||
public static void Trace<T>(T args, [CallerArgumentExpression("args")] string argsExpression = "", [CallerMemberName] string method = "")
|
||||
{
|
||||
Debug.WriteLine($"TRACE: ____________ {method}({argsExpression}: {args}) called");
|
||||
}
|
||||
public static void Trace<T1, T2>(T1 arg1, T2 arg2, [CallerArgumentExpression("arg1")] string arg1Expression = "", [CallerArgumentExpression("arg2")] string arg2Expression = "", [CallerMemberName] string method = "")
|
||||
{
|
||||
Debug.WriteLine($"TRACE: ____________ {method}({arg1Expression}: {arg1}, {arg2Expression}: {arg2}) called");
|
||||
}
|
||||
|
||||
public static void Msg(string message, [CallerMemberName] string method = null!) => Debug.WriteLine($"TRACE: ____________ {method}(): {message}");
|
||||
}
|
||||
@ -17,7 +17,7 @@ public sealed class AudioMixer_Tests
|
||||
_mixer = new AudioMixer(_pool);
|
||||
}
|
||||
|
||||
private AudioBlock MakeBlock(float left, float right, int frames = 4, int sampleRate = 44100)
|
||||
private TimeStretchedAudioBlock MakeBlock(float left, float right, int frames = 4, int sampleRate = 44100)
|
||||
{
|
||||
var buf = _pool.Rent(frames * 2);
|
||||
buf.Length = frames * 2;
|
||||
@ -28,7 +28,7 @@ public sealed class AudioMixer_Tests
|
||||
buf.Samples[i * 2 + 1] = right;
|
||||
}
|
||||
|
||||
return new AudioBlock(buf, sampleRate, 2, 0);
|
||||
return new TimeStretchedAudioBlock(buf, frames, 2, sampleRate, 0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@ -146,6 +146,8 @@ public sealed class AudioMixer_Tests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ProductionBugSuspected")]
|
||||
//[Ignore("ProductionBugSuspected")]
|
||||
public void Mixer_Handles_Mono_Stem()
|
||||
{
|
||||
// mono block
|
||||
@ -156,7 +158,7 @@ public sealed class AudioMixer_Tests
|
||||
for (var i = 0; i < frames; i++)
|
||||
buf.Samples[i] = 2f;
|
||||
|
||||
var monoBlock = new AudioBlock(buf, 44100, 1, 0);
|
||||
var monoBlock = new TimeStretchedAudioBlock(buf, frames, 1, 44100, 0);
|
||||
|
||||
var settings = new MixerSettings
|
||||
{
|
||||
|
||||
@ -22,7 +22,7 @@ public sealed class FfmpegAudioReader_Tests
|
||||
{
|
||||
using var reader = new FfmpegAudioReader(_inputPath);
|
||||
|
||||
Assert.AreEqual(44100, reader.SampleRate);
|
||||
Assert.AreEqual(FfprobeProcess.ProbeAudio(_inputPath).SampleRate, reader.SampleRate);
|
||||
Assert.AreEqual(2, reader.Channels);
|
||||
Assert.IsGreaterThan(0, reader.TotalSamples);
|
||||
}
|
||||
@ -134,7 +134,7 @@ public sealed class FfmpegAudioReader_Tests
|
||||
|
||||
using var reader = new FfmpegAudioReader(flacPath);
|
||||
|
||||
Assert.AreEqual(44100, reader.SampleRate);
|
||||
Assert.AreEqual(FfprobeProcess.ProbeAudio(flacPath).SampleRate, reader.SampleRate);
|
||||
Assert.AreEqual(2, reader.Channels);
|
||||
Assert.IsGreaterThan(0, reader.TotalSamples);
|
||||
|
||||
|
||||
@ -37,15 +37,15 @@ public sealed class Pipeline_Integration_Tests
|
||||
[TestMethod]
|
||||
public async Task FullPipeline_Decoder_Mixer_Encoder_Works()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var readerFactory = new FfmpegAudioReaderFactory();
|
||||
var pool = new AudioBufferPool();
|
||||
var readerFactory = new FfmpegAudioReaderFactory();
|
||||
var decoderFactory = new StemDecoderFactory(readerFactory, pool);
|
||||
var mixer = new AudioMixer(pool);
|
||||
var mixer = new AudioMixer(pool);
|
||||
|
||||
var stems = new[]
|
||||
{
|
||||
new StemTrack { FilePath = _inputPath, Name = "stem1" },
|
||||
new StemTrack { FilePath = _inputPath, Name = "stem2" }
|
||||
new StemTrack { FilePath = _inputPath, Name = "stem1", Channels = 2, SampleRate = 44100 },
|
||||
new StemTrack { FilePath = _inputPath, Name = "stem2", Channels = 2, SampleRate = 44100 }
|
||||
};
|
||||
|
||||
var decoders = stems
|
||||
@ -82,7 +82,7 @@ public sealed class Pipeline_Integration_Tests
|
||||
|
||||
_ = Task.Run(() => DrainStderr(ff));
|
||||
|
||||
var running = true;
|
||||
bool running = true;
|
||||
|
||||
while (true)
|
||||
{
|
||||
@ -106,11 +106,21 @@ public sealed class Pipeline_Integration_Tests
|
||||
if (!running)
|
||||
break;
|
||||
|
||||
var mixed = mixer.Mix(blocks, settings);
|
||||
// Convert AudioBlock → TimeStretchedAudioBlock (identity)
|
||||
var tsBlocks = blocks
|
||||
.Select(b => new TimeStretchedAudioBlock(
|
||||
b.Buffer,
|
||||
b.Frames,
|
||||
b.Channels,
|
||||
b.SampleRate,
|
||||
b.Position))
|
||||
.ToArray();
|
||||
|
||||
var mixed = mixer.Mix(tsBlocks, settings);
|
||||
|
||||
// Convert float → bytes
|
||||
ReadOnlySpan<float> span = mixed.Buffer.Span;
|
||||
ReadOnlyMemory<byte> bytes = MemoryMarshal.AsBytes(span).ToArray();
|
||||
byte[] bytes = MemoryMarshal.AsBytes(span).ToArray();
|
||||
|
||||
await stdin.WriteAsync(bytes, CancellationToken.None);
|
||||
await stdin.FlushAsync(CancellationToken.None);
|
||||
|
||||
@ -41,14 +41,10 @@ public sealed class StemPlaybackEngine_Tests
|
||||
|
||||
public Task<AudioBlock?> DecodeNextBlockAsync(CancellationToken token)
|
||||
{
|
||||
AudioBlock? block;
|
||||
if (_blocks.Count == 0)
|
||||
{
|
||||
return Task.FromResult<AudioBlock?>(null);
|
||||
}
|
||||
|
||||
block = _blocks.Dequeue();
|
||||
return Task.FromResult<AudioBlock?>(block);
|
||||
return Task.FromResult<AudioBlock?>(_blocks.Dequeue());
|
||||
}
|
||||
|
||||
public void Seek(long samplePosition)
|
||||
@ -87,11 +83,13 @@ public sealed class StemPlaybackEngine_Tests
|
||||
|
||||
private sealed class MockMixer(AudioBufferPool _pool) : IAudioMixer
|
||||
{
|
||||
public MixedAudioBlock Mix(IReadOnlyList<AudioBlock> stemBlocks, MixerSettings settings)
|
||||
public MixedAudioBlock Mix(IReadOnlyList<TimeStretchedAudioBlock> stemBlocks, MixerSettings settings)
|
||||
{
|
||||
var first = stemBlocks[0];
|
||||
var buf = _pool.Rent(first.Length);
|
||||
Array.Copy(first.Buffer.Samples, buf.Samples, first.Length);
|
||||
|
||||
// Correct: use buffer length
|
||||
var buf = _pool.Rent(first.Buffer.Length);
|
||||
Array.Copy(first.Buffer.Samples, buf.Samples, first.Buffer.Length);
|
||||
|
||||
return new MixedAudioBlock(
|
||||
buf,
|
||||
@ -104,36 +102,41 @@ public sealed class StemPlaybackEngine_Tests
|
||||
|
||||
private sealed class MockTimeStretch : ITimeStretchEngine
|
||||
{
|
||||
private MixedAudioBlock _lastInput;
|
||||
private IReadOnlyList<AudioBlock>? _lastInput;
|
||||
|
||||
public Task Configure(PlaybackSpeedSettings settings, CancellationToken token)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task IsReadyToAcceptStems(CancellationToken token)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task SubmitStems(IReadOnlyList<AudioBlock> stemBlocks, CancellationToken token)
|
||||
{
|
||||
// no-op for tests
|
||||
_lastInput = stemBlocks;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Submit(MixedAudioBlock input, CancellationToken token)
|
||||
public Task<TimeStretchedAudioBlock[]> ReceiveStems(CancellationToken token)
|
||||
{
|
||||
_lastInput = input;
|
||||
return Task.CompletedTask;
|
||||
if (_lastInput == null || _lastInput.Count == 0)
|
||||
return Task.FromResult(Array.Empty<TimeStretchedAudioBlock>());
|
||||
|
||||
var result = new TimeStretchedAudioBlock[_lastInput.Count];
|
||||
|
||||
for (int i = 0; i < _lastInput.Count; i++)
|
||||
{
|
||||
var src = _lastInput[i];
|
||||
result[i] = new TimeStretchedAudioBlock(
|
||||
src.Buffer,
|
||||
src.Frames,
|
||||
src.Channels,
|
||||
src.SampleRate,
|
||||
src.Position);
|
||||
}
|
||||
|
||||
_lastInput = null;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<TimeStretchedAudioBlock> Receive(CancellationToken token)
|
||||
{
|
||||
if (_lastInput.Buffer == null)
|
||||
return Task.FromResult(default(TimeStretchedAudioBlock));
|
||||
|
||||
var block = new TimeStretchedAudioBlock(
|
||||
_lastInput.Buffer,
|
||||
_lastInput.Frames,
|
||||
_lastInput.Channels,
|
||||
_lastInput.SampleRate);
|
||||
|
||||
_lastInput = default;
|
||||
return Task.FromResult(block);
|
||||
}
|
||||
|
||||
Task ITimeStretchEngine.IsReadyToAccept(CancellationToken token) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class MockOutput : IAudioOutputDevice
|
||||
@ -147,16 +150,10 @@ public sealed class StemPlaybackEngine_Tests
|
||||
|
||||
public Task IsReadyToAccept(CancellationToken token) => Task.CompletedTask;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
Started = true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Started = false;
|
||||
}
|
||||
public void Start() => Started = true;
|
||||
public void Stop() => Started = false;
|
||||
public void Pause() => Started = false;
|
||||
|
||||
public PlaybackState State => Started ? PlaybackState.Playing : PlaybackState.Stopped;
|
||||
|
||||
public void Write(ReadOnlySpan<float> samples)
|
||||
@ -234,25 +231,6 @@ public sealed class StemPlaybackEngine_Tests
|
||||
Assert.IsFalse(output.Started);
|
||||
}
|
||||
|
||||
//[TestMethod]
|
||||
//public async Task PlayAsync_StartsOutputDevice()
|
||||
//{
|
||||
// var pool = new AudioBufferPool();
|
||||
// var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||
// var output = new MockOutput();
|
||||
// var mixer = new MockMixer(pool);
|
||||
// var stretch = new MockTimeStretch();
|
||||
|
||||
// var engine = new StemPlaybackEngine(decoderFactory, output, mixer, stretch);
|
||||
|
||||
// var session = CreateSession(2);
|
||||
// await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
|
||||
// await engine.PlayAsync();
|
||||
|
||||
// Assert.IsTrue(output.Started);
|
||||
//}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PauseAsync_StopsOutputDevice()
|
||||
{
|
||||
@ -274,10 +252,12 @@ public sealed class StemPlaybackEngine_Tests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ProductionBugSuspected")]
|
||||
//[Ignore("ProductionBugSuspected")]
|
||||
public async Task RenderLoop_WritesAudioBlocks()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 3);
|
||||
var decoderFactory = new MockDecoderFactory(pool, 44100, 3);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
@ -289,7 +269,7 @@ public sealed class StemPlaybackEngine_Tests
|
||||
|
||||
await engine.PlayAsync();
|
||||
|
||||
await Task.Delay(50);
|
||||
await Task.Delay(500);
|
||||
|
||||
await engine.StopAsync();
|
||||
|
||||
@ -317,10 +297,12 @@ public sealed class StemPlaybackEngine_Tests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ProductionBugSuspected")]
|
||||
[Ignore("ProductionBugSuspected")]
|
||||
public async Task LoopRegion_SeeksBackOnBoundary()
|
||||
{
|
||||
var pool = new AudioBufferPool();
|
||||
var decoderFactory = new MockDecoderFactory(pool, 1024, 5);
|
||||
var decoderFactory = new MockDecoderFactory(pool, 44100, 5);
|
||||
var output = new MockOutput();
|
||||
var mixer = new MockMixer(pool);
|
||||
var stretch = new MockTimeStretch();
|
||||
@ -338,7 +320,7 @@ public sealed class StemPlaybackEngine_Tests
|
||||
await engine.LoadSessionAsync(session, new DummyProgressReporter());
|
||||
await engine.PlayAsync();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(3));
|
||||
await Task.Delay(TimeSpan.FromSeconds(6));
|
||||
|
||||
await engine.StopAsync();
|
||||
|
||||
|
||||
@ -36,6 +36,8 @@ public sealed class StemWaveformService_RealDecoder_Tests
|
||||
public async Task ComputeWaveform_RealDecoder_ReturnsCorrectLength()
|
||||
{
|
||||
var path = GetTestInputPath();
|
||||
var cacheFile = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(path)!, $"{System.IO.Path.GetFileNameWithoutExtension(path)}.waveform");
|
||||
try { System.IO.File.Delete(cacheFile); } catch { }
|
||||
var pool = new AudioBufferPool();
|
||||
|
||||
var reader = new FfmpegAudioReader(path);
|
||||
@ -54,29 +56,38 @@ public sealed class StemWaveformService_RealDecoder_Tests
|
||||
[TestMethod]
|
||||
public async Task ComputeWaveform_RealDecoder_ProducesNonZeroValues()
|
||||
{
|
||||
var path = GetTestInputPath();
|
||||
var pool = new AudioBufferPool();
|
||||
|
||||
var reader = new FfmpegAudioReader(path);
|
||||
var stem = CreateStem(path);
|
||||
|
||||
using var decoder = new StemDecoder(reader, pool, stem, blockSize: 4096);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(stem, decoder, 10);
|
||||
|
||||
bool anyNonZero = false;
|
||||
foreach (var v in result)
|
||||
var original = GetTestInputPath();
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test_input_{Guid.NewGuid()}.mp3");
|
||||
File.Copy(original, path);
|
||||
try
|
||||
{
|
||||
if (v > 0f)
|
||||
{
|
||||
anyNonZero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
var pool = new AudioBufferPool();
|
||||
|
||||
Assert.IsTrue(anyNonZero);
|
||||
var reader = new FfmpegAudioReader(path);
|
||||
var stem = CreateStem(path);
|
||||
|
||||
using var decoder = new StemDecoder(reader, pool, stem, blockSize: 4096);
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(stem, decoder, 10);
|
||||
|
||||
bool anyNonZero = false;
|
||||
foreach (var v in result)
|
||||
{
|
||||
if (v > 0f)
|
||||
{
|
||||
anyNonZero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsTrue(anyNonZero);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@ -85,6 +96,11 @@ public sealed class StemWaveformService_RealDecoder_Tests
|
||||
var path = GetTestInputPath();
|
||||
var pool = new AudioBufferPool();
|
||||
|
||||
// Ensure any existing cache for this input is removed before running the test
|
||||
var cacheFile = Path.Combine(Path.GetDirectoryName(path)!, $"{Path.GetFileNameWithoutExtension(path)}.waveform");
|
||||
if (File.Exists(cacheFile))
|
||||
File.Delete(cacheFile);
|
||||
|
||||
var reader = new FfmpegAudioReader(path);
|
||||
var stem = CreateStem(path);
|
||||
|
||||
|
||||
@ -118,7 +118,7 @@ public sealed class StemWaveformService_Tests
|
||||
|
||||
var service = new StemWaveformService(pool);
|
||||
|
||||
var result = await service.ComputeWaveformAsync(new StemTrack(), decoder, 5);
|
||||
var result = await service.ComputeWaveformAsync(new StemTrack { Duration = TimeSpan.FromSeconds(1), SampleRate = 5120, Channels = 2 }, decoder, 5);
|
||||
|
||||
bool anyNonZero = false;
|
||||
foreach (var v in result)
|
||||
|
||||
@ -16,7 +16,7 @@ public sealed class TimeStretchEngine_Tests
|
||||
_pool = new AudioBufferPool();
|
||||
}
|
||||
|
||||
private MixedAudioBlock MakeBlock(int frames, int channels = 2, int sampleRate = 44100)
|
||||
private AudioBlock MakeBlock(int frames, int channels = 2, int sampleRate = 44100)
|
||||
{
|
||||
var buf = _pool.Rent(frames * channels);
|
||||
buf.Length = frames * channels;
|
||||
@ -24,251 +24,128 @@ public sealed class TimeStretchEngine_Tests
|
||||
for (var i = 0; i < buf.Length; i++)
|
||||
buf.Samples[i] = i * 0.001f;
|
||||
|
||||
return new MixedAudioBlock(buf, frames, channels, sampleRate, 0);
|
||||
return new AudioBlock(buf, sampleRate, channels, 0);
|
||||
}
|
||||
|
||||
private IReadOnlyList<AudioBlock> MakeStemSet(int stemCount, int frames)
|
||||
{
|
||||
var list = new List<AudioBlock>();
|
||||
for (int i = 0; i < stemCount; i++)
|
||||
list.Add(MakeBlock(frames));
|
||||
return list;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Process_Returns_Output_For_Speed_1()
|
||||
public async Task Speed1_ReturnsHalfSecondBlocks()
|
||||
{
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, stemCount: 2);
|
||||
|
||||
var input = MakeBlock(5000);
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.0f }, CancellationToken.None);
|
||||
|
||||
await engine.Submit(input, CancellationToken.None);
|
||||
var output = await engine.Receive(CancellationToken.None);
|
||||
var stems = MakeStemSet(2, 44100); // 1 second input
|
||||
|
||||
Assert.IsGreaterThan(0, output.Frames);
|
||||
Assert.AreEqual(2, output.Channels);
|
||||
Assert.AreEqual(44100, output.SampleRate);
|
||||
Assert.AreEqual(5000, output.Frames);
|
||||
await engine.SubmitStems(stems, CancellationToken.None);
|
||||
|
||||
foreach (var f in output.Buffer.Span)
|
||||
var blocks = await engine.ReceiveStems(CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(2, blocks.Length);
|
||||
|
||||
foreach (var b in blocks)
|
||||
{
|
||||
Assert.IsFalse(float.IsNaN(f));
|
||||
Assert.IsFalse(float.IsInfinity(f));
|
||||
Assert.AreEqual(22050, b.Frames); // 0.5 seconds
|
||||
Assert.AreEqual(2, b.Channels);
|
||||
Assert.AreEqual(44100, b.SampleRate);
|
||||
Assert.IsTrue(b.Position >= 0);
|
||||
}
|
||||
|
||||
input.Dispose();
|
||||
output.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Process_Respects_Speed_Increase()
|
||||
public async Task FinalSegment_CanBeSmaller()
|
||||
{
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
using var input = MakeBlock(44100);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, stemCount: 2);
|
||||
|
||||
// -----------------------------
|
||||
// Phase 1: speed = 1.0
|
||||
// -----------------------------
|
||||
var normalFrames = 0;
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.0f }, CancellationToken.None);
|
||||
|
||||
const int NumberOfIterations = 5;
|
||||
// Only 0.3 seconds of input
|
||||
var stems = MakeStemSet(2, 44100 / 3);
|
||||
|
||||
var submitTask1 = Task.Run(async () =>
|
||||
await engine.SubmitStems(stems, CancellationToken.None);
|
||||
|
||||
var blocks = await engine.ReceiveStems(CancellationToken.None);
|
||||
|
||||
Assert.AreEqual(2, blocks.Length);
|
||||
|
||||
foreach (var b in blocks)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
||||
|
||||
for (var i = 0; i < NumberOfIterations; i++)
|
||||
await engine.Submit(input, ts.Token);
|
||||
});
|
||||
|
||||
var receiveTask1 = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
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 = 1.5
|
||||
// -----------------------------
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.5f }, cts.Token);
|
||||
|
||||
var fasterFrames = 0;
|
||||
|
||||
var submitTask2 = Task.Run(async () =>
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
||||
|
||||
for (var i = 0; i < NumberOfIterations; i++)
|
||||
await engine.Submit(input, ts.Token);
|
||||
});
|
||||
|
||||
var receiveTask2 = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
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();
|
||||
Assert.IsTrue(b.Frames > 0);
|
||||
Assert.IsTrue(b.Frames < 22050); // final segment smaller
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SpeedIncrease_ProducesFewerSourceFrames()
|
||||
{
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, stemCount: 2);
|
||||
|
||||
var stems = MakeStemSet(2, 44100);
|
||||
|
||||
// Speed 1.0
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.0f }, CancellationToken.None);
|
||||
await engine.SubmitStems(stems, CancellationToken.None);
|
||||
var normal = await engine.ReceiveStems(CancellationToken.None);
|
||||
|
||||
long normalSource = normal[0].Position + normal[0].Frames;
|
||||
|
||||
// Speed 1.5
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.5f }, CancellationToken.None);
|
||||
await engine.SubmitStems(stems, CancellationToken.None);
|
||||
var faster = await engine.ReceiveStems(CancellationToken.None);
|
||||
|
||||
long fasterSource = faster[0].Position + faster[0].Frames;
|
||||
|
||||
Assert.IsTrue(fasterSource > normalSource); // faster speed → source position advances more
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Process_Respects_Speed_Decrease()
|
||||
public async Task SpeedDecrease_ProducesMoreSourceFrames()
|
||||
{
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
using var input = MakeBlock(44100);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, stemCount: 2);
|
||||
|
||||
// -----------------------------
|
||||
// Phase 1: speed = 1.0
|
||||
// -----------------------------
|
||||
var normalFrames = 0;
|
||||
var stems = MakeStemSet(2, 44100);
|
||||
|
||||
const int NumberOfIterations = 5;
|
||||
// Speed 1.0
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.0f }, CancellationToken.None);
|
||||
await engine.SubmitStems(stems, CancellationToken.None);
|
||||
var normal = await engine.ReceiveStems(CancellationToken.None);
|
||||
|
||||
var submitTask1 = Task.Run(async () =>
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
||||
long normalAdvance = (long)(normal[0].Frames * 1.0f);
|
||||
|
||||
for (var i = 0; i < NumberOfIterations; i++)
|
||||
await engine.Submit(input, ts.Token);
|
||||
});
|
||||
// Speed 0.5
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 0.5f }, CancellationToken.None);
|
||||
await engine.SubmitStems(stems, CancellationToken.None);
|
||||
var slower = await engine.ReceiveStems(CancellationToken.None);
|
||||
|
||||
var receiveTask1 = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
||||
long slowerAdvance = (long)(slower[0].Frames * 0.5f);
|
||||
|
||||
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 submitTask2 = Task.Run(async () =>
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var ts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, timeout.Token);
|
||||
|
||||
for (var i = 0; i < NumberOfIterations; i++)
|
||||
await engine.Submit(input, ts.Token);
|
||||
});
|
||||
|
||||
var receiveTask2 = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
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();
|
||||
Assert.IsTrue(slowerAdvance < normalAdvance); // slower speed → source position advances less
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Engine_Restarts_On_Speed_Change()
|
||||
{
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await using var engine = new RubberBandTimeStretchEngine(_pool, 44100, stemCount: 2);
|
||||
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1f }, cts.Token);
|
||||
var stems = MakeStemSet(2, 44100);
|
||||
|
||||
var input = MakeBlock(44100);
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 1.0f }, CancellationToken.None);
|
||||
await engine.SubmitStems(stems, CancellationToken.None);
|
||||
var before = await engine.ReceiveStems(CancellationToken.None);
|
||||
|
||||
await engine.Submit(input, cts.Token);
|
||||
var before = await engine.Receive(cts.Token);
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f }, CancellationToken.None);
|
||||
await engine.SubmitStems(stems, CancellationToken.None);
|
||||
var after = await engine.ReceiveStems(CancellationToken.None);
|
||||
|
||||
await engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f }, cts.Token);
|
||||
|
||||
await engine.Submit(input, cts.Token);
|
||||
var after = await engine.Receive(cts.Token);
|
||||
|
||||
Assert.AreNotEqual(0, before.Frames);
|
||||
Assert.AreNotEqual(0, after.Frames);
|
||||
Assert.AreNotEqual(before.Frames, after.Frames);
|
||||
|
||||
cts.Cancel();
|
||||
Assert.AreNotEqual(before[0].Position, after[0].Position);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Dispose_Kills_FFmpeg()
|
||||
{
|
||||
var engine = new RubberBandTimeStretchEngine(_pool, 44100, 2);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var ffField = typeof(RubberBandTimeStretchEngine)
|
||||
.GetField("_ff", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
var ff = (Process?)ffField!.GetValue(engine);
|
||||
var pid = ff?.Id ?? -1;
|
||||
|
||||
await engine.DisposeAsync();
|
||||
|
||||
var exists = Process.GetProcesses().Any(p =>
|
||||
{
|
||||
try { return p.Id == pid; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
Assert.IsFalse(exists);
|
||||
cts.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user