ABStemPlayer/AudioCore/Impl/RubberBandTimeStretchEngine.cs
Alexander Shabarshov 950e5f1dcc Refactor playback speed & mixer management; UI tweaks
Refactor playback speed and mixer settings to be managed via PlaybackSession, removing SetSpeed/SetStem* methods. Update Play command to apply current mixer and speed. Optimize RenderLoop with reusable audio block list and conditional output start. Align playback control icons in XAML. Update tests for new speed handling and add speed decrease coverage. Remove unused IStemPlaybackEngine methods.
2026-07-05 15:07:42 +01:00

233 lines
6.3 KiB
C#

using System.Diagnostics;
using System.Runtime.InteropServices;
namespace AudioCore.Impl;
public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposable
{
private readonly AudioBufferPool _pool;
private readonly int _sampleRate;
private readonly int _channels;
private Process? _ff;
private Stream? _stdin;
private Stream? _stdout;
private float _speed = 1.0f;
private readonly byte[] _ring;
private int _ringWrite;
private int _ringRead;
private readonly object _ringLock = new();
private Thread? _readerThread;
private bool _readerRunning;
public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int channels = 2)
{
_pool = pool;
_sampleRate = sampleRate;
_channels = channels;
// Ring buffer: e.g. 1 second of audio
var bytesPerSecond = sampleRate * channels * sizeof(float);
_ring = new byte[bytesPerSecond];
StartProcess();
}
public void Configure(PlaybackSpeedSettings settings)
{
if (Math.Abs(settings.Speed - _speed) < 0.0001f)
return;
_speed = settings.Speed;
RestartProcess();
}
public TimeStretchedAudioBlock Process(MixedAudioBlock input)
{
var expectedFloats = input.Frames * _channels;
var expectedBytes = expectedFloats * sizeof(float);
if (Math.Abs(_speed - 1.0f) < 0.01f)
{
var buf = _pool.Rent(expectedFloats);
Array.Copy(input.Buffer.Samples, buf.Samples, input.Buffer.Length);
return new TimeStretchedAudioBlock(buf, input.Frames, _channels, _sampleRate);
}
var span = input.Buffer.Span;
var bytes = MemoryMarshal.AsBytes(span);
_stdin!.Write(bytes);
_stdin.Flush();
var available = WaitForOutput();
if (available <= 0)
return default;
var outBuf = _pool.Rent(expectedFloats);
var outBytes = MemoryMarshal.AsBytes(outBuf.Span);
var readBytes = DrainRing(outBytes, expectedBytes);
if (readBytes <= 0)
{
outBuf.Dispose();
return default;
}
var frames = readBytes / (_channels * sizeof(float));
outBuf.Length = frames * _channels;
return new TimeStretchedAudioBlock(outBuf, frames, _channels, _sampleRate);
}
private int WaitForOutput(int timeoutMs = 5000)
{
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < timeoutMs)
{
lock (_ringLock)
{
var available = (_ringWrite >= _ringRead)
? _ringWrite - _ringRead
: _ring.Length - _ringRead + _ringWrite;
if (available > 0)
return available;
}
Thread.Sleep(2);
}
return 0;
}
private void StartProcess()
{
var psi = new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments =
$"-hide_banner -loglevel error " +
$"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " +
$"-af \"rubberband=tempo={_speed}\" " +
$"-f f32le -ar {_sampleRate} -ac {_channels} pipe:1",
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
};
_ff = System.Diagnostics.Process.Start(psi);
_stdin = _ff!.StandardInput.BaseStream;
_stdout = _ff!.StandardOutput.BaseStream;
_readerRunning = true;
_readerThread = new Thread(ReaderLoop) { IsBackground = true };
_readerThread.Start();
}
private void RestartProcess()
{
DisposeProcess();
ResetRing();
StartProcess();
}
private void ReaderLoop()
{
var buf = new byte[4096];
try
{
while (_readerRunning)
{
var read = _stdout!.Read(buf, 0, buf.Length);
if (read <= 0)
break;
lock (_ringLock)
{
var first = Math.Min(read, _ring.Length - _ringWrite);
Buffer.BlockCopy(buf, 0, _ring, _ringWrite, first);
_ringWrite = (_ringWrite + first) % _ring.Length;
var remaining = read - first;
if (remaining > 0)
{
Buffer.BlockCopy(buf, first, _ring, _ringWrite, remaining);
_ringWrite = (_ringWrite + remaining) % _ring.Length;
}
}
}
}
catch { /* swallow for now */ }
}
private int DrainRing(Span<byte> dest, int maxBytes)
{
lock (_ringLock)
{
var available = (_ringWrite >= _ringRead)
? _ringWrite - _ringRead
: _ring.Length - _ringRead + _ringWrite;
if (available <= 0)
return 0;
var toRead = Math.Min(available, Math.Min(maxBytes, dest.Length));
var first = Math.Min(toRead, _ring.Length - _ringRead);
new Span<byte>(_ring, _ringRead, first).CopyTo(dest.Slice(0, first));
_ringRead = (_ringRead + first) % _ring.Length;
var remaining = toRead - first;
if (remaining > 0)
{
new Span<byte>(_ring, _ringRead, remaining)
.CopyTo(dest.Slice(first, remaining));
_ringRead = (_ringRead + remaining) % _ring.Length;
}
return toRead;
}
}
private void ResetRing()
{
lock (_ringLock)
{
_ringWrite = 0;
_ringRead = 0;
}
}
private void DisposeProcess()
{
_readerRunning = false;
try { _stdout?.Close(); } catch { }
try { _stdin?.Close(); } catch { }
try { _ff?.Kill(); } catch { }
try { _ff?.Dispose(); } catch { }
if (_readerThread != null)
{
try { _readerThread.Join(500); } catch { }
_readerThread = null;
}
}
public void Dispose() => DisposeProcess();
}