Separate class for ffprobe process. Removed FfmpegPipe class. Reader is lazy now.

This commit is contained in:
Alexander Shabarshov 2026-07-07 11:16:33 +01:00
parent c63c6da255
commit 74057c2006
6 changed files with 203 additions and 129 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;
public int Channels => _pipe.Channels;
public long TotalSamples => _pipe.TotalSamples;
// Lazy process wrapper
private Lazy<FfmpegProcess> _process;
// 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)
{
_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)
=> _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)
=> _pipe.Seek(sampleIndex);
{
_pendingSeekSample = sampleIndex;
DisposeProcessOnly();
_process = CreateLazyProcess(); // new lazy instance
}
public void Reset()
=> Seek(0);
{
Seek(0);
}
private void DisposeProcessOnly()
{
if (_process.IsValueCreated)
{
try { _process.Value.Dispose(); } catch { }
}
}
public void Dispose()
=> _pipe.Dispose();
{
DisposeProcessOnly();
}
}

View File

@ -1,75 +0,0 @@
using System.Diagnostics;
namespace AudioCore.Impl;
public sealed class FfmpegPipe : IDisposable
{
private readonly string _path;
private FfmpegProcess? _process;
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;
TotalSamples = ProbeTotalSamples(path, sampleRate);
StartProcess(0);
}
private void StartProcess(long startSample)
{
var startSeconds = (double)startSample / 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";
_process = new FfmpegProcess(
name: $"pipe:{_path}",
commandLine: cmd,
redirectOutput: true,
redirectInput: true);
_process.StartProcess();
}
public int Read(float[] buffer, int offset, int count)
{
if (_process?.Stdout is null)
return 0;
return _process.Read(buffer, offset, count);
}
public void Seek(long sampleIndex)
{
DisposeProcessOnly();
StartProcess(sampleIndex);
}
public void Reset()
{
Seek(0);
}
private static long ProbeTotalSamples(string path, int sampleRate) => FfmpegProcess.ProbeTotalSamples(path, sampleRate);
private void DisposeProcessOnly()
{
try { _process?.Dispose(); } catch { }
_process = null;
}
public void Dispose()
{
DisposeProcessOnly();
}
}

View File

@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text.Json;
namespace AudioCore.Impl;
@ -23,6 +24,8 @@ public sealed class FfmpegProcess : IDisposable
public void StartProcess()
{
Debug.WriteLine($"{_name}: Starting ffmpeg process");
var psi = new ProcessStartInfo
{
FileName = "ffmpeg",
@ -81,38 +84,17 @@ public sealed class FfmpegProcess : IDisposable
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 { }
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(); } catch { }
try { Proc?.Dispose(); Proc = null; } catch { }
}
public void Dispose()

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

@ -31,8 +31,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
var bytesPerSecond = sampleRate * channels * sizeof(float);
_ring = new byte[bytesPerSecond];
StartProcess();
}
public void Configure(PlaybackSpeedSettings settings)

View File

@ -129,7 +129,7 @@ public sealed class FfmpegAudioReader_Tests
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));