33d6f34d8a
Background-job transcode (ffmpeg/libopus) after source store; pure C# Ogg walker builds the 0.5s-bucketed granule→byte seek index + captures the OpusHead/OpusTags setup header into a per-track sidecar in a new track-opus vault. Best-effort, additive, regenerated on replace-audio.
139 lines
5.3 KiB
C#
139 lines
5.3 KiB
C#
using System.Diagnostics;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace DeepDrftContent.Processors.Opus;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>ffmpeg</c> binary is therefore a host runtime prerequisite (flagged in the wave handoff).
|
|
/// </summary>
|
|
public sealed class FfmpegOpusEncoder
|
|
{
|
|
private readonly OpusTranscodeOptions _options;
|
|
private readonly ILogger<FfmpegOpusEncoder> _logger;
|
|
|
|
public FfmpegOpusEncoder(IOptions<OpusTranscodeOptions> options, ILogger<FfmpegOpusEncoder> logger)
|
|
{
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transcodes <paramref name="sourcePath"/> to an Ogg Opus file at <paramref name="destinationPath"/>.
|
|
/// 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".
|
|
/// </summary>
|
|
public async Task<bool> 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);
|
|
throw; // genuine shutdown cancellation — let it propagate
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
TryKill(process);
|
|
_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<string> SafeStderr(Task<string> stderrTask)
|
|
{
|
|
try { return await stderrTask; }
|
|
catch { return "<stderr unavailable>"; }
|
|
}
|
|
}
|