diff --git a/AudioCore/Impl/FfmpegAudioReader.cs b/AudioCore/Impl/FfmpegAudioReader.cs index 05b8c71..c5722f3 100644 --- a/AudioCore/Impl/FfmpegAudioReader.cs +++ b/AudioCore/Impl/FfmpegAudioReader.cs @@ -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 _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 CreateLazyProcess() => new Lazy(() => + { + 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(); + } } diff --git a/AudioCore/Impl/FfmpegPipe.cs b/AudioCore/Impl/FfmpegPipe.cs deleted file mode 100644 index 24ff8ef..0000000 --- a/AudioCore/Impl/FfmpegPipe.cs +++ /dev/null @@ -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(); - } -} diff --git a/AudioCore/Impl/FfmpegProcess.cs b/AudioCore/Impl/FfmpegProcess.cs index 140a3b6..0e144e3 100644 --- a/AudioCore/Impl/FfmpegProcess.cs +++ b/AudioCore/Impl/FfmpegProcess.cs @@ -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 { } - try { Proc?.StandardError.BaseStream?.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() diff --git a/AudioCore/Impl/FfprobeProcess.cs b/AudioCore/Impl/FfprobeProcess.cs new file mode 100644 index 0000000..9fb4670 --- /dev/null +++ b/AudioCore/Impl/FfprobeProcess.cs @@ -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 + { + 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(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 + }; + } +} \ No newline at end of file diff --git a/AudioCore/Impl/RubberBandTimeStretchEngine.cs b/AudioCore/Impl/RubberBandTimeStretchEngine.cs index 8adefbd..7f252c4 100644 --- a/AudioCore/Impl/RubberBandTimeStretchEngine.cs +++ b/AudioCore/Impl/RubberBandTimeStretchEngine.cs @@ -6,22 +6,22 @@ namespace AudioCore.Impl; public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposable { private readonly AudioBufferPool _pool; - private readonly int _sampleRate; - private readonly int _channels; + private readonly int _sampleRate; + private readonly int _channels; - private FfmpegProcess? _ff; - private Stream? _stdin; - private Stream? _stdout; + private FfmpegProcess? _ff; + private Stream? _stdin; + private Stream? _stdout; - private float _speed = 1.0f; + private float _speed = 1.0f; - private readonly byte[] _ring; - private int _ringWrite; - private int _ringRead; - private readonly object _ringLock = new(); + private readonly byte[] _ring; + private int _ringWrite; + private int _ringRead; + private readonly object _ringLock = new(); - private Thread? _readerThread; - private bool _readerRunning; + private Thread? _readerThread; + private bool _readerRunning; public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int channels = 2) { @@ -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) diff --git a/AudioCore_Tests/FfmpegAudioReader_Tests.cs b/AudioCore_Tests/FfmpegAudioReader_Tests.cs index 7444bd7..9e4d5d9 100644 --- a/AudioCore_Tests/FfmpegAudioReader_Tests.cs +++ b/AudioCore_Tests/FfmpegAudioReader_Tests.cs @@ -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));