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,151 @@
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// Derives and persists a track's low-data Ogg Opus artifacts (Phase 18.1). Mirrors
|
||||
/// <see cref="WaveformProfileService"/>'s derived-artifact lifecycle: compute from the stored source,
|
||||
/// store in a dedicated vault keyed by <c>EntryKey</c>, regenerable, failure-tolerant. For one track it
|
||||
/// produces two entries in the <see cref="VaultConstants.TrackOpus"/> vault — the Opus audio bytes and a
|
||||
/// combined setup-header + seek-index sidecar (§3.4a). Strictly additive: the source <c>tracks</c> vault
|
||||
/// is never touched, and a failure here leaves the track lossless-only and eligible for backfill (C2/C6).
|
||||
/// </summary>
|
||||
public sealed class OpusTranscodeService
|
||||
{
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly FfmpegOpusEncoder _encoder;
|
||||
private readonly OpusTranscodeOptions _options;
|
||||
private readonly ILogger<OpusTranscodeService> _logger;
|
||||
|
||||
public OpusTranscodeService(
|
||||
FileDb fileDatabase,
|
||||
FfmpegOpusEncoder encoder,
|
||||
IOptions<OpusTranscodeOptions> options,
|
||||
ILogger<OpusTranscodeService> logger)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_encoder = encoder;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the source audio for <paramref name="entryKey"/> from the <c>tracks</c> vault, transcodes it
|
||||
/// to Ogg Opus 320, walks the encoded stream to build the seek index + capture the setup header, and
|
||||
/// stores the Opus bytes and the sidecar in the <see cref="VaultConstants.TrackOpus"/> vault under the
|
||||
/// same key. Re-runnable — a second call overwrites the prior artifacts (backfill / replace-audio).
|
||||
/// Returns false (logged) on any failure; never throws for expected failure modes (C6). The only
|
||||
/// propagated exception is <see cref="OperationCanceledException"/> on genuine shutdown.
|
||||
/// </summary>
|
||||
public async Task<bool> TranscodeAndStoreAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
var source = await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, entryKey);
|
||||
if (source is null)
|
||||
{
|
||||
_logger.LogWarning("Opus transcode: no source audio in vault for {EntryKey}; skipping.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_options.StagingPath);
|
||||
var sourcePath = Path.Combine(_options.StagingPath, $"opus-src-{Guid.NewGuid():N}{source.Extension}");
|
||||
var opusPath = Path.Combine(_options.StagingPath, $"opus-out-{Guid.NewGuid():N}{OggOpusConstants.OpusExtension}");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(sourcePath, source.Buffer, ct);
|
||||
|
||||
if (!await _encoder.EncodeAsync(sourcePath, opusPath, ct))
|
||||
return false; // encoder already logged the cause
|
||||
|
||||
var opusBytes = await File.ReadAllBytesAsync(opusPath, ct);
|
||||
|
||||
var walk = OggOpusParser.Walk(opusBytes);
|
||||
if (walk is null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Opus transcode: ffmpeg produced output but the Ogg stream could not be walked for {EntryKey}; " +
|
||||
"no artifacts stored.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
await EnsureVaultAsync();
|
||||
|
||||
var opusBitrate = source.Duration > 0
|
||||
? (int)(opusBytes.Length * 8 / source.Duration / 1000)
|
||||
: _options.BitrateKbps;
|
||||
var audioBinary = new AudioBinary(new AudioBinaryParams(
|
||||
opusBytes, opusBytes.Length, OggOpusConstants.OpusExtension, source.Duration, opusBitrate));
|
||||
|
||||
var sidecar = new OpusSidecar(walk.SetupHeaderBytes, walk.SeekIndex).ToBytes();
|
||||
var sidecarBinary = new MediaBinary(new MediaBinaryParams(
|
||||
sidecar, sidecar.Length, OggOpusConstants.SidecarExtension));
|
||||
|
||||
// Store the audio first, then the sidecar. If the sidecar write fails the Opus bytes are
|
||||
// present but unseekable — treat that as a failed derive (return false) so a backfill re-runs
|
||||
// it; do not leave a half-derived track that the delivery layer would treat as complete.
|
||||
var audioStored = await _fileDatabase.RegisterResourceAsync(
|
||||
VaultConstants.TrackOpus, OpusAudioKey(entryKey), audioBinary);
|
||||
if (!audioStored)
|
||||
{
|
||||
_logger.LogError("Opus transcode: vault write of Opus audio failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
var sidecarStored = await _fileDatabase.RegisterResourceAsync(
|
||||
VaultConstants.TrackOpus, OpusSidecarKey(entryKey), sidecarBinary);
|
||||
if (!sidecarStored)
|
||||
{
|
||||
_logger.LogError("Opus transcode: vault write of sidecar failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Opus transcode complete for {EntryKey}: {OpusBytes} bytes, {Points} seek points, {Duration:F1}s.",
|
||||
entryKey, opusBytes.Length, walk.SeekIndex.Points.Count, walk.SeekIndex.TotalDurationSeconds);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Opus transcode failed for {EntryKey}; track stays lossless-only.", entryKey);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(sourcePath);
|
||||
TryDelete(opusPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The vault entry key under which a track's Opus audio bytes are stored.</summary>
|
||||
public static string OpusAudioKey(string entryKey) => entryKey;
|
||||
|
||||
/// <summary>The vault entry key under which a track's setup-header + seek-index sidecar is stored.</summary>
|
||||
public static string OpusSidecarKey(string entryKey) => $"{entryKey}-sidecar";
|
||||
|
||||
private async Task EnsureVaultAsync()
|
||||
{
|
||||
if (!_fileDatabase.HasVault(VaultConstants.TrackOpus))
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
|
||||
}
|
||||
|
||||
private void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Opus transcode: failed to delete staging file {Path}.", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user