mirror of
https://github.com/unclshura/ABStemPlayer.git
synced 2026-08-07 00:43:38 +00:00
Separate class for Ffmpeg process handling
This commit is contained in:
parent
950e5f1dcc
commit
c63c6da255
@ -5,8 +5,7 @@ namespace AudioCore.Impl;
|
||||
public sealed class FfmpegPipe : IDisposable
|
||||
{
|
||||
private readonly string _path;
|
||||
private Process? _proc;
|
||||
private Stream? _stdout;
|
||||
private FfmpegProcess? _process;
|
||||
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
@ -18,7 +17,6 @@ public sealed class FfmpegPipe : IDisposable
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
|
||||
// Optional: probe duration
|
||||
TotalSamples = ProbeTotalSamples(path, sampleRate);
|
||||
|
||||
StartProcess(0);
|
||||
@ -28,41 +26,28 @@ public sealed class FfmpegPipe : IDisposable
|
||||
{
|
||||
var startSeconds = (double)startSample / SampleRate;
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments =
|
||||
$"-hide_banner -loglevel error " +
|
||||
$"-nostdin " + // prevent console attach
|
||||
var cmd =
|
||||
"-hide_banner -loglevel error " +
|
||||
"-nostdin " +
|
||||
$"-ss {startSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} " +
|
||||
$"-i \"{_path}\" " +
|
||||
$"-f f32le -ac {Channels} -ar {SampleRate} pipe:1",
|
||||
$"-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
|
||||
};
|
||||
_process = new FfmpegProcess(
|
||||
name: $"pipe:{_path}",
|
||||
commandLine: cmd,
|
||||
redirectOutput: true,
|
||||
redirectInput: true);
|
||||
|
||||
_proc = Process.Start(psi);
|
||||
_stdout = _proc!.StandardOutput.BaseStream;
|
||||
_process.StartProcess();
|
||||
}
|
||||
|
||||
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)
|
||||
if (_process?.Stdout is null)
|
||||
return 0;
|
||||
|
||||
Buffer.BlockCopy(tmp, 0, buffer, offset * sizeof(float), readBytes);
|
||||
|
||||
return readBytes / sizeof(float);
|
||||
return _process.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public void Seek(long sampleIndex)
|
||||
@ -71,38 +56,16 @@ public sealed class FfmpegPipe : IDisposable
|
||||
StartProcess(sampleIndex);
|
||||
}
|
||||
|
||||
public void Reset() => Seek(0);
|
||||
|
||||
private static long ProbeTotalSamples(string path, int sampleRate)
|
||||
public void Reset()
|
||||
{
|
||||
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;
|
||||
Seek(0);
|
||||
}
|
||||
|
||||
private static long ProbeTotalSamples(string path, int sampleRate) => FfmpegProcess.ProbeTotalSamples(path, sampleRate);
|
||||
private void DisposeProcessOnly()
|
||||
{
|
||||
try { _stdout?.Dispose(); } catch { }
|
||||
try { if (_proc != null && !_proc.HasExited) _proc.Kill(); } catch { }
|
||||
try { _proc?.Dispose(); } catch { }
|
||||
try { _process?.Dispose(); } catch { }
|
||||
_process = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
122
AudioCore/Impl/FfmpegProcess.cs
Normal file
122
AudioCore/Impl/FfmpegProcess.cs
Normal file
@ -0,0 +1,122 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
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()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public 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 { Stdin?.Dispose(); } catch { }
|
||||
try { Proc?.StandardError.BaseStream?.Dispose(); } catch { }
|
||||
try { if (Proc != null && !Proc.HasExited) Proc.Kill(); } catch { }
|
||||
try { Proc?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeProcessOnly();
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,9 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
private const int _sampleRate = 44100;
|
||||
private const int _channels = 2;
|
||||
private const double _segmentSeconds = 7.8;
|
||||
private const int _segmentSamples = (int)(_sampleRate * _segmentSeconds); // 343,980
|
||||
private const int _overlap = _segmentSamples / 4; // 85,995
|
||||
private const int _stride = _segmentSamples - _overlap; // 257,985
|
||||
private const int _segmentSamples = (int)(_sampleRate * _segmentSeconds);
|
||||
private const int _overlap = _segmentSamples / 4;
|
||||
private const int _stride = _segmentSamples - _overlap;
|
||||
|
||||
private static readonly string[] _stemNames = Enum.GetNames<StemType>();
|
||||
|
||||
@ -26,14 +26,12 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
if (existingStems != null)
|
||||
return existingStems;
|
||||
|
||||
// 1. Load audio
|
||||
var mix = LoadStereoFloatWave(request.SourceFilePath, out var sr);
|
||||
if (sr != _sampleRate)
|
||||
throw new InvalidOperationException($"Input must be {_sampleRate} Hz");
|
||||
|
||||
var totalSamples = mix.GetLength(1);
|
||||
|
||||
// 2. Prepare ONNX session
|
||||
var opts = new SessionOptions();
|
||||
opts.AppendExecutionProvider_CPU();
|
||||
opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL;
|
||||
@ -41,30 +39,24 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
var modelPath = Path.Combine(AppContext.BaseDirectory, "Data", "htdemucs_6s.onnx");
|
||||
using var session = new InferenceSession(modelPath, opts);
|
||||
|
||||
// 3. Prepare buffers
|
||||
var outStems = new float[_stemNames.Length, _channels, totalSamples];
|
||||
var weight = new float[totalSamples];
|
||||
var window = MakeWindow(_segmentSamples, _overlap);
|
||||
|
||||
var nChunks = Math.Max(1, (totalSamples + _stride - 1) / _stride);
|
||||
|
||||
// 4. Sliding window inference
|
||||
for (var i = 0; i < nChunks; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var start = i * _stride;
|
||||
var end = Math.Min(start + _segmentSamples, totalSamples);
|
||||
var clen = end - start;
|
||||
var end = Math.Min(start + _segmentSamples, totalSamples);
|
||||
var clen = end - start;
|
||||
|
||||
// Extract chunk into [2, N]
|
||||
var chunk = new float[_channels, _segmentSamples];
|
||||
for (var ch = 0; ch < _channels; ch++)
|
||||
{
|
||||
Array.Copy(mix, ch * totalSamples + start, chunk, ch * _segmentSamples, clen);
|
||||
}
|
||||
|
||||
// Build flat input buffer (1,2,N)
|
||||
var inputData = new float[_channels * _segmentSamples];
|
||||
for (var ch = 0; ch < _channels; ch++)
|
||||
{
|
||||
@ -73,42 +65,31 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
inputData[baseIndex + s] = chunk[ch, s];
|
||||
}
|
||||
|
||||
// Create OrtValue for input
|
||||
using var inputOrtValue = OrtValue.CreateTensorValueFromMemory(
|
||||
inputData,
|
||||
new long[] { 1, _channels, _segmentSamples }
|
||||
);
|
||||
inputData,
|
||||
new long[] { 1, _channels, _segmentSamples });
|
||||
|
||||
// Prepare output buffer (CPU)
|
||||
var outputData = new float[_stemNames.Length * _channels * _segmentSamples];
|
||||
|
||||
// Create OrtValue for output
|
||||
using var outputOrtValue = OrtValue.CreateTensorValueFromMemory(
|
||||
outputData,
|
||||
new long[] { 1, _stemNames.Length, _channels, _segmentSamples }
|
||||
);
|
||||
outputData,
|
||||
new long[] { 1, _stemNames.Length, _channels, _segmentSamples });
|
||||
|
||||
// Bind using IOBinding
|
||||
using var io = session.CreateIoBinding();
|
||||
io.BindInput("mix", inputOrtValue);
|
||||
io.BindOutput("stems", outputOrtValue);
|
||||
|
||||
// Execute on GPU → output goes directly to CPU buffer
|
||||
session.RunWithBinding(new RunOptions(), io);
|
||||
|
||||
// Now outputData contains (1,6,2,N)
|
||||
var buf = outputData.AsSpan();
|
||||
|
||||
// Extract output tensor shape (1, 6, 2, N)
|
||||
var stemCnt = _stemNames.Length; // 6
|
||||
var chCnt = _channels; // 2
|
||||
var length = _segmentSamples; // 343980
|
||||
var stemCnt = _stemNames.Length;
|
||||
var chCnt = _channels;
|
||||
var length = _segmentSamples;
|
||||
|
||||
// Compute strides for flattened buffer
|
||||
var stemStride = chCnt * length; // 2 * N
|
||||
var channelStride = length; // N
|
||||
var stemStride = chCnt * length;
|
||||
var channelStride = length;
|
||||
|
||||
// Overlap-add
|
||||
for (var stem = 0; stem < stemCnt; stem++)
|
||||
{
|
||||
for (var ch = 0; ch < chCnt; ch++)
|
||||
@ -119,7 +100,6 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
{
|
||||
var w = window[s];
|
||||
var v = buf[baseIndex + s];
|
||||
|
||||
outStems[stem, ch, start + s] += v * w;
|
||||
}
|
||||
}
|
||||
@ -131,7 +111,6 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
await progress.ReportProgress((double)(i + 1) / nChunks, ct);
|
||||
}
|
||||
|
||||
// 5. Normalize by weight
|
||||
for (var stem = 0; stem < _stemNames.Length; stem++)
|
||||
{
|
||||
for (var ch = 0; ch < _channels; ch++)
|
||||
@ -145,36 +124,37 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Write stems
|
||||
var result = new List<StemTrack>();
|
||||
for (var i = 0; i < _stemNames.Length; i++)
|
||||
{
|
||||
var name = $"{Path.GetFileNameWithoutExtension(request.SourceFilePath)}_{_stemNames[i]}.flac";
|
||||
var path = Path.Combine(request.OutputDirectory, name);
|
||||
|
||||
WriteFlac(path, outStems, i, totalSamples);
|
||||
|
||||
result.Add(new StemTrack
|
||||
{
|
||||
Type = Enum.Parse<StemType>(_stemNames[i]),
|
||||
Name = name,
|
||||
FilePath = path,
|
||||
Type = Enum.Parse<StemType>(_stemNames[i]),
|
||||
Name = name,
|
||||
FilePath = path,
|
||||
SampleRate = _sampleRate,
|
||||
Channels = _channels,
|
||||
Duration = TimeSpan.FromSeconds((double)totalSamples / _sampleRate)
|
||||
Channels = _channels,
|
||||
Duration = TimeSpan.FromSeconds((double)totalSamples / _sampleRate)
|
||||
});
|
||||
}
|
||||
|
||||
return new StemSet
|
||||
{
|
||||
OriginalFilePath = request.SourceFilePath,
|
||||
Stems = result
|
||||
};
|
||||
return new StemSet
|
||||
{
|
||||
OriginalFilePath = request.SourceFilePath,
|
||||
Stems = result
|
||||
};
|
||||
}
|
||||
|
||||
private StemSet? CheckExistingStems(StemSeparationRequest request)
|
||||
{
|
||||
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();
|
||||
|
||||
var stems = new List<StemTrack>();
|
||||
@ -188,26 +168,20 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
{
|
||||
using var reader = new AudioFileReader(f.Item2);
|
||||
|
||||
var stem =new StemTrack
|
||||
stems.Add(new StemTrack
|
||||
{
|
||||
Type = Enum.Parse<StemType>(f.Item1),
|
||||
Name = Path.GetFileName(f.Item2),
|
||||
FilePath = f.Item2,
|
||||
Type = Enum.Parse<StemType>(f.Item1),
|
||||
Name = Path.GetFileName(f.Item2),
|
||||
FilePath = f.Item2,
|
||||
SampleRate = reader.WaveFormat.SampleRate,
|
||||
Channels = reader.WaveFormat.Channels,
|
||||
Duration = reader.TotalTime
|
||||
};
|
||||
|
||||
stems.Add(stem);
|
||||
Channels = reader.WaveFormat.Channels,
|
||||
Duration = reader.TotalTime
|
||||
});
|
||||
}
|
||||
|
||||
return stems.Count > 0 ? set : null;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Helpers
|
||||
// ------------------------------
|
||||
|
||||
private static float[] MakeWindow(int n, int overlap)
|
||||
{
|
||||
var w = new float[n];
|
||||
@ -245,59 +219,38 @@ public sealed class Htdemucs6sSeparator : IStemSeparator
|
||||
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)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments =
|
||||
var cmd =
|
||||
"-y " +
|
||||
"-f f32le " + // raw float32 little-endian
|
||||
"-ar 44100 " + // sample rate
|
||||
"-ac 2 " + // channels
|
||||
"-i pipe:0 " + // read from stdin
|
||||
"-compression_level 12 " + // max FLAC compression
|
||||
$"\"{path}\"",
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
};
|
||||
"-f f32le " +
|
||||
"-ar 44100 " +
|
||||
"-ac 2 " +
|
||||
"-i pipe:0 " +
|
||||
"-compression_level 12 " +
|
||||
$"\"{path}\"";
|
||||
|
||||
using var ff = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start FFmpeg process");
|
||||
using var stdin = ff.StandardInput.BaseStream;
|
||||
using var ff = new FfmpegProcess(
|
||||
name: $"flac:{Path.GetFileName(path)}",
|
||||
commandLine: cmd,
|
||||
redirectOutput: true,
|
||||
redirectInput: true);
|
||||
|
||||
// Write raw float32 PCM directly to FFmpeg
|
||||
var buffer = new byte[sizeof(float) * 2]; // stereo frame
|
||||
ff.StartProcess();
|
||||
|
||||
var stdin = ff.Stdin!;
|
||||
var frame = new byte[sizeof(float) * 2];
|
||||
|
||||
for (var i = 0; i < totalSamples; i++)
|
||||
{
|
||||
BitConverter.TryWriteBytes(buffer.AsSpan(0, 4), stems[stemIndex, 0, i]);
|
||||
BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), stems[stemIndex, 1, i]);
|
||||
stdin.Write(buffer, 0, buffer.Length);
|
||||
BitConverter.TryWriteBytes(frame.AsSpan(0, 4), stems[stemIndex, 0, i]);
|
||||
BitConverter.TryWriteBytes(frame.AsSpan(4, 4), stems[stemIndex, 1, i]);
|
||||
stdin.Write(frame, 0, frame.Length);
|
||||
}
|
||||
|
||||
stdin.Flush();
|
||||
stdin.Close();
|
||||
|
||||
ff.WaitForExit();
|
||||
ff.Proc!.WaitForExit();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
private readonly int _sampleRate;
|
||||
private readonly int _channels;
|
||||
|
||||
private Process? _ff;
|
||||
private FfmpegProcess? _ff;
|
||||
private Stream? _stdin;
|
||||
private Stream? _stdout;
|
||||
|
||||
@ -29,7 +29,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
_sampleRate = sampleRate;
|
||||
_channels = channels;
|
||||
|
||||
// Ring buffer: e.g. 1 second of audio
|
||||
var bytesPerSecond = sampleRate * channels * sizeof(float);
|
||||
_ring = new byte[bytesPerSecond];
|
||||
|
||||
@ -48,17 +47,16 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
public TimeStretchedAudioBlock Process(MixedAudioBlock input)
|
||||
{
|
||||
var expectedFloats = input.Frames * _channels;
|
||||
var expectedBytes = expectedFloats * sizeof(float);
|
||||
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 span = input.Buffer.Span;
|
||||
var bytes = MemoryMarshal.AsBytes(span);
|
||||
|
||||
_stdin!.Write(bytes);
|
||||
@ -68,11 +66,10 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
if (available <= 0)
|
||||
return default;
|
||||
|
||||
var outBuf = _pool.Rent(expectedFloats);
|
||||
var outBuf = _pool.Rent(expectedFloats);
|
||||
var outBytes = MemoryMarshal.AsBytes(outBuf.Span);
|
||||
|
||||
var readBytes = DrainRing(outBytes, expectedBytes);
|
||||
|
||||
if (readBytes <= 0)
|
||||
{
|
||||
outBuf.Dispose();
|
||||
@ -94,8 +91,8 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
lock (_ringLock)
|
||||
{
|
||||
var available = (_ringWrite >= _ringRead)
|
||||
? _ringWrite - _ringRead
|
||||
: _ring.Length - _ringRead + _ringWrite;
|
||||
? _ringWrite - _ringRead
|
||||
: _ring.Length - _ringRead + _ringWrite;
|
||||
|
||||
if (available > 0)
|
||||
return available;
|
||||
@ -104,38 +101,34 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
Thread.Sleep(2);
|
||||
}
|
||||
|
||||
Debug.WriteLine("Rubberband: Timeout waiting for output from ffmpeg");
|
||||
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,
|
||||
};
|
||||
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 = System.Diagnostics.Process.Start(psi);
|
||||
_stdin = _ff!.StandardInput.BaseStream;
|
||||
_stdout = _ff!.StandardOutput.BaseStream;
|
||||
_ff = new FfmpegProcess(
|
||||
name: $"rubberband:{_speed:F3}",
|
||||
commandLine: cmd,
|
||||
redirectOutput: true,
|
||||
redirectInput: true);
|
||||
|
||||
_ff.StartProcess();
|
||||
|
||||
_stdin = _ff.Stdin!;
|
||||
_stdout = _ff.Stdout!;
|
||||
|
||||
_readerRunning = true;
|
||||
_readerThread = new Thread(ReaderLoop) { IsBackground = true };
|
||||
_readerThread = new Thread(ReaderLoop) { IsBackground = true };
|
||||
_readerThread.Start();
|
||||
}
|
||||
|
||||
|
||||
private void RestartProcess()
|
||||
{
|
||||
DisposeProcess();
|
||||
@ -152,7 +145,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
while (_readerRunning)
|
||||
{
|
||||
var read = _stdout!.Read(buf, 0, buf.Length);
|
||||
|
||||
if (read <= 0)
|
||||
break;
|
||||
|
||||
@ -171,7 +163,7 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* swallow for now */ }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private int DrainRing(Span<byte> dest, int maxBytes)
|
||||
@ -179,8 +171,8 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
lock (_ringLock)
|
||||
{
|
||||
var available = (_ringWrite >= _ringRead)
|
||||
? _ringWrite - _ringRead
|
||||
: _ring.Length - _ringRead + _ringWrite;
|
||||
? _ringWrite - _ringRead
|
||||
: _ring.Length - _ringRead + _ringWrite;
|
||||
|
||||
if (available <= 0)
|
||||
return 0;
|
||||
@ -203,7 +195,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void ResetRing()
|
||||
{
|
||||
lock (_ringLock)
|
||||
@ -216,9 +207,9 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
private void DisposeProcess()
|
||||
{
|
||||
_readerRunning = false;
|
||||
|
||||
try { _stdout?.Close(); } catch { }
|
||||
try { _stdin?.Close(); } catch { }
|
||||
try { _ff?.Kill(); } catch { }
|
||||
try { _ff?.Dispose(); } catch { }
|
||||
|
||||
if (_readerThread != null)
|
||||
@ -226,6 +217,10 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
||||
try { _readerThread.Join(500); } catch { }
|
||||
_readerThread = null;
|
||||
}
|
||||
|
||||
_ff = null;
|
||||
_stdin = null;
|
||||
_stdout = null;
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeProcess();
|
||||
|
||||
@ -131,6 +131,9 @@ public sealed class FfmpegAudioReader_Tests
|
||||
|
||||
using (var p = Process.Start(psi))
|
||||
{
|
||||
// Start draining stderr immediately
|
||||
_ = Task.Run(() => DrainStderr(p));
|
||||
|
||||
p!.WaitForExit();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -79,6 +79,8 @@ public sealed class Pipeline_Integration_Tests
|
||||
|
||||
using var ff = Process.Start(psi);
|
||||
var stdin = ff!.StandardInput.BaseStream;
|
||||
// Start draining stderr immediately
|
||||
_ = Task.Run(() => DrainStderr(ff));
|
||||
|
||||
var running = true;
|
||||
|
||||
@ -128,4 +130,24 @@ public sealed class Pipeline_Integration_Tests
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user