Compare commits

...

2 Commits

8 changed files with 426 additions and 274 deletions

View File

@ -1,27 +1,88 @@
namespace AudioCore.Impl; using System.Diagnostics;
public sealed class FfmpegAudioReader : IAudioReader namespace AudioCore.Impl;
public sealed class FfmpegAudioReader : IAudioReader, IDisposable
{ {
private readonly FfmpegPipe _pipe; private readonly string _path;
public int SampleRate => _pipe.SampleRate; // Lazy process wrapper
public int Channels => _pipe.Channels; private Lazy<FfmpegProcess> _process;
public long TotalSamples => _pipe.TotalSamples;
// Remember last seek position
private long _pendingSeekSample = 0;
public int SampleRate { get; }
public int Channels { get; }
public long TotalSamples { get; }
public TimeSpan Duration { get; }
public FfmpegAudioReader(string path) public FfmpegAudioReader(string path)
{ {
_pipe = new FfmpegPipe(path); _path = path;
var probe = FfprobeProcess.ProbeAudio(path);
SampleRate = probe.SampleRate;
Channels = probe.Channels;
TotalSamples = probe.TotalSamples;
Duration = probe.Duration;
_process = CreateLazyProcess();
} }
private Lazy<FfmpegProcess> CreateLazyProcess() => new Lazy<FfmpegProcess>(() =>
{
var startSeconds = (double)_pendingSeekSample / SampleRate;
var cmd =
"-hide_banner -loglevel error " +
"-nostdin " +
$"-ss {startSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} " +
$"-i \"{_path}\" " +
$"-f f32le -ac {Channels} -ar {SampleRate} pipe:1";
var p = new FfmpegProcess(
name: $"pipe:{_path}",
commandLine: cmd,
redirectOutput: true,
redirectInput: true);
p.StartProcess();
return p;
});
public int Read(float[] buffer, int offset, int count) public int Read(float[] buffer, int offset, int count)
=> _pipe.Read(buffer, offset, count); {
var proc = _process.Value; // starts process if not started
if (proc.Stdout is null)
return 0;
return proc.Read(buffer, offset, count);
}
public void Seek(long sampleIndex) public void Seek(long sampleIndex)
=> _pipe.Seek(sampleIndex); {
_pendingSeekSample = sampleIndex;
DisposeProcessOnly();
_process = CreateLazyProcess(); // new lazy instance
}
public void Reset() public void Reset()
=> Seek(0); {
Seek(0);
}
private void DisposeProcessOnly()
{
if (_process.IsValueCreated)
{
try { _process.Value.Dispose(); } catch { }
}
}
public void Dispose() public void Dispose()
=> _pipe.Dispose(); {
DisposeProcessOnly();
}
} }

View File

@ -1,112 +0,0 @@
using System.Diagnostics;
namespace AudioCore.Impl;
public sealed class FfmpegPipe : IDisposable
{
private readonly string _path;
private Process? _proc;
private Stream? _stdout;
public int SampleRate { get; }
public int Channels { get; }
public long TotalSamples { get; }
public FfmpegPipe(string path, int sampleRate = 44100, int channels = 2)
{
_path = path;
SampleRate = sampleRate;
Channels = channels;
// Optional: probe duration
TotalSamples = ProbeTotalSamples(path, sampleRate);
StartProcess(0);
}
private void StartProcess(long startSample)
{
var startSeconds = (double)startSample / SampleRate;
var psi = new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments =
$"-hide_banner -loglevel error " +
$"-nostdin " + // prevent console attach
$"-ss {startSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} " +
$"-i \"{_path}\" " +
$"-f f32le -ac {Channels} -ar {SampleRate} pipe:1",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true, // prevents console window
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
};
_proc = Process.Start(psi);
_stdout = _proc!.StandardOutput.BaseStream;
}
public int Read(float[] buffer, int offset, int count)
{
var bytesNeeded = count * sizeof(float);
var tmp = new byte[bytesNeeded];
var readBytes = _stdout!.Read(tmp, 0, bytesNeeded);
if (readBytes <= 0)
return 0;
Buffer.BlockCopy(tmp, 0, buffer, offset * sizeof(float), readBytes);
return readBytes / sizeof(float);
}
public void Seek(long sampleIndex)
{
DisposeProcessOnly();
StartProcess(sampleIndex);
}
public void Reset() => Seek(0);
private static long ProbeTotalSamples(string path, int sampleRate)
{
var psi = new ProcessStartInfo
{
FileName = "ffprobe",
Arguments = $"-v error -show_entries format=duration -of csv=p=0 \"{path}\"",
RedirectStandardOutput = true,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = false
};
using var p = Process.Start(psi);
var s = p!.StandardOutput.ReadToEnd();
p.WaitForExit();
if (double.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var seconds))
{
return (long)(seconds * sampleRate);
}
return 0;
}
private void DisposeProcessOnly()
{
try { _stdout?.Dispose(); } catch { }
try { if (_proc != null && !_proc.HasExited) _proc.Kill(); } catch { }
try { _proc?.Dispose(); } catch { }
}
public void Dispose()
{
DisposeProcessOnly();
}
}

View File

@ -0,0 +1,104 @@
using System.Diagnostics;
using System.Text.Json;
namespace AudioCore.Impl;
public sealed class FfmpegProcess : IDisposable
{
public Process? Proc { get; private set; }
public Stream? Stdout { get; private set; }
public Stream? Stdin { get; private set; }
private string _name;
private string _commandLine;
private bool _redirectOutput;
private bool _redirectInput;
public FfmpegProcess(string name, string commandLine, bool redirectOutput = true, bool redirectInput = true)
{
_name = name;
_commandLine = commandLine;
_redirectOutput = redirectOutput;
_redirectInput = redirectInput;
}
public void StartProcess()
{
Debug.WriteLine($"{_name}: Starting ffmpeg process");
var psi = new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = _commandLine,
UseShellExecute = false,
RedirectStandardOutput = _redirectOutput,
RedirectStandardError = true,
RedirectStandardInput = _redirectInput,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
};
Proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start ffmpeg process");
if ( _redirectInput)
Stdin = Proc.StandardInput.BaseStream;
if (_redirectOutput)
Stdout = Proc.StandardOutput.BaseStream;
// Start draining stderr immediately
_ = Task.Run(() => DrainStderr(Proc));
}
private void DrainStderr(Process proc)
{
try
{
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;
while ((line = reader.ReadLine()) != null)
{
Debug.WriteLine($"{_name}: {line}");
}
}
catch
{
// ignore exceptions during stderr drain, as the process may have exited
}
}
public int Read(float[] buffer, int offset, int count)
{
var bytesNeeded = count * sizeof(float);
var tmp = new byte[bytesNeeded];
var readBytes = Stdout!.Read(tmp, 0, bytesNeeded);
if (readBytes <= 0)
return 0;
Buffer.BlockCopy(tmp, 0, buffer, offset * sizeof(float), readBytes);
return readBytes / sizeof(float);
}
private void DisposeProcessOnly()
{
if (Proc != null)
Debug.WriteLine($"{_name}: Disposing ffmpeg process");
try { Stdout?.Dispose(); Stdout = null; } catch { }
try { Stdin?.Dispose(); Stdin = null; } catch { }
try { Proc?.StandardError.BaseStream?.Dispose(); } catch { }
try { if (Proc != null && !Proc.HasExited) Proc.Kill(); } catch { }
try { Proc?.Dispose(); Proc = null; } catch { }
}
public void Dispose()
{
DisposeProcessOnly();
}
}

View File

@ -0,0 +1,108 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AudioCore.Impl;
public class AudioProbe
{
public int SampleRate { get; init; }
public int Channels { get; init; }
public long TotalSamples { get; init; }
public TimeSpan Duration { get; init; }
}
public static class FfprobeProcess
{
private sealed class FfprobeJson
{
public FfprobeFormat? Format { get; set; }
public FfprobeStream[]? Streams { get; set; }
}
private sealed class FfprobeFormat
{
public string? Duration { get; set; }
}
private sealed class FfprobeStream
{
[JsonConverter(typeof(IntFlexibleConverter))]
public int Sample_Rate { get; set; }
[JsonConverter(typeof(IntFlexibleConverter))]
public int Channels { get; set; }
}
private sealed class IntFlexibleConverter : JsonConverter<int>
{
public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetInt32(),
JsonTokenType.String => int.Parse(reader.GetString()!, System.Globalization.CultureInfo.InvariantCulture),
_ => throw new JsonException($"Invalid token for int: {reader.TokenType}")
};
}
public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value);
}
}
public static AudioProbe ProbeAudio(string path)
{
var psi = new ProcessStartInfo
{
FileName = "ffprobe",
Arguments =
"-v error " +
"-select_streams a:0 " +
"-show_entries format=duration " +
"-show_entries stream=sample_rate,channels " +
"-print_format json " +
$"\"{path}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using var p = Process.Start(psi) ?? throw new InvalidOperationException("ffprobe failed");
var json = p.StandardOutput.ReadToEnd();
p.WaitForExit();
var opts = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var probe = JsonSerializer.Deserialize<FfprobeJson>(json, opts)
?? throw new InvalidOperationException("Invalid ffprobe JSON");
if (probe.Format?.Duration is null)
throw new InvalidOperationException("ffprobe missing duration");
if (probe.Streams is null || probe.Streams.Length == 0)
throw new InvalidOperationException("ffprobe missing audio stream");
var stream = probe.Streams[0];
var durationSeconds = double.Parse(probe.Format.Duration,
System.Globalization.CultureInfo.InvariantCulture);
var sampleRate = stream.Sample_Rate;
var channels = stream.Channels;
var totalSamples = (long)(durationSeconds * sampleRate);
return new AudioProbe
{
SampleRate = sampleRate,
Channels = channels,
Duration = TimeSpan.FromSeconds(durationSeconds),
TotalSamples = totalSamples
};
}
}

View File

@ -9,9 +9,9 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
private const int _sampleRate = 44100; private const int _sampleRate = 44100;
private const int _channels = 2; private const int _channels = 2;
private const double _segmentSeconds = 7.8; private const double _segmentSeconds = 7.8;
private const int _segmentSamples = (int)(_sampleRate * _segmentSeconds); // 343,980 private const int _segmentSamples = (int)(_sampleRate * _segmentSeconds);
private const int _overlap = _segmentSamples / 4; // 85,995 private const int _overlap = _segmentSamples / 4;
private const int _stride = _segmentSamples - _overlap; // 257,985 private const int _stride = _segmentSamples - _overlap;
private static readonly string[] _stemNames = Enum.GetNames<StemType>(); private static readonly string[] _stemNames = Enum.GetNames<StemType>();
@ -26,14 +26,12 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
if (existingStems != null) if (existingStems != null)
return existingStems; return existingStems;
// 1. Load audio
var mix = LoadStereoFloatWave(request.SourceFilePath, out var sr); var mix = LoadStereoFloatWave(request.SourceFilePath, out var sr);
if (sr != _sampleRate) if (sr != _sampleRate)
throw new InvalidOperationException($"Input must be {_sampleRate} Hz"); throw new InvalidOperationException($"Input must be {_sampleRate} Hz");
var totalSamples = mix.GetLength(1); var totalSamples = mix.GetLength(1);
// 2. Prepare ONNX session
var opts = new SessionOptions(); var opts = new SessionOptions();
opts.AppendExecutionProvider_CPU(); opts.AppendExecutionProvider_CPU();
opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL;
@ -41,14 +39,12 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
var modelPath = Path.Combine(AppContext.BaseDirectory, "Data", "htdemucs_6s.onnx"); var modelPath = Path.Combine(AppContext.BaseDirectory, "Data", "htdemucs_6s.onnx");
using var session = new InferenceSession(modelPath, opts); using var session = new InferenceSession(modelPath, opts);
// 3. Prepare buffers
var outStems = new float[_stemNames.Length, _channels, totalSamples]; var outStems = new float[_stemNames.Length, _channels, totalSamples];
var weight = new float[totalSamples]; var weight = new float[totalSamples];
var window = MakeWindow(_segmentSamples, _overlap); var window = MakeWindow(_segmentSamples, _overlap);
var nChunks = Math.Max(1, (totalSamples + _stride - 1) / _stride); var nChunks = Math.Max(1, (totalSamples + _stride - 1) / _stride);
// 4. Sliding window inference
for (var i = 0; i < nChunks; i++) for (var i = 0; i < nChunks; i++)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@ -57,14 +53,10 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
var end = Math.Min(start + _segmentSamples, totalSamples); var end = Math.Min(start + _segmentSamples, totalSamples);
var clen = end - start; var clen = end - start;
// Extract chunk into [2, N]
var chunk = new float[_channels, _segmentSamples]; var chunk = new float[_channels, _segmentSamples];
for (var ch = 0; ch < _channels; ch++) for (var ch = 0; ch < _channels; ch++)
{
Array.Copy(mix, ch * totalSamples + start, chunk, ch * _segmentSamples, clen); Array.Copy(mix, ch * totalSamples + start, chunk, ch * _segmentSamples, clen);
}
// Build flat input buffer (1,2,N)
var inputData = new float[_channels * _segmentSamples]; var inputData = new float[_channels * _segmentSamples];
for (var ch = 0; ch < _channels; ch++) for (var ch = 0; ch < _channels; ch++)
{ {
@ -73,42 +65,31 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
inputData[baseIndex + s] = chunk[ch, s]; inputData[baseIndex + s] = chunk[ch, s];
} }
// Create OrtValue for input
using var inputOrtValue = OrtValue.CreateTensorValueFromMemory( using var inputOrtValue = OrtValue.CreateTensorValueFromMemory(
inputData, inputData,
new long[] { 1, _channels, _segmentSamples } new long[] { 1, _channels, _segmentSamples });
);
// Prepare output buffer (CPU)
var outputData = new float[_stemNames.Length * _channels * _segmentSamples]; var outputData = new float[_stemNames.Length * _channels * _segmentSamples];
// Create OrtValue for output
using var outputOrtValue = OrtValue.CreateTensorValueFromMemory( using var outputOrtValue = OrtValue.CreateTensorValueFromMemory(
outputData, outputData,
new long[] { 1, _stemNames.Length, _channels, _segmentSamples } new long[] { 1, _stemNames.Length, _channels, _segmentSamples });
);
// Bind using IOBinding
using var io = session.CreateIoBinding(); using var io = session.CreateIoBinding();
io.BindInput("mix", inputOrtValue); io.BindInput("mix", inputOrtValue);
io.BindOutput("stems", outputOrtValue); io.BindOutput("stems", outputOrtValue);
// Execute on GPU → output goes directly to CPU buffer
session.RunWithBinding(new RunOptions(), io); session.RunWithBinding(new RunOptions(), io);
// Now outputData contains (1,6,2,N)
var buf = outputData.AsSpan(); var buf = outputData.AsSpan();
// Extract output tensor shape (1, 6, 2, N) var stemCnt = _stemNames.Length;
var stemCnt = _stemNames.Length; // 6 var chCnt = _channels;
var chCnt = _channels; // 2 var length = _segmentSamples;
var length = _segmentSamples; // 343980
// Compute strides for flattened buffer var stemStride = chCnt * length;
var stemStride = chCnt * length; // 2 * N var channelStride = length;
var channelStride = length; // N
// Overlap-add
for (var stem = 0; stem < stemCnt; stem++) for (var stem = 0; stem < stemCnt; stem++)
{ {
for (var ch = 0; ch < chCnt; ch++) for (var ch = 0; ch < chCnt; ch++)
@ -119,7 +100,6 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
{ {
var w = window[s]; var w = window[s];
var v = buf[baseIndex + s]; var v = buf[baseIndex + s];
outStems[stem, ch, start + s] += v * w; outStems[stem, ch, start + s] += v * w;
} }
} }
@ -131,7 +111,6 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
await progress.ReportProgress((double)(i + 1) / nChunks, ct); await progress.ReportProgress((double)(i + 1) / nChunks, ct);
} }
// 5. Normalize by weight
for (var stem = 0; stem < _stemNames.Length; stem++) for (var stem = 0; stem < _stemNames.Length; stem++)
{ {
for (var ch = 0; ch < _channels; ch++) for (var ch = 0; ch < _channels; ch++)
@ -145,12 +124,12 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
} }
} }
// 6. Write stems
var result = new List<StemTrack>(); var result = new List<StemTrack>();
for (var i = 0; i < _stemNames.Length; i++) for (var i = 0; i < _stemNames.Length; i++)
{ {
var name = $"{Path.GetFileNameWithoutExtension(request.SourceFilePath)}_{_stemNames[i]}.flac"; var name = $"{Path.GetFileNameWithoutExtension(request.SourceFilePath)}_{_stemNames[i]}.flac";
var path = Path.Combine(request.OutputDirectory, name); var path = Path.Combine(request.OutputDirectory, name);
WriteFlac(path, outStems, i, totalSamples); WriteFlac(path, outStems, i, totalSamples);
result.Add(new StemTrack result.Add(new StemTrack
@ -174,7 +153,8 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
private StemSet? CheckExistingStems(StemSeparationRequest request) private StemSet? CheckExistingStems(StemSeparationRequest request)
{ {
var filesToCheck = Enum.GetNames(typeof(StemType)) var filesToCheck = Enum.GetNames(typeof(StemType))
.Select(stemType => (stemType, Path.Combine(request.OutputDirectory, $"{Path.GetFileNameWithoutExtension(request.SourceFilePath)}_{stemType}.flac"))) .Select(stemType => (stemType, Path.Combine(request.OutputDirectory,
$"{Path.GetFileNameWithoutExtension(request.SourceFilePath)}_{stemType}.flac")))
.ToList(); .ToList();
var stems = new List<StemTrack>(); var stems = new List<StemTrack>();
@ -188,7 +168,7 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
{ {
using var reader = new AudioFileReader(f.Item2); using var reader = new AudioFileReader(f.Item2);
var stem =new StemTrack stems.Add(new StemTrack
{ {
Type = Enum.Parse<StemType>(f.Item1), Type = Enum.Parse<StemType>(f.Item1),
Name = Path.GetFileName(f.Item2), Name = Path.GetFileName(f.Item2),
@ -196,18 +176,12 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
SampleRate = reader.WaveFormat.SampleRate, SampleRate = reader.WaveFormat.SampleRate,
Channels = reader.WaveFormat.Channels, Channels = reader.WaveFormat.Channels,
Duration = reader.TotalTime Duration = reader.TotalTime
}; });
stems.Add(stem);
} }
return stems.Count > 0 ? set : null; return stems.Count > 0 ? set : null;
} }
// ------------------------------
// Helpers
// ------------------------------
private static float[] MakeWindow(int n, int overlap) private static float[] MakeWindow(int n, int overlap)
{ {
var w = new float[n]; var w = new float[n];
@ -245,59 +219,38 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
return result; return result;
} }
//private static void WriteWave(string path, float[,,] stems, int stemIndex, int totalSamples)
//{
// var format = WaveFormat.CreateIeeeFloatWaveFormat(_sampleRate, _channels);
// using var writer = new WaveFileWriter(path, format);
// for (int i = 0; i < totalSamples; i++)
// {
// writer.WriteSample(stems[stemIndex, 0, i]);
// writer.WriteSample(stems[stemIndex, 1, i]);
// }
//}
private static void WriteFlac(string path, float[,,] stems, int stemIndex, int totalSamples) private static void WriteFlac(string path, float[,,] stems, int stemIndex, int totalSamples)
{ {
var psi = new ProcessStartInfo var cmd =
{
FileName = "ffmpeg",
Arguments =
"-y " + "-y " +
"-f f32le " + // raw float32 little-endian "-f f32le " +
"-ar 44100 " + // sample rate "-ar 44100 " +
"-ac 2 " + // channels "-ac 2 " +
"-i pipe:0 " + // read from stdin "-i pipe:0 " +
"-compression_level 12 " + // max FLAC compression "-compression_level 12 " +
$"\"{path}\"", $"\"{path}\"";
RedirectStandardInput = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
};
using var ff = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start FFmpeg process"); using var ff = new FfmpegProcess(
using var stdin = ff.StandardInput.BaseStream; name: $"flac:{Path.GetFileName(path)}",
commandLine: cmd,
redirectOutput: true,
redirectInput: true);
// Write raw float32 PCM directly to FFmpeg ff.StartProcess();
var buffer = new byte[sizeof(float) * 2]; // stereo frame
var stdin = ff.Stdin!;
var frame = new byte[sizeof(float) * 2];
for (var i = 0; i < totalSamples; i++) for (var i = 0; i < totalSamples; i++)
{ {
BitConverter.TryWriteBytes(buffer.AsSpan(0, 4), stems[stemIndex, 0, i]); BitConverter.TryWriteBytes(frame.AsSpan(0, 4), stems[stemIndex, 0, i]);
BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), stems[stemIndex, 1, i]); BitConverter.TryWriteBytes(frame.AsSpan(4, 4), stems[stemIndex, 1, i]);
stdin.Write(buffer, 0, buffer.Length); stdin.Write(frame, 0, frame.Length);
} }
stdin.Flush(); stdin.Flush();
stdin.Close(); stdin.Close();
ff.WaitForExit(); ff.Proc!.WaitForExit();
} }
} }

View File

@ -9,7 +9,7 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
private readonly int _sampleRate; private readonly int _sampleRate;
private readonly int _channels; private readonly int _channels;
private Process? _ff; private FfmpegProcess? _ff;
private Stream? _stdin; private Stream? _stdin;
private Stream? _stdout; private Stream? _stdout;
@ -29,11 +29,8 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
_sampleRate = sampleRate; _sampleRate = sampleRate;
_channels = channels; _channels = channels;
// Ring buffer: e.g. 1 second of audio
var bytesPerSecond = sampleRate * channels * sizeof(float); var bytesPerSecond = sampleRate * channels * sizeof(float);
_ring = new byte[bytesPerSecond]; _ring = new byte[bytesPerSecond];
StartProcess();
} }
public void Configure(PlaybackSpeedSettings settings) public void Configure(PlaybackSpeedSettings settings)
@ -54,7 +51,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
{ {
var buf = _pool.Rent(expectedFloats); var buf = _pool.Rent(expectedFloats);
Array.Copy(input.Buffer.Samples, buf.Samples, input.Buffer.Length); Array.Copy(input.Buffer.Samples, buf.Samples, input.Buffer.Length);
return new TimeStretchedAudioBlock(buf, input.Frames, _channels, _sampleRate); return new TimeStretchedAudioBlock(buf, input.Frames, _channels, _sampleRate);
} }
@ -72,7 +68,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
var outBytes = MemoryMarshal.AsBytes(outBuf.Span); var outBytes = MemoryMarshal.AsBytes(outBuf.Span);
var readBytes = DrainRing(outBytes, expectedBytes); var readBytes = DrainRing(outBytes, expectedBytes);
if (readBytes <= 0) if (readBytes <= 0)
{ {
outBuf.Dispose(); outBuf.Dispose();
@ -104,38 +99,34 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
Thread.Sleep(2); Thread.Sleep(2);
} }
Debug.WriteLine("Rubberband: Timeout waiting for output from ffmpeg");
return 0; return 0;
} }
private void StartProcess() private void StartProcess()
{ {
var psi = new ProcessStartInfo var cmd =
{
FileName = "ffmpeg",
Arguments =
$"-hide_banner -loglevel error " + $"-hide_banner -loglevel error " +
$"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " + $"-f f32le -ar {_sampleRate} -ac {_channels} -i pipe:0 " +
$"-af \"rubberband=tempo={_speed}\" " + $"-af \"rubberband=tempo={_speed}\" " +
$"-f f32le -ar {_sampleRate} -ac {_channels} pipe:1", $"-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); _ff = new FfmpegProcess(
_stdin = _ff!.StandardInput.BaseStream; name: $"rubberband:{_speed:F3}",
_stdout = _ff!.StandardOutput.BaseStream; commandLine: cmd,
redirectOutput: true,
redirectInput: true);
_ff.StartProcess();
_stdin = _ff.Stdin!;
_stdout = _ff.Stdout!;
_readerRunning = true; _readerRunning = true;
_readerThread = new Thread(ReaderLoop) { IsBackground = true }; _readerThread = new Thread(ReaderLoop) { IsBackground = true };
_readerThread.Start(); _readerThread.Start();
} }
private void RestartProcess() private void RestartProcess()
{ {
DisposeProcess(); DisposeProcess();
@ -152,7 +143,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
while (_readerRunning) while (_readerRunning)
{ {
var read = _stdout!.Read(buf, 0, buf.Length); var read = _stdout!.Read(buf, 0, buf.Length);
if (read <= 0) if (read <= 0)
break; break;
@ -171,7 +161,7 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
} }
} }
} }
catch { /* swallow for now */ } catch { }
} }
private int DrainRing(Span<byte> dest, int maxBytes) private int DrainRing(Span<byte> dest, int maxBytes)
@ -203,7 +193,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
} }
} }
private void ResetRing() private void ResetRing()
{ {
lock (_ringLock) lock (_ringLock)
@ -216,9 +205,9 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
private void DisposeProcess() private void DisposeProcess()
{ {
_readerRunning = false; _readerRunning = false;
try { _stdout?.Close(); } catch { } try { _stdout?.Close(); } catch { }
try { _stdin?.Close(); } catch { } try { _stdin?.Close(); } catch { }
try { _ff?.Kill(); } catch { }
try { _ff?.Dispose(); } catch { } try { _ff?.Dispose(); } catch { }
if (_readerThread != null) if (_readerThread != null)
@ -226,6 +215,10 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
try { _readerThread.Join(500); } catch { } try { _readerThread.Join(500); } catch { }
_readerThread = null; _readerThread = null;
} }
_ff = null;
_stdin = null;
_stdout = null;
} }
public void Dispose() => DisposeProcess(); public void Dispose() => DisposeProcess();

View File

@ -129,8 +129,11 @@ public sealed class FfmpegAudioReader_Tests
WindowStyle = ProcessWindowStyle.Hidden, WindowStyle = ProcessWindowStyle.Hidden,
}; };
using (var p = Process.Start(psi)) using (var p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start FFmpeg process"))
{ {
// Start draining stderr immediately
_ = 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");
} }
@ -204,4 +207,24 @@ public sealed class FfmpegAudioReader_Tests
} }
} }
private static void DrainStderr(Process proc)
{
try
{
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;
while ((line = reader.ReadLine()) != null)
{
Debug.WriteLine(line);
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
}
} }

View File

@ -79,6 +79,8 @@ 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));
var running = true; var running = true;
@ -128,4 +130,24 @@ public sealed class Pipeline_Integration_Tests
Assert.IsGreaterThan(0, read, "FLAC output is not decodable"); Assert.IsGreaterThan(0, read, "FLAC output is not decodable");
} }
private static void DrainStderr(Process proc)
{
try
{
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;
while ((line = reader.ReadLine()) != null)
{
Debug.WriteLine(line);
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
}
} }