using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DeepDrftContent.Processors.Opus; /// /// Encodes a source audio file (any format the source vault holds — WAV/MP3/FLAC) to Ogg Opus fullband /// 320 kbps by shelling out to FFmpeg (libopus). FFmpeg is chosen over a managed encoder because it /// muxes a correct Ogg container with accurate granule positions across every input format — the page /// structure the seek-index walk depends on — which a raw libopus binding does not provide. The external /// ffmpeg binary is therefore a host runtime prerequisite (flagged in the wave handoff). /// public sealed class FfmpegOpusEncoder { private readonly OpusTranscodeOptions _options; private readonly ILogger _logger; public FfmpegOpusEncoder(IOptions options, ILogger logger) { _options = options.Value; _logger = logger; } /// /// Transcodes to an Ogg Opus file at . /// Returns true on a clean exit with a non-empty output. Returns false (logged) on a non-zero exit, /// a timeout, a missing ffmpeg binary, or any process failure — a transcode failure must never throw /// to the caller (C6); the background worker treats false as "leave the track lossless-only". /// public async Task EncodeAsync(string sourcePath, string destinationPath, CancellationToken ct) { var ffmpeg = string.IsNullOrWhiteSpace(_options.FfmpegPath) ? "ffmpeg" : _options.FfmpegPath; // -vn drops any cover-art video stream; -map a:0 takes the first audio stream; -ar 48000 forces // fullband (Opus internally resamples to 48 kHz anyway, but stating it keeps granulepos math // unambiguous); libopus VBR at the target bitrate; -f ogg for an explicit Ogg container; -y // overwrites the (pre-created, empty) destination temp file. var args = new[] { "-hide_banner", "-nostdin", "-loglevel", "error", "-i", sourcePath, "-vn", "-map", "a:0", "-c:a", "libopus", "-b:a", $"{_options.BitrateKbps}k", "-ar", "48000", "-f", "ogg", "-y", destinationPath, }; var psi = new ProcessStartInfo(ffmpeg) { RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; foreach (var arg in args) psi.ArgumentList.Add(arg); using var process = new Process { StartInfo = psi }; try { if (!process.Start()) { _logger.LogError("Opus transcode: ffmpeg failed to start ({Ffmpeg}).", ffmpeg); return false; } } catch (Exception ex) { // Most commonly a missing binary (Win32Exception "file not found"). This is the ops // prerequisite failing — log loudly so it is unmistakable in the deploy logs. _logger.LogError(ex, "Opus transcode: could not launch ffmpeg ({Ffmpeg}). Is the ffmpeg binary installed on the host?", ffmpeg); return false; } using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds)); // Drain stderr concurrently — ffmpeg can block writing diagnostics if the pipe is not read. var stderrTask = process.StandardError.ReadToEndAsync(timeout.Token); try { await process.WaitForExitAsync(timeout.Token); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { TryKill(process); await SafeStderr(stderrTask); // observe to avoid unobserved-task warnings throw; // genuine shutdown cancellation — let it propagate } catch (OperationCanceledException) { TryKill(process); await SafeStderr(stderrTask); // observe to avoid unobserved-task warnings _logger.LogError("Opus transcode: ffmpeg exceeded the {Timeout}s timeout for {Source}.", _options.TimeoutSeconds, sourcePath); return false; } var stderr = await SafeStderr(stderrTask); if (process.ExitCode != 0) { _logger.LogError("Opus transcode: ffmpeg exited {Code} for {Source}. stderr: {Stderr}", process.ExitCode, sourcePath, stderr); return false; } if (!File.Exists(destinationPath) || new FileInfo(destinationPath).Length == 0) { _logger.LogError("Opus transcode: ffmpeg exited 0 but produced no output for {Source}.", sourcePath); return false; } return true; } private void TryKill(Process process) { try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (Exception ex) { _logger.LogWarning(ex, "Opus transcode: failed to kill timed-out ffmpeg process."); } } private static async Task SafeStderr(Task stderrTask) { try { return await stderrTask; } catch { return ""; } } }