feature: Phase 18.1 — derive Opus 320 + seek-index sidecar at ingest
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.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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>"; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user