From 33d6f34d8a7e19ddea004549ed8ca55a19341393 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 06:30:10 -0400 Subject: [PATCH 01/54] =?UTF-8?q?feature:=20Phase=2018.1=20=E2=80=94=20der?= =?UTF-8?q?ive=20Opus=20320=20+=20seek-index=20sidecar=20at=20ingest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DeepDrftAPI/Program.cs | 8 + .../Services/Opus/IOpusTranscodeQueue.cs | 18 ++ .../Opus/OpusTranscodeBackgroundService.cs | 72 +++++ DeepDrftAPI/Services/UnifiedTrackService.cs | 14 + DeepDrftAPI/Startup.cs | 22 ++ DeepDrftContent/Constants/VaultConstants.cs | 9 + .../FileDatabase/Models/MediaModels.cs | 1 + .../Processors/Opus/FfmpegOpusEncoder.cs | 138 ++++++++ .../Processors/Opus/OggOpusConstants.cs | 50 +++ .../Processors/Opus/OggOpusParser.cs | 124 +++++++ .../Processors/Opus/OggOpusSeekIndex.cs | 93 ++++++ .../Processors/Opus/OpusSidecar.cs | 57 ++++ .../Processors/Opus/OpusTranscodeOptions.cs | 33 ++ .../Processors/Opus/OpusTranscodeService.cs | 151 +++++++++ DeepDrftTests/NoOpOpusTranscodeQueue.cs | 16 + DeepDrftTests/OggOpusParserTests.cs | 304 ++++++++++++++++++ .../UploadDuplicateDetectionTests.cs | 1 + 17 files changed, 1111 insertions(+) create mode 100644 DeepDrftAPI/Services/Opus/IOpusTranscodeQueue.cs create mode 100644 DeepDrftAPI/Services/Opus/OpusTranscodeBackgroundService.cs create mode 100644 DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs create mode 100644 DeepDrftContent/Processors/Opus/OggOpusConstants.cs create mode 100644 DeepDrftContent/Processors/Opus/OggOpusParser.cs create mode 100644 DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs create mode 100644 DeepDrftContent/Processors/Opus/OpusSidecar.cs create mode 100644 DeepDrftContent/Processors/Opus/OpusTranscodeOptions.cs create mode 100644 DeepDrftContent/Processors/Opus/OpusTranscodeService.cs create mode 100644 DeepDrftTests/NoOpOpusTranscodeQueue.cs create mode 100644 DeepDrftTests/OggOpusParserTests.cs diff --git a/DeepDrftAPI/Program.cs b/DeepDrftAPI/Program.cs index 57ae729..6838e2a 100644 --- a/DeepDrftAPI/Program.cs +++ b/DeepDrftAPI/Program.cs @@ -4,6 +4,7 @@ using DeepDrftAPI; using DeepDrftAPI.Middleware; using DeepDrftAPI.Models; using DeepDrftAPI.Services; +using DeepDrftAPI.Services.Opus; using DeepDrftData; using DeepDrftData.Data; using DeepDrftData.Repositories; @@ -66,6 +67,13 @@ builder.Services .AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); +// Background Opus transcode (Phase 18.1, OQ6). One singleton is both the enqueue seam +// (IOpusTranscodeQueue, injected into the scoped UnifiedTrackService) and the hosted drain loop +// (IHostedService). It resolves OpusTranscodeService — a singleton — so no scope is captured. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + // Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL). // EventManager is the IEventService boundary; EventRepository owns the EF writes and the // release-resolution + counter-bump transaction. diff --git a/DeepDrftAPI/Services/Opus/IOpusTranscodeQueue.cs b/DeepDrftAPI/Services/Opus/IOpusTranscodeQueue.cs new file mode 100644 index 0000000..b30f317 --- /dev/null +++ b/DeepDrftAPI/Services/Opus/IOpusTranscodeQueue.cs @@ -0,0 +1,18 @@ +namespace DeepDrftAPI.Services.Opus; + +/// +/// The enqueue seam for the background Opus transcode (OQ6 / §3.1a). +/// depends only on this thin interface — not on the worker — so adding the background derive to the +/// upload/replace paths costs one small dependency, not the whole transcode graph. Enqueuing is +/// non-blocking and best-effort: a freshly uploaded track is already persisted and playable losslessly +/// before anything is enqueued, and the transcode runs off the request thread. +/// +public interface IOpusTranscodeQueue +{ + /// + /// Schedules a background Opus derive for the track identified by . Returns + /// immediately. A dropped or failed enqueue must not affect the caller — the track remains + /// lossless-only and eligible for backfill. + /// + void Enqueue(string entryKey); +} diff --git a/DeepDrftAPI/Services/Opus/OpusTranscodeBackgroundService.cs b/DeepDrftAPI/Services/Opus/OpusTranscodeBackgroundService.cs new file mode 100644 index 0000000..2ff077f --- /dev/null +++ b/DeepDrftAPI/Services/Opus/OpusTranscodeBackgroundService.cs @@ -0,0 +1,72 @@ +using System.Threading.Channels; +using DeepDrftContent.Processors.Opus; + +namespace DeepDrftAPI.Services.Opus; + +/// +/// The background worker behind (OQ6 / §3.1a). An unbounded in-process +/// channel buffers EntryKeys enqueued by the upload and replace-audio paths; a single hosted loop drains +/// them one at a time and runs for each. Serial +/// by design — a transcode is CPU-heavy (§3.1), so running them concurrently would starve request +/// handling; one-at-a-time keeps the derive strictly off the hot path without saturating the host. +/// +/// This worker IS the queue (implements ) so enqueue and drain share one +/// channel with no extra indirection. It is registered as a singleton and surfaced under both the +/// interface and . +/// +public sealed class OpusTranscodeBackgroundService : BackgroundService, IOpusTranscodeQueue +{ + private readonly Channel _channel = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + private readonly OpusTranscodeService _transcodeService; + private readonly ILogger _logger; + + public OpusTranscodeBackgroundService( + OpusTranscodeService transcodeService, + ILogger logger) + { + _transcodeService = transcodeService; + _logger = logger; + } + + public void Enqueue(string entryKey) + { + if (string.IsNullOrWhiteSpace(entryKey)) + return; + + if (!_channel.Writer.TryWrite(entryKey)) + { + // Unbounded writer only rejects after Complete(), i.e. during shutdown. The track stays + // lossless-only and is eligible for backfill, so a dropped enqueue is non-fatal — log it. + _logger.LogWarning("Opus transcode: could not enqueue {EntryKey} (queue closed).", entryKey); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var entryKey in _channel.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await _transcodeService.TranscodeAndStoreAsync(entryKey, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; // host shutting down + } + catch (Exception ex) + { + // TranscodeAndStoreAsync already swallows expected failures; this guards the loop against + // anything unexpected so one bad track never kills the worker. + _logger.LogError(ex, "Opus transcode: unhandled failure draining {EntryKey}; worker continues.", entryKey); + } + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + _channel.Writer.TryComplete(); + return base.StopAsync(cancellationToken); + } +} diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 5c7ef52..f1ef417 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -1,3 +1,4 @@ +using DeepDrftAPI.Services.Opus; using DeepDrftContent; using DeepDrftContent.Constants; using DeepDrftContent.Processors; @@ -39,6 +40,7 @@ public class UnifiedTrackService private readonly ITrackService _sqlTrackService; private readonly FileDb _fileDatabase; private readonly WaveformProfileService _waveformProfileService; + private readonly IOpusTranscodeQueue _opusTranscodeQueue; private readonly ILogger _logger; public UnifiedTrackService( @@ -46,12 +48,14 @@ public class UnifiedTrackService ITrackService sqlTrackService, FileDb fileDatabase, WaveformProfileService waveformProfileService, + IOpusTranscodeQueue opusTranscodeQueue, ILogger logger) { _contentTrackContentService = contentTrackContentService; _sqlTrackService = sqlTrackService; _fileDatabase = fileDatabase; _waveformProfileService = waveformProfileService; + _opusTranscodeQueue = opusTranscodeQueue; _logger = logger; } @@ -219,6 +223,11 @@ public class UnifiedTrackService // frontend, so a failure here is logged and swallowed — never fails the upload. await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct); + // Schedule the low-data Opus derive (OQ6 / §3.1a): the track is persisted and lossless-playable + // NOW; the transcode + seek-index build run on a background worker. Non-blocking and best-effort + // — the upload response never waits on it, and a transcode failure leaves the track lossless-only. + _opusTranscodeQueue.Enqueue(unpersisted.EntryKey); + return saveResult; } @@ -303,6 +312,11 @@ public class UnifiedTrackService return Result.CreateFailResult("Audio replaced but duration metadata could not be updated."); } + // The stale Opus artifact (if any) no longer matches the new source. Schedule a background + // regenerate — the transcode service overwrites the prior artifacts in place keyed by the same + // EntryKey. Best-effort, off the request thread, mirrors the waveform regen above. + _opusTranscodeQueue.Enqueue(entryKey); + return Result.CreatePassResult(); } diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index f735fd1..b45fe56 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -4,6 +4,7 @@ using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Models; using DeepDrftContent.FileDatabase.Services; using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; using Microsoft.Extensions.Logging; using NetBlocks.Utilities.Environment; @@ -44,6 +45,7 @@ namespace DeepDrftAPI InitializeTrackVault(db).GetAwaiter().GetResult(); InitializeImageVault(db).GetAwaiter().GetResult(); InitializeTrackWaveformsVault(db).GetAwaiter().GetResult(); + InitializeTrackOpusVault(db).GetAwaiter().GetResult(); return db; }); @@ -65,6 +67,16 @@ namespace DeepDrftAPI Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath); builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath)); + // Opus low-data transcode (Phase 18.1). The domain service lives in DeepDrftContent; the host + // owns only the engine config and the background worker. Bitrate/ffmpeg-path come from the + // OpusTranscode config section; StagingPath is forced to the same data-disk staging directory + // the upload path uses so large transcode temp files never land on the /tmp tmpfs. + builder.Services.Configure( + builder.Configuration.GetSection(nameof(OpusTranscodeOptions))); + builder.Services.PostConfigure(o => o.StagingPath = stagingPath); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + return Task.CompletedTask; } @@ -107,5 +119,15 @@ namespace DeepDrftAPI await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media); } } + + // Ensure the track-opus vault exists (Phase 18.1). Holds the derived low-data Ogg Opus artifacts + // — the Opus audio bytes and the setup-header + seek-index sidecar — keyed by the track's EntryKey. + private static async Task InitializeTrackOpusVault(FileDatabase fileDatabase) + { + if (!fileDatabase.HasVault(VaultConstants.TrackOpus)) + { + await fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio); + } + } } } \ No newline at end of file diff --git a/DeepDrftContent/Constants/VaultConstants.cs b/DeepDrftContent/Constants/VaultConstants.cs index ba1d5fc..906b74e 100644 --- a/DeepDrftContent/Constants/VaultConstants.cs +++ b/DeepDrftContent/Constants/VaultConstants.cs @@ -28,4 +28,13 @@ public static class VaultConstants /// The datum resolution is duration-derived (≈333 samples/sec, see WaveformResolution). /// public const string TrackWaveforms = "track-waveforms"; + + /// + /// Vault name for the derived low-data Ogg Opus artifacts, keyed by the track's EntryKey (Phase 18, + /// S2). Holds two entries per track: the Opus audio bytes (.opus) and the combined setup-header + /// + granule→byte seek-index sidecar (.opusidx). Both are best-effort derived artifacts — + /// regenerable, and a track without them still plays losslessly. Distinct from the source tracks + /// vault so the source means exactly one thing (mirrors the track-waveforms precedent). + /// + public const string TrackOpus = "track-opus"; } \ No newline at end of file diff --git a/DeepDrftContent/FileDatabase/Models/MediaModels.cs b/DeepDrftContent/FileDatabase/Models/MediaModels.cs index 44cc961..703d7a5 100644 --- a/DeepDrftContent/FileDatabase/Models/MediaModels.cs +++ b/DeepDrftContent/FileDatabase/Models/MediaModels.cs @@ -206,6 +206,7 @@ public static class MimeTypeExtensions { ".flac", "audio/flac" }, { ".aac", "audio/aac" }, { ".ogg", "audio/ogg" }, + { ".opus", "audio/ogg" }, { ".m4a", "audio/mp4" } }; diff --git a/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs b/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs new file mode 100644 index 0000000..4e7f7e1 --- /dev/null +++ b/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs @@ -0,0 +1,138 @@ +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); + 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 SafeStderr(Task stderrTask) + { + try { return await stderrTask; } + catch { return ""; } + } +} diff --git a/DeepDrftContent/Processors/Opus/OggOpusConstants.cs b/DeepDrftContent/Processors/Opus/OggOpusConstants.cs new file mode 100644 index 0000000..f6fa431 --- /dev/null +++ b/DeepDrftContent/Processors/Opus/OggOpusConstants.cs @@ -0,0 +1,50 @@ +namespace DeepDrftContent.Processors.Opus; + +/// +/// Wire-format constants for the Ogg-Opus derived artifacts. Centralised so the seek-index codec, +/// the page walker, and the tests agree on one set of magic numbers. +/// +public static class OggOpusConstants +{ + /// Opus granule positions are always sample counts at 48 kHz, regardless of input rate. + public const double OpusSampleRate = 48000.0; + + /// One seek-index entry per this many seconds of audio (OQ7 — 0.5 s buckets). + public const double SeekBucketSeconds = 0.5; + + /// The Ogg page capture pattern "OggS" — every page starts with these four bytes. + public static ReadOnlySpan CapturePattern => "OggS"u8; + + /// Magic signature opening an OpusHead identification header packet. + public static ReadOnlySpan OpusHeadSignature => "OpusHead"u8; + + /// Magic signature opening an OpusTags comment header packet. + public static ReadOnlySpan OpusTagsSignature => "OpusTags"u8; + + /// + /// Fixed size of an Ogg page header before the segment table: capture(4) + version(1) + + /// header-type(1) + granulepos(8) + serial(4) + sequence(4) + checksum(4) + page-segments(1). + /// + public const int OggPageHeaderSize = 27; + + /// Byte offset of the 64-bit granule position within an Ogg page header. + public const int GranulePositionOffset = 6; + + /// Byte offset of the page-segment count (the segment-table length) within the header. + public const int PageSegmentCountOffset = 26; + + /// Sentinel granule position for a page that ends mid-packet (no usable timestamp). + public const ulong NoGranulePosition = 0xFFFFFFFFFFFFFFFFUL; + + /// Header size of the serialized seek-index blob: totalBytes(8) + duration(8) + count(4). + public const int SeekIndexHeaderSize = 20; + + /// Size of one serialized seek point: granulepos(8) + byteOffset(8). + public const int SeekPointSize = 16; + + /// Vault-resource extension for the Opus audio bytes. + public const string OpusExtension = ".opus"; + + /// Vault-resource extension for the combined setup-header + seek-index sidecar. + public const string SidecarExtension = ".opusidx"; +} diff --git a/DeepDrftContent/Processors/Opus/OggOpusParser.cs b/DeepDrftContent/Processors/Opus/OggOpusParser.cs new file mode 100644 index 0000000..98452b4 --- /dev/null +++ b/DeepDrftContent/Processors/Opus/OggOpusParser.cs @@ -0,0 +1,124 @@ +using System.Buffers.Binary; + +namespace DeepDrftContent.Processors.Opus; + +/// +/// The result of walking an encoded Ogg Opus stream once: the captured setup header (the leading +/// OpusHead + OpusTags pages, verbatim) and the bucketed granule→byte seek index. This +/// is everything the sidecar artifact carries (§3.4a) — built at transcode time so delivery never +/// re-walks the stream. +/// +/// The leading setup pages (OpusHead + OpusTags), exactly as they +/// appear at the start of the stream, ready to prepend to any mid-stream page run before decode. +/// The accurate, 0.5 s-bucketed granule→byte transfer function. +public sealed record OggOpusWalk(byte[] SetupHeaderBytes, OggOpusSeekIndex SeekIndex); + +/// +/// Pure Ogg-Opus stream walker. Reads the page structure directly (the OggS capture pattern and +/// the 27-byte page header) to (1) capture the setup-header pages and (2) record, for every audio page, +/// its end granule position and exact byte offset — bucketed to 0.5 s with each bucket boundary snapped +/// to the nearest enclosing page start. No external dependency: the encoder (FFmpeg) produces the bytes; +/// this turns them into the seek artifact deterministically, so it is unit-testable without a codec. +/// +public static class OggOpusParser +{ + /// + /// Walks and produces the setup header + seek index, or null if the + /// bytes are not a recognisable Ogg Opus stream (no setup header, no audio pages, or truncated + /// structure). A null is the caller's signal to treat the transcode as failed and leave the track + /// lossless-only (C6) — it does not throw for malformed input. + /// + public static OggOpusWalk? Walk(ReadOnlySpan oggBytes) + { + var setupHeaderEnd = -1; + var sawOpusHead = false; + var sawOpusTags = false; + + var points = new List(); + ulong lastGranule = 0; + var nextBucketTime = 0.0; + var firstAudioPointTaken = false; + + var offset = 0; + while (offset + OggOpusConstants.OggPageHeaderSize <= oggBytes.Length) + { + var page = oggBytes.Slice(offset); + if (!page[..4].SequenceEqual(OggOpusConstants.CapturePattern)) + { + // Not on a page boundary — the encoder writes contiguous pages, so this means the + // stream is malformed or we mis-stepped. Either way it is unrecoverable here. + return null; + } + + var segmentCount = page[OggOpusConstants.PageSegmentCountOffset]; + var segmentTableEnd = OggOpusConstants.OggPageHeaderSize + segmentCount; + if (segmentTableEnd > page.Length) + return null; // truncated header + + var payloadSize = 0; + for (var i = 0; i < segmentCount; i++) + payloadSize += page[OggOpusConstants.OggPageHeaderSize + i]; + + var pageTotalSize = segmentTableEnd + payloadSize; + if (pageTotalSize > page.Length) + return null; // truncated payload + + var payload = page.Slice(segmentTableEnd, payloadSize); + var granule = BinaryPrimitives.ReadUInt64LittleEndian( + page.Slice(OggOpusConstants.GranulePositionOffset, 8)); + + // The setup pages carry no audio granule (OpusHead has granulepos 0; OpusTags too). They + // are the leading pages whose payload opens with the Opus magic signatures. + if (!sawOpusHead && StartsWith(payload, OggOpusConstants.OpusHeadSignature)) + { + sawOpusHead = true; + setupHeaderEnd = offset + pageTotalSize; + } + else if (sawOpusHead && !sawOpusTags && StartsWith(payload, OggOpusConstants.OpusTagsSignature)) + { + sawOpusTags = true; + setupHeaderEnd = offset + pageTotalSize; + } + else if (sawOpusHead && sawOpusTags) + { + // Audio page. Record the first audio page unconditionally (the seek anchor at t=0), + // then one entry per 0.5 s bucket. A page with no end-granule (mid-packet continuation, + // granulepos == -1) is skipped for indexing — its time is unknown — but still advances + // the byte cursor. + if (granule != OggOpusConstants.NoGranulePosition) + { + var pageTime = granule / OggOpusConstants.OpusSampleRate; + if (!firstAudioPointTaken) + { + points.Add(new OpusSeekPoint(granule, (ulong)offset)); + firstAudioPointTaken = true; + nextBucketTime = OggOpusConstants.SeekBucketSeconds; + } + else if (pageTime >= nextBucketTime) + { + points.Add(new OpusSeekPoint(granule, (ulong)offset)); + // Advance past every bucket this page crossed so a long page does not emit a + // backlog of entries; the next bucket is the first boundary strictly after it. + while (nextBucketTime <= pageTime) + nextBucketTime += OggOpusConstants.SeekBucketSeconds; + } + + lastGranule = granule; + } + } + + offset += pageTotalSize; + } + + if (!sawOpusHead || setupHeaderEnd < 0 || points.Count == 0) + return null; + + var setupHeader = oggBytes[..setupHeaderEnd].ToArray(); + var totalDuration = lastGranule / OggOpusConstants.OpusSampleRate; + var index = new OggOpusSeekIndex(points, totalDuration, (ulong)oggBytes.Length); + return new OggOpusWalk(setupHeader, index); + } + + private static bool StartsWith(ReadOnlySpan payload, ReadOnlySpan signature) => + payload.Length >= signature.Length && payload[..signature.Length].SequenceEqual(signature); +} diff --git a/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs b/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs new file mode 100644 index 0000000..b5bff4c --- /dev/null +++ b/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs @@ -0,0 +1,93 @@ +using System.Buffers.Binary; + +namespace DeepDrftContent.Processors.Opus; + +/// +/// A single seek-index entry: an authoritative 48 kHz (Opus granule +/// positions are always sample counts at 48 kHz, so time = granulepos / 48000) paired with the exact +/// byte offset of the Ogg page that carries it. Every is a real page-start +/// boundary, so a Range: bytes={ByteOffset}- fetch lands the decoder Ogg-sync-aligned. +/// +/// The page's end granule position (48 kHz sample count). +/// The byte offset of the page start in the Opus file. +public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOffset) +{ + /// Time in seconds this granule position represents (granulepos / 48 kHz). + public double TimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate; +} + +/// +/// The accurate, precomputed transfer function from seek-time to true file byte offset for one Ogg +/// Opus stream (§3.4a A). Built once at transcode time by walking the encoded stream; the client reads +/// it back and binary-searches instead of doing inaccurate VBR byte-rate math. +/// One entry per 0.5 s of audio (), each snapped to the +/// nearest enclosing page start, plus the totals needed to clamp a seek to range. +/// +/// Ordered (granulepos, byteOffset) entries, ascending. The first is always the +/// first audio page (offset just past the setup headers). +/// Total stream duration from the final granule position. +/// Total Opus file byte length, for clamping a seek past the end. +public sealed record OggOpusSeekIndex( + IReadOnlyList Points, + double TotalDurationSeconds, + ulong TotalByteLength) +{ + /// + /// Serializes the index to the compact little-endian binary blob the sidecar stores. Layout: + /// [uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount] then + /// pointCount × (uint64 granulepos, uint64 byteOffset). Fixed-width records keep the client + /// parse to a single typed-array read. + /// + public byte[] ToBytes() + { + var size = OggOpusConstants.SeekIndexHeaderSize + Points.Count * OggOpusConstants.SeekPointSize; + var bytes = new byte[size]; + var span = bytes.AsSpan(); + + BinaryPrimitives.WriteUInt64LittleEndian(span[..8], TotalByteLength); + BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(8, 8), TotalDurationSeconds); + BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16, 4), (uint)Points.Count); + + var cursor = OggOpusConstants.SeekIndexHeaderSize; + foreach (var point in Points) + { + BinaryPrimitives.WriteUInt64LittleEndian(span.Slice(cursor, 8), point.GranulePosition); + BinaryPrimitives.WriteUInt64LittleEndian(span.Slice(cursor + 8, 8), point.ByteOffset); + cursor += OggOpusConstants.SeekPointSize; + } + + return bytes; + } + + /// + /// Parses a blob produced by . Returns null if the blob is too short or its + /// declared point count does not fit — the storage contract is exact, so a malformed blob is a + /// corruption signal, not a recoverable shape. (Provided so tests and any future server-side reader + /// share one codec with the writer.) + /// + public static OggOpusSeekIndex? FromBytes(ReadOnlySpan bytes) + { + if (bytes.Length < OggOpusConstants.SeekIndexHeaderSize) + return null; + + var totalByteLength = BinaryPrimitives.ReadUInt64LittleEndian(bytes[..8]); + var totalDuration = BinaryPrimitives.ReadDoubleLittleEndian(bytes.Slice(8, 8)); + var count = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(16, 4)); + + var expected = OggOpusConstants.SeekIndexHeaderSize + (long)count * OggOpusConstants.SeekPointSize; + if (bytes.Length < expected) + return null; + + var points = new OpusSeekPoint[count]; + var cursor = OggOpusConstants.SeekIndexHeaderSize; + for (var i = 0; i < count; i++) + { + var granule = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(cursor, 8)); + var offset = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(cursor + 8, 8)); + points[i] = new OpusSeekPoint(granule, offset); + cursor += OggOpusConstants.SeekPointSize; + } + + return new OggOpusSeekIndex(points, totalDuration, totalByteLength); + } +} diff --git a/DeepDrftContent/Processors/Opus/OpusSidecar.cs b/DeepDrftContent/Processors/Opus/OpusSidecar.cs new file mode 100644 index 0000000..07cd594 --- /dev/null +++ b/DeepDrftContent/Processors/Opus/OpusSidecar.cs @@ -0,0 +1,57 @@ +using System.Buffers.Binary; + +namespace DeepDrftContent.Processors.Opus; + +/// +/// The single derived sidecar artifact per track (§3.4a B, recommended design): the Opus setup header +/// (OpusHead + OpusTags) followed by the granule→byte seek index. The client fetches this +/// once on track load and parses it into its OpusSeekData, so it always has both the setup bytes +/// (to prepend to any mid-stream slice) and the accurate seek transfer function before it ever issues a +/// Range fetch — including a window that opens away from byte 0 (UC9). +/// +/// The verbatim OpusHead + OpusTags pages. +/// The bucketed granule→byte seek index. +public sealed record OpusSidecar(byte[] SetupHeaderBytes, OggOpusSeekIndex SeekIndex) +{ + /// + /// Serializes to [uint32 setupHeaderLength][setup-header bytes][seek-index blob]. The + /// length prefix lets the client split the two regions with one read; the seek-index blob carries + /// its own self-describing header (), so it needs no trailing + /// length. + /// + public byte[] ToBytes() + { + var indexBytes = SeekIndex.ToBytes(); + var bytes = new byte[4 + SetupHeaderBytes.Length + indexBytes.Length]; + var span = bytes.AsSpan(); + + BinaryPrimitives.WriteUInt32LittleEndian(span[..4], (uint)SetupHeaderBytes.Length); + SetupHeaderBytes.CopyTo(span.Slice(4)); + indexBytes.CopyTo(span.Slice(4 + SetupHeaderBytes.Length)); + + return bytes; + } + + /// + /// Parses a blob produced by . Returns null on any structural inconsistency + /// (short blob, length prefix that overruns, or an unparseable index) — the format is exact, so a + /// malformed blob is corruption. + /// + public static OpusSidecar? FromBytes(ReadOnlySpan bytes) + { + if (bytes.Length < 4) + return null; + + var setupLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes[..4]); + var indexStart = 4 + (long)setupLength; + if (bytes.Length < indexStart) + return null; + + var setupHeader = bytes.Slice(4, (int)setupLength).ToArray(); + var index = OggOpusSeekIndex.FromBytes(bytes.Slice((int)indexStart)); + if (index is null) + return null; + + return new OpusSidecar(setupHeader, index); + } +} diff --git a/DeepDrftContent/Processors/Opus/OpusTranscodeOptions.cs b/DeepDrftContent/Processors/Opus/OpusTranscodeOptions.cs new file mode 100644 index 0000000..ac81a7a --- /dev/null +++ b/DeepDrftContent/Processors/Opus/OpusTranscodeOptions.cs @@ -0,0 +1,33 @@ +namespace DeepDrftContent.Processors.Opus; + +/// +/// Host-supplied configuration for the Opus transcode. The only operationally significant knob is +/// — the transcode shells out to FFmpeg (libopus), which must be present on the +/// DeepDrftAPI host (see the wave handoff notes). Defaults target Ogg Opus fullband (48 kHz) at 320 kbps, +/// the artifact the spec fixes (§1). +/// +public sealed class OpusTranscodeOptions +{ + /// + /// Path to the ffmpeg executable. Empty/null resolves to "ffmpeg" (found on PATH). Override + /// with an absolute path when the binary is not on the host PATH. + /// + public string FfmpegPath { get; set; } = "ffmpeg"; + + /// Target Opus bitrate in kbps. 320 kbps fullband is the fixed artifact quality (§1). + public int BitrateKbps { get; set; } = 320; + + /// + /// Directory for the transient source/output files the transcode stages. Defaults to the system + /// temp path; the host overrides it to the data-disk upload-staging directory so large files never + /// land on the small RAM-backed /tmp tmpfs (same constraint the upload path already honours). + /// + public string StagingPath { get; set; } = Path.GetTempPath(); + + /// + /// Hard ceiling on a single transcode, in seconds. A run that exceeds it is killed and the track + /// stays lossless-only (C6). Generous by default — a 1 GB mix is CPU-expensive (§3.1) — but bounded + /// so a hung ffmpeg never wedges the background worker. + /// + public int TimeoutSeconds { get; set; } = 3600; +} diff --git a/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs b/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs new file mode 100644 index 0000000..712344c --- /dev/null +++ b/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs @@ -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; + +/// +/// Derives and persists a track's low-data Ogg Opus artifacts (Phase 18.1). Mirrors +/// 's derived-artifact lifecycle: compute from the stored source, +/// store in a dedicated vault keyed by EntryKey, regenerable, failure-tolerant. For one track it +/// produces two entries in the vault — the Opus audio bytes and a +/// combined setup-header + seek-index sidecar (§3.4a). Strictly additive: the source tracks vault +/// is never touched, and a failure here leaves the track lossless-only and eligible for backfill (C2/C6). +/// +public sealed class OpusTranscodeService +{ + private readonly FileDb _fileDatabase; + private readonly FfmpegOpusEncoder _encoder; + private readonly OpusTranscodeOptions _options; + private readonly ILogger _logger; + + public OpusTranscodeService( + FileDb fileDatabase, + FfmpegOpusEncoder encoder, + IOptions options, + ILogger logger) + { + _fileDatabase = fileDatabase; + _encoder = encoder; + _options = options.Value; + _logger = logger; + } + + /// + /// Reads the source audio for from the tracks 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 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 on genuine shutdown. + /// + public async Task TranscodeAndStoreAsync(string entryKey, CancellationToken ct) + { + var source = await _fileDatabase.LoadResourceAsync(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); + } + } + + /// The vault entry key under which a track's Opus audio bytes are stored. + public static string OpusAudioKey(string entryKey) => entryKey; + + /// The vault entry key under which a track's setup-header + seek-index sidecar is stored. + 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); + } + } +} diff --git a/DeepDrftTests/NoOpOpusTranscodeQueue.cs b/DeepDrftTests/NoOpOpusTranscodeQueue.cs new file mode 100644 index 0000000..33a6103 --- /dev/null +++ b/DeepDrftTests/NoOpOpusTranscodeQueue.cs @@ -0,0 +1,16 @@ +using DeepDrftAPI.Services.Opus; + +namespace DeepDrftTests; + +/// +/// Test double for . The background Opus derive is out of scope for the +/// upload/dual-database tests — those assert SQL + source-vault behaviour, which is unchanged by Phase 18 +/// (Opus is strictly additive). Enqueuing is a no-op so the upload path under test never spins up a real +/// transcode. Records the enqueued keys in case a test wants to assert the scheduling contract. +/// +public sealed class NoOpOpusTranscodeQueue : IOpusTranscodeQueue +{ + public List Enqueued { get; } = []; + + public void Enqueue(string entryKey) => Enqueued.Add(entryKey); +} diff --git a/DeepDrftTests/OggOpusParserTests.cs b/DeepDrftTests/OggOpusParserTests.cs new file mode 100644 index 0000000..2a7728f --- /dev/null +++ b/DeepDrftTests/OggOpusParserTests.cs @@ -0,0 +1,304 @@ +using System.Buffers.Binary; +using System.Text; +using DeepDrftContent.Processors.Opus; + +namespace DeepDrftTests; + +/// +/// Coverage for the Phase 18.1 seek-index + setup-header extraction (§3.4a). These exercise the pure +/// Ogg-Opus walker and the sidecar codec over hand-built Ogg streams — no ffmpeg dependency — so the +/// granule→byte mapping, page-boundary snapping, 0.5 s bucketing, clamp totals, and setup-header capture +/// are asserted deterministically. The byte layout mirrors a real Opus stream: an OpusHead page, an +/// OpusTags page, then audio pages each carrying an end granule position at 48 kHz. +/// +[TestFixture] +public class OggOpusParserTests +{ + [Test] + public void Walk_CapturesSetupHeader_AsLeadingOpusHeadAndOpusTagsPagesVerbatim() + { + var head = OggPage(granule: 0, OpusHeadPacket()); + var tags = OggPage(granule: 0, OpusTagsPacket()); + var audio = OggPage(granule: 48000, AudioPacket(64)); + + var stream = Concat(head, tags, audio); + + var walk = OggOpusParser.Walk(stream); + + Assert.That(walk, Is.Not.Null, "A well-formed Ogg Opus stream must walk"); + var expectedSetup = Concat(head, tags); + Assert.That(walk!.SetupHeaderBytes, Is.EqualTo(expectedSetup), + "Setup header must be the OpusHead + OpusTags pages, byte-for-byte, and stop before the first audio page"); + } + + [Test] + public void Walk_FirstSeekPoint_IsTheFirstAudioPageAtItsExactByteOffset() + { + var head = OggPage(granule: 0, OpusHeadPacket()); + var tags = OggPage(granule: 0, OpusTagsPacket()); + var audio = OggPage(granule: 48000, AudioPacket(64)); + + var stream = Concat(head, tags, audio); + var firstAudioOffset = (ulong)(head.Length + tags.Length); + + var walk = OggOpusParser.Walk(stream); + + Assert.That(walk, Is.Not.Null); + var first = walk!.SeekIndex.Points[0]; + Assert.That(first.ByteOffset, Is.EqualTo(firstAudioOffset), + "The first seek point must land on the first audio page's start offset (an exact page boundary)"); + Assert.That(first.GranulePosition, Is.EqualTo(48000UL)); + } + + [Test] + public void Walk_EverySeekOffset_LandsOnARealPageBoundary() + { + // Ten audio pages, ~0.25 s each (12000 samples). Bucketing at 0.5 s means roughly every other + // page is indexed; every indexed offset must still be the start of some OggS page. + var head = OggPage(granule: 0, OpusHeadPacket()); + var tags = OggPage(granule: 0, OpusTagsPacket()); + + var pages = new List { head, tags }; + ulong granule = 0; + for (var i = 0; i < 10; i++) + { + granule += 12000; // 0.25 s at 48 kHz + pages.Add(OggPage(granule, AudioPacket(50 + i))); + } + + var stream = Concat(pages.ToArray()); + var pageOffsets = CollectPageStartOffsets(stream); + + var walk = OggOpusParser.Walk(stream); + Assert.That(walk, Is.Not.Null); + + foreach (var point in walk!.SeekIndex.Points) + { + Assert.That(pageOffsets, Does.Contain(point.ByteOffset), + $"Seek offset {point.ByteOffset} must be a real OggS page start"); + } + } + + [Test] + public void Walk_Bucketing_EmitsRoughlyOneEntryPerHalfSecond() + { + // Twenty audio pages of 0.25 s each = 5 s total. At 0.5 s buckets we expect ~10 entries + // (the first audio page is always taken, then one per crossed half-second boundary). + var head = OggPage(granule: 0, OpusHeadPacket()); + var tags = OggPage(granule: 0, OpusTagsPacket()); + + var pages = new List { head, tags }; + ulong granule = 0; + for (var i = 0; i < 20; i++) + { + granule += 12000; // 0.25 s + pages.Add(OggPage(granule, AudioPacket(40))); + } + + var stream = Concat(pages.ToArray()); + var walk = OggOpusParser.Walk(stream); + + Assert.That(walk, Is.Not.Null); + // 5 s of audio → first point + one per 0.5 s boundary up to 5.0 s. Allow a small tolerance for + // boundary rounding, but it must be far below "one per page" (20) and at least the ~10 buckets. + Assert.That(walk!.SeekIndex.Points.Count, Is.InRange(9, 12), + "Bucketing must coalesce ~0.25 s pages into ~0.5 s index entries, not one per page"); + } + + [Test] + public void Walk_PointsAreStrictlyAscending_InBothGranuleAndOffset() + { + var head = OggPage(granule: 0, OpusHeadPacket()); + var tags = OggPage(granule: 0, OpusTagsPacket()); + + var pages = new List { head, tags }; + ulong granule = 0; + for (var i = 0; i < 12; i++) + { + granule += 24000; // 0.5 s — one index entry per page + pages.Add(OggPage(granule, AudioPacket(30))); + } + + var walk = OggOpusParser.Walk(Concat(pages.ToArray())); + Assert.That(walk, Is.Not.Null); + + var points = walk!.SeekIndex.Points; + for (var i = 1; i < points.Count; i++) + { + Assert.That(points[i].GranulePosition, Is.GreaterThan(points[i - 1].GranulePosition)); + Assert.That(points[i].ByteOffset, Is.GreaterThan(points[i - 1].ByteOffset)); + } + } + + [Test] + public void Walk_ClampValues_ReflectFinalGranuleAndTotalByteLength() + { + var head = OggPage(granule: 0, OpusHeadPacket()); + var tags = OggPage(granule: 0, OpusTagsPacket()); + var a1 = OggPage(granule: 48000, AudioPacket(64)); // 1.0 s + var a2 = OggPage(granule: 144000, AudioPacket(64)); // 3.0 s (final) + + var stream = Concat(head, tags, a1, a2); + + var walk = OggOpusParser.Walk(stream); + Assert.That(walk, Is.Not.Null); + + Assert.That(walk!.SeekIndex.TotalByteLength, Is.EqualTo((ulong)stream.Length), + "Total byte length must equal the full stream length for end-of-stream clamping"); + Assert.That(walk.SeekIndex.TotalDurationSeconds, Is.EqualTo(3.0).Within(1e-9), + "Total duration must derive from the final page's granule position (144000 / 48000 = 3.0 s)"); + } + + [Test] + public void Walk_MalformedStream_ReturnsNull_RatherThanThrowing() + { + var notOgg = Encoding.ASCII.GetBytes("this is not an ogg stream at all"); + Assert.That(OggOpusParser.Walk(notOgg), Is.Null); + + // OpusHead present but no audio pages → no seek points → null (nothing to index). + var headOnly = Concat(OggPage(0, OpusHeadPacket()), OggPage(0, OpusTagsPacket())); + Assert.That(OggOpusParser.Walk(headOnly), Is.Null); + } + + [Test] + public void SeekIndex_RoundTrips_ThroughBinaryEncoding() + { + var points = new[] + { + new OpusSeekPoint(48000, 200), + new OpusSeekPoint(72000, 512), + new OpusSeekPoint(96000, 900), + }; + var index = new OggOpusSeekIndex(points, TotalDurationSeconds: 2.0, TotalByteLength: 1024); + + var restored = OggOpusSeekIndex.FromBytes(index.ToBytes()); + + Assert.That(restored, Is.Not.Null); + Assert.That(restored!.TotalByteLength, Is.EqualTo(1024UL)); + Assert.That(restored.TotalDurationSeconds, Is.EqualTo(2.0)); + Assert.That(restored.Points, Is.EqualTo(points)); + } + + [Test] + public void Sidecar_RoundTrips_PreservingSetupHeaderAndIndex() + { + var setup = Encoding.ASCII.GetBytes("OpusHead-and-OpusTags-bytes-go-here"); + var index = new OggOpusSeekIndex( + new[] { new OpusSeekPoint(48000, 200), new OpusSeekPoint(96000, 700) }, + TotalDurationSeconds: 2.0, TotalByteLength: 800); + var sidecar = new OpusSidecar(setup, index); + + var restored = OpusSidecar.FromBytes(sidecar.ToBytes()); + + Assert.That(restored, Is.Not.Null); + Assert.That(restored!.SetupHeaderBytes, Is.EqualTo(setup), + "The sidecar must preserve the setup header so the client can prepend it to mid-stream slices"); + Assert.That(restored.SeekIndex.Points, Is.EqualTo(index.Points)); + Assert.That(restored.SeekIndex.TotalByteLength, Is.EqualTo(800UL)); + } + + [Test] + public void Sidecar_FromBytes_RejectsTruncatedBlob() + { + Assert.That(OpusSidecar.FromBytes(new byte[2]), Is.Null, "A blob shorter than the length prefix is corruption"); + + // A length prefix that overruns the buffer must be rejected, not over-read. + var bad = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(bad, 9999); + Assert.That(OpusSidecar.FromBytes(bad), Is.Null); + } + + // ---- Ogg stream construction helpers (minimal, single-packet pages) ---- + + // Builds one Ogg page wrapping a single packet payload with the given end granule position. The page + // header layout matches the spec the parser reads: capture "OggS", version, header-type, granulepos, + // serial, sequence, checksum (zeroed — the parser does not verify CRC), page-segments, segment table. + private static byte[] OggPage(ulong granule, byte[] packet) + { + // Lacing: a packet of length L is split into 255-byte segments plus a final < 255 segment. + var segments = new List(); + var remaining = packet.Length; + while (remaining >= 255) + { + segments.Add(255); + remaining -= 255; + } + segments.Add((byte)remaining); + + var header = new byte[OggOpusConstants.OggPageHeaderSize + segments.Count]; + OggOpusConstants.CapturePattern.CopyTo(header); + header[4] = 0; // version + header[5] = 0; // header-type flags + BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(OggOpusConstants.GranulePositionOffset, 8), granule); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(14, 4), 0xDEAD); // serial + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(18, 4), 0); // sequence (unused by parser) + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(22, 4), 0); // checksum (unverified) + header[OggOpusConstants.PageSegmentCountOffset] = (byte)segments.Count; + for (var i = 0; i < segments.Count; i++) + header[OggOpusConstants.OggPageHeaderSize + i] = segments[i]; + + return Concat(header, packet); + } + + private static byte[] OpusHeadPacket() + { + // "OpusHead" + a minimal valid-ish identification header tail (version, channels, pre-skip, etc.). + var tail = new byte[] { 1, 2, 0, 0, 0x80, 0xBB, 0, 0, 0, 0, 0 }; + return Concat(OggOpusConstants.OpusHeadSignature.ToArray(), tail); + } + + private static byte[] OpusTagsPacket() + { + // "OpusTags" + a tiny vendor string region (length-prefixed) + zero user comments. + var vendor = Encoding.ASCII.GetBytes("test"); + var packet = new List(); + packet.AddRange(OggOpusConstants.OpusTagsSignature.ToArray()); + packet.AddRange(BitConverter.GetBytes((uint)vendor.Length)); + packet.AddRange(vendor); + packet.AddRange(BitConverter.GetBytes(0u)); // user comment count + return packet.ToArray(); + } + + private static byte[] AudioPacket(int size) + { + var packet = new byte[size]; + for (var i = 0; i < size; i++) + packet[i] = (byte)(i & 0xFF); + return packet; + } + + private static List CollectPageStartOffsets(byte[] stream) + { + var offsets = new List(); + var span = stream.AsSpan(); + var offset = 0; + while (offset + OggOpusConstants.OggPageHeaderSize <= span.Length) + { + var page = span.Slice(offset); + if (!page[..4].SequenceEqual(OggOpusConstants.CapturePattern)) + break; + + var segmentCount = page[OggOpusConstants.PageSegmentCountOffset]; + var payload = 0; + for (var i = 0; i < segmentCount; i++) + payload += page[OggOpusConstants.OggPageHeaderSize + i]; + + offsets.Add((ulong)offset); + offset += OggOpusConstants.OggPageHeaderSize + segmentCount + payload; + } + return offsets; + } + + private static byte[] Concat(params byte[][] parts) + { + var total = parts.Sum(p => p.Length); + var result = new byte[total]; + var cursor = 0; + foreach (var part in parts) + { + part.CopyTo(result, cursor); + cursor += part.Length; + } + return result; + } +} diff --git a/DeepDrftTests/UploadDuplicateDetectionTests.cs b/DeepDrftTests/UploadDuplicateDetectionTests.cs index ab8fceb..97af62e 100644 --- a/DeepDrftTests/UploadDuplicateDetectionTests.cs +++ b/DeepDrftTests/UploadDuplicateDetectionTests.cs @@ -77,6 +77,7 @@ public class UploadDuplicateDetectionTests return new UnifiedTrackService( content, sqlTrackService, fileDatabase!, waveforms, + new NoOpOpusTranscodeQueue(), NullLogger.Instance); } From 6add30a4ff71d281ceec8a8d829d8cd9fb27474d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 06:55:31 -0400 Subject: [PATCH 02/54] =?UTF-8?q?fix:=20Wave=2018.1=20review=20=E2=80=94?= =?UTF-8?q?=20pre-skip=20subtraction,=20t=3D0=20anchor,=20PreSkip=20in=20s?= =?UTF-8?q?idecar,=20stderr=20on=20cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Processors/Opus/FfmpegOpusEncoder.cs | 2 + .../Processors/Opus/OggOpusConstants.cs | 19 +- .../Processors/Opus/OggOpusParser.cs | 34 +++- .../Processors/Opus/OggOpusSeekIndex.cs | 57 ++++-- .../Processors/Opus/OpusTranscodeService.cs | 3 + DeepDrftTests/OggOpusParserTests.cs | 181 +++++++++++++++--- 6 files changed, 251 insertions(+), 45 deletions(-) diff --git a/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs b/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs index 4e7f7e1..65af701 100644 --- a/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs +++ b/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs @@ -90,11 +90,13 @@ public sealed class FfmpegOpusEncoder 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; diff --git a/DeepDrftContent/Processors/Opus/OggOpusConstants.cs b/DeepDrftContent/Processors/Opus/OggOpusConstants.cs index f6fa431..fbba891 100644 --- a/DeepDrftContent/Processors/Opus/OggOpusConstants.cs +++ b/DeepDrftContent/Processors/Opus/OggOpusConstants.cs @@ -36,8 +36,23 @@ public static class OggOpusConstants /// Sentinel granule position for a page that ends mid-packet (no usable timestamp). public const ulong NoGranulePosition = 0xFFFFFFFFFFFFFFFFUL; - /// Header size of the serialized seek-index blob: totalBytes(8) + duration(8) + count(4). - public const int SeekIndexHeaderSize = 20; + /// + /// Minimum byte length of an OpusHead packet payload to safely read pre_skip. + /// RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) + pre_skip(2) = 12 bytes minimum. + /// + public const int OpusHeadMinSize = 12; + + /// + /// Byte offset of pre_skip within the full OpusHead packet payload (including the + /// magic). RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) = 10 bytes before pre_skip. + /// + public const int OpusHeadPreSkipOffset = 10; + + /// + /// Header size of the serialized seek-index blob: + /// totalBytes(8) + duration(8) + count(4) + preSkip(2) + reserved(2) = 24 bytes. + /// + public const int SeekIndexHeaderSize = 24; /// Size of one serialized seek point: granulepos(8) + byteOffset(8). public const int SeekPointSize = 16; diff --git a/DeepDrftContent/Processors/Opus/OggOpusParser.cs b/DeepDrftContent/Processors/Opus/OggOpusParser.cs index 98452b4..0e4f850 100644 --- a/DeepDrftContent/Processors/Opus/OggOpusParser.cs +++ b/DeepDrftContent/Processors/Opus/OggOpusParser.cs @@ -33,6 +33,7 @@ public static class OggOpusParser var setupHeaderEnd = -1; var sawOpusHead = false; var sawOpusTags = false; + ushort preSkip = 0; var points = new List(); ulong lastGranule = 0; @@ -73,6 +74,15 @@ public static class OggOpusParser { sawOpusHead = true; setupHeaderEnd = offset + pageTotalSize; + + // RFC 7845 §5.1 — OpusHead layout after the 8-byte "OpusHead" magic: + // [0] version (1 byte), [1] channel count (1 byte), + // [2-3] pre_skip (little-endian uint16) ← at packet bytes 10-11 + // pre_skip is the number of decoder samples to discard before presenting audio; + // all granule→time conversions must subtract it (RFC 7845 §4.3). + if (payload.Length >= OggOpusConstants.OpusHeadMinSize) + preSkip = BinaryPrimitives.ReadUInt16LittleEndian( + payload.Slice(OggOpusConstants.OpusHeadPreSkipOffset, 2)); } else if (sawOpusHead && !sawOpusTags && StartsWith(payload, OggOpusConstants.OpusTagsSignature)) { @@ -87,19 +97,28 @@ public static class OggOpusParser // the byte cursor. if (granule != OggOpusConstants.NoGranulePosition) { - var pageTime = granule / OggOpusConstants.OpusSampleRate; + // RFC 7845 §4.3: presentation time = max(0, granule − preSkip) / 48000. + // Use this corrected time for bucketing so that a stream with pre-skip 3840 (~80 ms) + // does not systematically offset every indexed time by that amount. + var correctedTime = Math.Max(0.0, + (granule - (double)preSkip) / OggOpusConstants.OpusSampleRate); + if (!firstAudioPointTaken) { - points.Add(new OpusSeekPoint(granule, (ulong)offset)); + // Anchor the first seek point at corrected time = 0 by storing the granule as + // preSkip. This guarantees that a binary search for t=0 ("largest entry with + // corrected time ≤ 0") always resolves to the first audio page's byte offset — + // even when the real granule is slightly above preSkip due to encoder lead-in. + points.Add(new OpusSeekPoint(preSkip, (ulong)offset)); firstAudioPointTaken = true; nextBucketTime = OggOpusConstants.SeekBucketSeconds; } - else if (pageTime >= nextBucketTime) + else if (correctedTime >= nextBucketTime) { points.Add(new OpusSeekPoint(granule, (ulong)offset)); // Advance past every bucket this page crossed so a long page does not emit a // backlog of entries; the next bucket is the first boundary strictly after it. - while (nextBucketTime <= pageTime) + while (nextBucketTime <= correctedTime) nextBucketTime += OggOpusConstants.SeekBucketSeconds; } @@ -114,8 +133,11 @@ public static class OggOpusParser return null; var setupHeader = oggBytes[..setupHeaderEnd].ToArray(); - var totalDuration = lastGranule / OggOpusConstants.OpusSampleRate; - var index = new OggOpusSeekIndex(points, totalDuration, (ulong)oggBytes.Length); + // RFC 7845 §4.3: total duration is also pre-skip-corrected, matching the time a listener + // experiences (the last audio page's corrected time, clamped to ≥ 0). + var totalDuration = Math.Max(0.0, + (lastGranule - (double)preSkip) / OggOpusConstants.OpusSampleRate); + var index = new OggOpusSeekIndex(points, totalDuration, (ulong)oggBytes.Length, preSkip); return new OggOpusWalk(setupHeader, index); } diff --git a/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs b/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs index b5bff4c..465a1b5 100644 --- a/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs +++ b/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs @@ -4,16 +4,25 @@ namespace DeepDrftContent.Processors.Opus; /// /// A single seek-index entry: an authoritative 48 kHz (Opus granule -/// positions are always sample counts at 48 kHz, so time = granulepos / 48000) paired with the exact -/// byte offset of the Ogg page that carries it. Every is a real page-start -/// boundary, so a Range: bytes={ByteOffset}- fetch lands the decoder Ogg-sync-aligned. +/// positions are always sample counts at 48 kHz) paired with the exact byte offset of the Ogg page that +/// carries it. Every is a real page-start boundary, so a +/// Range: bytes={ByteOffset}- fetch lands the decoder Ogg-sync-aligned. /// +/// +/// Per RFC 7845 §4.3, the PCM presentation time is (granulepos − preSkip) / 48000. The raw +/// is stored here as-is; callers should subtract the containing +/// before converting to a presentation time. Use +/// for the corrected value. +/// /// The page's end granule position (48 kHz sample count). /// The byte offset of the page start in the Opus file. public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOffset) { - /// Time in seconds this granule position represents (granulepos / 48 kHz). - public double TimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate; + /// + /// Raw granule-position-to-time conversion (granulepos / 48 kHz). Does NOT subtract pre-skip — use + /// for the RFC 7845-correct presentation time. + /// + public double RawTimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate; } /// @@ -23,20 +32,38 @@ public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOff /// One entry per 0.5 s of audio (), each snapped to the /// nearest enclosing page start, plus the totals needed to clamp a seek to range. /// -/// Ordered (granulepos, byteOffset) entries, ascending. The first is always the -/// first audio page (offset just past the setup headers). -/// Total stream duration from the final granule position. +/// Ordered (granulepos, byteOffset) entries, ascending. The first entry always +/// has == (corrected time = 0) +/// and points at the first audio page start, ensuring a seek to t=0 always resolves. +/// +/// Pre-skip-corrected total stream duration: max(0, lastGranule − preSkip) / 48000. +/// /// Total Opus file byte length, for clamping a seek past the end. +/// +/// The pre_skip value from the OpusHead identification header (RFC 7845 §5.1). Opus +/// decoders must discard this many samples from the decoded start before presenting audio. The client +/// (wave 18.4) needs this to trim the first decoded buffer; storing it here avoids a re-parse of the +/// Ogg stream at delivery time. +/// public sealed record OggOpusSeekIndex( IReadOnlyList Points, double TotalDurationSeconds, - ulong TotalByteLength) + ulong TotalByteLength, + ushort PreSkip) { + /// + /// Returns the RFC 7845-correct presentation time for a seek point: max(0, granule − preSkip) / 48000. + /// Use this for all time comparisons; raw omits the pre-skip. + /// + public double PresentationTimeSeconds(OpusSeekPoint point) => + Math.Max(0.0, (point.GranulePosition - (double)PreSkip) / OggOpusConstants.OpusSampleRate); + /// /// Serializes the index to the compact little-endian binary blob the sidecar stores. Layout: - /// [uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount] then - /// pointCount × (uint64 granulepos, uint64 byteOffset). Fixed-width records keep the client - /// parse to a single typed-array read. + /// [uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount][uint16 preSkip][uint16 reserved] + /// then pointCount × (uint64 granulepos, uint64 byteOffset). The four-byte preSkip+reserved + /// region pads the header to 24 bytes, keeping the point table 8-byte-aligned. + /// Fixed-width records keep the client parse to a single typed-array read. /// public byte[] ToBytes() { @@ -47,6 +74,8 @@ public sealed record OggOpusSeekIndex( BinaryPrimitives.WriteUInt64LittleEndian(span[..8], TotalByteLength); BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(8, 8), TotalDurationSeconds); BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16, 4), (uint)Points.Count); + BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(20, 2), PreSkip); + // bytes 22-23: reserved (zero-initialized by array allocation) var cursor = OggOpusConstants.SeekIndexHeaderSize; foreach (var point in Points) @@ -73,6 +102,8 @@ public sealed record OggOpusSeekIndex( var totalByteLength = BinaryPrimitives.ReadUInt64LittleEndian(bytes[..8]); var totalDuration = BinaryPrimitives.ReadDoubleLittleEndian(bytes.Slice(8, 8)); var count = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(16, 4)); + var preSkip = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(20, 2)); + // bytes 22-23: reserved — ignored on read for forward-compatibility var expected = OggOpusConstants.SeekIndexHeaderSize + (long)count * OggOpusConstants.SeekPointSize; if (bytes.Length < expected) @@ -88,6 +119,6 @@ public sealed record OggOpusSeekIndex( cursor += OggOpusConstants.SeekPointSize; } - return new OggOpusSeekIndex(points, totalDuration, totalByteLength); + return new OggOpusSeekIndex(points, totalDuration, totalByteLength, preSkip); } } diff --git a/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs b/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs index 712344c..dba73e9 100644 --- a/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs +++ b/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs @@ -132,6 +132,9 @@ public sealed class OpusTranscodeService private async Task EnsureVaultAsync() { + // The TrackOpus vault is created at host startup (Startup.cs), so this guard is normally a + // no-op for the upload path. It is retained for the backfill path, which may run via a + // standalone CLI or a host that skips vault pre-creation, where the vault might not exist. if (!_fileDatabase.HasVault(VaultConstants.TrackOpus)) await _fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio); } diff --git a/DeepDrftTests/OggOpusParserTests.cs b/DeepDrftTests/OggOpusParserTests.cs index 2a7728f..9d024f9 100644 --- a/DeepDrftTests/OggOpusParserTests.cs +++ b/DeepDrftTests/OggOpusParserTests.cs @@ -7,13 +7,19 @@ namespace DeepDrftTests; /// /// Coverage for the Phase 18.1 seek-index + setup-header extraction (§3.4a). These exercise the pure /// Ogg-Opus walker and the sidecar codec over hand-built Ogg streams — no ffmpeg dependency — so the -/// granule→byte mapping, page-boundary snapping, 0.5 s bucketing, clamp totals, and setup-header capture -/// are asserted deterministically. The byte layout mirrors a real Opus stream: an OpusHead page, an -/// OpusTags page, then audio pages each carrying an end granule position at 48 kHz. +/// granule→byte mapping, pre-skip correction (RFC 7845 §4.3), page-boundary snapping, 0.5 s bucketing, +/// t=0 anchor, clamp totals, and setup-header capture are asserted deterministically. The byte layout +/// mirrors a real Opus stream: an OpusHead page, an OpusTags page, then audio pages each carrying an +/// end granule position at 48 kHz. /// [TestFixture] public class OggOpusParserTests { + // libopus default pre-skip: 312 samples at 48 kHz (≈ 6.5 ms). FFmpeg may use 3840 (~80 ms). + // Using 312 here as a realistic non-zero value that is small enough not to affect the test + // granules (which start at 48000), while still exercising the pre-skip subtraction path. + private const ushort TestPreSkip = 312; + [Test] public void Walk_CapturesSetupHeader_AsLeadingOpusHeadAndOpusTagsPagesVerbatim() { @@ -32,9 +38,11 @@ public class OggOpusParserTests } [Test] - public void Walk_FirstSeekPoint_IsTheFirstAudioPageAtItsExactByteOffset() + public void Walk_FirstSeekPoint_IsAnchoredAtTimeZero_PointingAtFirstAudioPage() { - var head = OggPage(granule: 0, OpusHeadPacket()); + // Use a non-zero pre-skip to verify that the first seek point is explicitly anchored at + // corrected time = 0, not at the raw granule time. + var head = OggPage(granule: 0, OpusHeadPacket(preSkip: TestPreSkip)); var tags = OggPage(granule: 0, OpusTagsPacket()); var audio = OggPage(granule: 48000, AudioPacket(64)); @@ -45,9 +53,87 @@ public class OggOpusParserTests Assert.That(walk, Is.Not.Null); var first = walk!.SeekIndex.Points[0]; + + // The first point's byte offset must be the first audio page start (exact page boundary). Assert.That(first.ByteOffset, Is.EqualTo(firstAudioOffset), "The first seek point must land on the first audio page's start offset (an exact page boundary)"); - Assert.That(first.GranulePosition, Is.EqualTo(48000UL)); + + // The first point's stored granule is clamped to preSkip so corrected presentation time = 0. + // This guarantees a binary search for t=0 always resolves to the first audio page. + Assert.That(walk.SeekIndex.PreSkip, Is.EqualTo(TestPreSkip), + "PreSkip must be parsed from OpusHead and carried into the seek index"); + Assert.That(walk.SeekIndex.PresentationTimeSeconds(first), Is.EqualTo(0.0).Within(1e-12), + "First seek point must have corrected presentation time = 0 so a seek to t=0 always resolves"); + } + + [Test] + public void Walk_PreSkip_IsSubtractedFromGranuleInTimeCalculations() + { + // Pre-skip of 3840 samples (≈ 80 ms, the libopus typical value used by ffmpeg). + // Without the fix, pageTime = 48000 / 48000 = 1.0 s; with fix, (48000 - 3840) / 48000 = 0.92 s. + const ushort preSkip = 3840; + var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip)); + var tags = OggPage(granule: 0, OpusTagsPacket()); + // First audio page at granule 48000 (1.0 s raw; 0.92 s corrected) + var a1 = OggPage(granule: 48000, AudioPacket(64)); + // Second audio page at granule 96000 (2.0 s raw; 1.92 s corrected) + var a2 = OggPage(granule: 96000, AudioPacket(64)); + // Third audio page at granule 144000 (3.0 s raw; 2.92 s corrected) + var a3 = OggPage(granule: 144000, AudioPacket(64)); + + var walk = OggOpusParser.Walk(Concat(head, tags, a1, a2, a3)); + + Assert.That(walk, Is.Not.Null); + var index = walk!.SeekIndex; + + Assert.That(index.PreSkip, Is.EqualTo(preSkip), "PreSkip must be parsed from OpusHead"); + + // TotalDurationSeconds must be pre-skip-corrected: (144000 - 3840) / 48000 = 2.92 s + var expectedDuration = (144000.0 - preSkip) / 48000.0; + Assert.That(index.TotalDurationSeconds, Is.EqualTo(expectedDuration).Within(1e-9), + "TotalDurationSeconds must subtract preSkip (RFC 7845 §4.3), not use raw lastGranule / 48000"); + + // The second indexed point (first real bucket) must have corrected time, not raw time. + // With correctedTime(a2) = 1.92 s and bucket = 0.5 s, it should fall in the 1.5 s bucket. + if (index.Points.Count > 1) + { + var secondPoint = index.Points[1]; + var corrected = index.PresentationTimeSeconds(secondPoint); + Assert.That(corrected, Is.GreaterThan(0.0), + "Non-first indexed points must have positive corrected presentation times"); + Assert.That(secondPoint.RawTimeSeconds, Is.GreaterThan(corrected), + "Raw time must be greater than corrected time when pre-skip > 0"); + } + } + + [Test] + public void Walk_SeekToZero_ResolvesToFirstAudioPageOffset_WithNonZeroPreSkip() + { + // This is the AC9 / Critical-2 regression test: a seek to t=0 must resolve to the first audio + // page's byte offset, not produce "no entry found". With the old code (no t=0 anchor and no + // pre-skip correction), the first indexed point had correctedTime ≈ 0.92 s (for preSkip=3840), + // so a binary search for t=0 would find no entry with time ≤ 0 and fail. + const ushort preSkip = 3840; + var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip)); + var tags = OggPage(granule: 0, OpusTagsPacket()); + var a1 = OggPage(granule: 48000, AudioPacket(64)); + var a2 = OggPage(granule: 96000, AudioPacket(64)); + + var stream = Concat(head, tags, a1, a2); + var firstAudioByteOffset = (ulong)(head.Length + tags.Length); + + var walk = OggOpusParser.Walk(stream); + Assert.That(walk, Is.Not.Null); + + var index = walk!.SeekIndex; + var firstPoint = index.Points[0]; + + // Simulate the binary search: find the largest entry with PresentationTimeSeconds ≤ 0. + // With the fix, the first point has corrected time = 0.0, so it IS found. + Assert.That(index.PresentationTimeSeconds(firstPoint), Is.EqualTo(0.0).Within(1e-12), + "First point corrected time must be exactly 0.0 so binary search for t=0 resolves it"); + Assert.That(firstPoint.ByteOffset, Is.EqualTo(firstAudioByteOffset), + "The t=0 anchor must point at the first audio page's byte offset, not the stream start"); } [Test] @@ -82,8 +168,8 @@ public class OggOpusParserTests [Test] public void Walk_Bucketing_EmitsRoughlyOneEntryPerHalfSecond() { - // Twenty audio pages of 0.25 s each = 5 s total. At 0.5 s buckets we expect ~10 entries - // (the first audio page is always taken, then one per crossed half-second boundary). + // Twenty audio pages of 0.25 s each = 5 s total (zero pre-skip). At 0.5 s buckets: + // first point (anchored at t=0) + one per 0.5 s boundary = 1 + 10 = 11 entries expected. var head = OggPage(granule: 0, OpusHeadPacket()); var tags = OggPage(granule: 0, OpusTagsPacket()); @@ -99,9 +185,9 @@ public class OggOpusParserTests var walk = OggOpusParser.Walk(stream); Assert.That(walk, Is.Not.Null); - // 5 s of audio → first point + one per 0.5 s boundary up to 5.0 s. Allow a small tolerance for - // boundary rounding, but it must be far below "one per page" (20) and at least the ~10 buckets. - Assert.That(walk!.SeekIndex.Points.Count, Is.InRange(9, 12), + // 5 s of audio with 0.5 s buckets: 1 anchor + 10 bucket crossings = 11 entries. + // Accept 10–12 for floating-point boundary tolerance, but must be far below 20 (one per page). + Assert.That(walk!.SeekIndex.Points.Count, Is.InRange(10, 12), "Bucketing must coalesce ~0.25 s pages into ~0.5 s index entries, not one per page"); } @@ -131,12 +217,13 @@ public class OggOpusParserTests } [Test] - public void Walk_ClampValues_ReflectFinalGranuleAndTotalByteLength() + public void Walk_ClampValues_ReflectPreSkipCorrectedDurationAndTotalByteLength() { - var head = OggPage(granule: 0, OpusHeadPacket()); + const ushort preSkip = 312; + var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip)); var tags = OggPage(granule: 0, OpusTagsPacket()); - var a1 = OggPage(granule: 48000, AudioPacket(64)); // 1.0 s - var a2 = OggPage(granule: 144000, AudioPacket(64)); // 3.0 s (final) + var a1 = OggPage(granule: 48000, AudioPacket(64)); // 1.0 s raw; ~0.9935 s corrected + var a2 = OggPage(granule: 144000, AudioPacket(64)); // 3.0 s raw; ~2.9935 s corrected (final) var stream = Concat(head, tags, a1, a2); @@ -145,8 +232,13 @@ public class OggOpusParserTests Assert.That(walk!.SeekIndex.TotalByteLength, Is.EqualTo((ulong)stream.Length), "Total byte length must equal the full stream length for end-of-stream clamping"); - Assert.That(walk.SeekIndex.TotalDurationSeconds, Is.EqualTo(3.0).Within(1e-9), - "Total duration must derive from the final page's granule position (144000 / 48000 = 3.0 s)"); + + var expectedDuration = (144000.0 - preSkip) / 48000.0; + Assert.That(walk.SeekIndex.TotalDurationSeconds, Is.EqualTo(expectedDuration).Within(1e-9), + "TotalDurationSeconds must be pre-skip-corrected: (lastGranule - preSkip) / 48000"); + + Assert.That(walk.SeekIndex.PreSkip, Is.EqualTo(preSkip), + "PreSkip must round-trip through the seek index"); } [Test] @@ -165,27 +257,58 @@ public class OggOpusParserTests { var points = new[] { - new OpusSeekPoint(48000, 200), + new OpusSeekPoint(312, 200), // first point anchored at preSkip new OpusSeekPoint(72000, 512), new OpusSeekPoint(96000, 900), }; - var index = new OggOpusSeekIndex(points, TotalDurationSeconds: 2.0, TotalByteLength: 1024); + var index = new OggOpusSeekIndex(points, TotalDurationSeconds: 2.0, TotalByteLength: 1024, + PreSkip: 312); var restored = OggOpusSeekIndex.FromBytes(index.ToBytes()); Assert.That(restored, Is.Not.Null); Assert.That(restored!.TotalByteLength, Is.EqualTo(1024UL)); Assert.That(restored.TotalDurationSeconds, Is.EqualTo(2.0)); + Assert.That(restored.PreSkip, Is.EqualTo((ushort)312), + "PreSkip must survive the binary round-trip"); Assert.That(restored.Points, Is.EqualTo(points)); } + [Test] + public void SeekIndex_PresentationTimeSeconds_SubtractsPreSkip() + { + const ushort preSkip = 3840; + var point = new OpusSeekPoint(GranulePosition: 48000, ByteOffset: 200); + var index = new OggOpusSeekIndex( + new[] { point }, TotalDurationSeconds: 0.92, TotalByteLength: 500, PreSkip: preSkip); + + var corrected = index.PresentationTimeSeconds(point); + var expected = (48000.0 - preSkip) / 48000.0; // ≈ 0.92 s + + Assert.That(corrected, Is.EqualTo(expected).Within(1e-9), + "PresentationTimeSeconds must return (granule - preSkip) / 48000, not raw granule / 48000"); + } + + [Test] + public void SeekIndex_PresentationTimeSeconds_ClampsToZeroForFirstAnchorPoint() + { + const ushort preSkip = 3840; + // First anchor point: granule stored as preSkip, so corrected time = 0. + var firstPoint = new OpusSeekPoint(GranulePosition: preSkip, ByteOffset: 150); + var index = new OggOpusSeekIndex( + new[] { firstPoint }, TotalDurationSeconds: 2.0, TotalByteLength: 500, PreSkip: preSkip); + + Assert.That(index.PresentationTimeSeconds(firstPoint), Is.EqualTo(0.0).Within(1e-12), + "The t=0 anchor point (granule == preSkip) must yield corrected time = 0.0 exactly"); + } + [Test] public void Sidecar_RoundTrips_PreservingSetupHeaderAndIndex() { var setup = Encoding.ASCII.GetBytes("OpusHead-and-OpusTags-bytes-go-here"); var index = new OggOpusSeekIndex( - new[] { new OpusSeekPoint(48000, 200), new OpusSeekPoint(96000, 700) }, - TotalDurationSeconds: 2.0, TotalByteLength: 800); + new[] { new OpusSeekPoint(312, 200), new OpusSeekPoint(96000, 700) }, + TotalDurationSeconds: 2.0, TotalByteLength: 800, PreSkip: 312); var sidecar = new OpusSidecar(setup, index); var restored = OpusSidecar.FromBytes(sidecar.ToBytes()); @@ -195,6 +318,8 @@ public class OggOpusParserTests "The sidecar must preserve the setup header so the client can prepend it to mid-stream slices"); Assert.That(restored.SeekIndex.Points, Is.EqualTo(index.Points)); Assert.That(restored.SeekIndex.TotalByteLength, Is.EqualTo(800UL)); + Assert.That(restored.SeekIndex.PreSkip, Is.EqualTo((ushort)312), + "PreSkip must survive the sidecar binary round-trip"); } [Test] @@ -240,10 +365,18 @@ public class OggOpusParserTests return Concat(header, packet); } - private static byte[] OpusHeadPacket() + private static byte[] OpusHeadPacket(ushort preSkip = 0) { - // "OpusHead" + a minimal valid-ish identification header tail (version, channels, pre-skip, etc.). - var tail = new byte[] { 1, 2, 0, 0, 0x80, 0xBB, 0, 0, 0, 0, 0 }; + // "OpusHead" + RFC 7845 §5.1 identification header: + // [0] version = 1, [1] channel count = 2, + // [2-3] pre_skip (little-endian uint16), [4-7] input sample rate = 0xBB80 = 48000, + // [8-9] output gain = 0, [10] channel mapping family = 0. + var tail = new byte[11]; + tail[0] = 1; // version + tail[1] = 2; // channels + BinaryPrimitives.WriteUInt16LittleEndian(tail.AsSpan(2, 2), preSkip); // pre_skip + BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4, 4), 48000); // input sample rate + tail[10] = 0; // channel mapping family return Concat(OggOpusConstants.OpusHeadSignature.ToArray(), tail); } From ba064cc136e2f626f6110fbf7abe880db2c3f66a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 07:30:13 -0400 Subject: [PATCH 03/54] provision ffmpeg on DeepDrftAPI host for Opus transcode Phase 18.1 needs ffmpeg (libopus). Add it to bootstrap.sh apt prereqs and a preflight guard in install.sh; resolves via the systemd user unit's default PATH (/usr/bin), no config change. --- deploy/bootstrap.sh | 3 ++- deploy/install.sh | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 17e8ef5..6903bda 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -41,7 +41,8 @@ apt-get install -y --no-install-recommends \ openssl \ jq \ wget \ - ca-certificates + ca-certificates \ + ffmpeg echo " [ok] apt packages installed" diff --git a/deploy/install.sh b/deploy/install.sh index 8a2441a..ddd7e69 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -41,6 +41,12 @@ if ! command -v nginx &>/dev/null; then die "nginx not found on PATH. Install nginx before running this script." fi +# ffmpeg (with libopus) is required at runtime by DeepDrftAPI for Opus transcode (Phase 18.1). +# On Ubuntu/Debian the stock ffmpeg apt package includes libopus; bootstrap.sh installs it. +if ! command -v ffmpeg &>/dev/null; then + die "ffmpeg not found on PATH. Install it before running this script: apt-get install -y ffmpeg" +fi + # rrsync ships in the rsync package; path varies by distro version RRSYNC_BIN="" if command -v rrsync &>/dev/null; then @@ -51,7 +57,7 @@ else die "rrsync not found. Install the rsync package: apt-get install -y rsync" fi -ok "psql present, PostgreSQL active, nginx present" +ok "psql present, PostgreSQL active, nginx present, ffmpeg present" ok "rrsync found at: ${RRSYNC_BIN}" # ── Step 0b: Parameter collection ───────────────────────────────────────────── @@ -433,6 +439,7 @@ Installed / configured: - PostgreSQL role '${PG_ROLE}' and databases: ${DB_META}, ${DB_AUTH} - SSH authorized_keys with forced-command + restrict - nginx sites-available + sites-enabled, default site removed + - ffmpeg (with libopus) installed at $(command -v ffmpeg) — runtime prereq for Opus transcode ------------------------------------------------------------------------ Remaining manual steps: From 19793ba1c34afabda16ed08416955de62bf1a7c3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 07:45:06 -0400 Subject: [PATCH 04/54] feature: Opus format resolution + sidecar lookup contract (Phase 18.2) --- DeepDrftAPI/Startup.cs | 5 + .../Processors/Opus/ResolvedAudio.cs | 23 +++ .../Processors/Opus/TrackFormatResolver.cs | 91 +++++++++ DeepDrftModels/Enums/AudioFormat.cs | 24 +++ DeepDrftTests/TrackFormatResolverTests.cs | 180 ++++++++++++++++++ 5 files changed, 323 insertions(+) create mode 100644 DeepDrftContent/Processors/Opus/ResolvedAudio.cs create mode 100644 DeepDrftContent/Processors/Opus/TrackFormatResolver.cs create mode 100644 DeepDrftModels/Enums/AudioFormat.cs create mode 100644 DeepDrftTests/TrackFormatResolverTests.cs diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index b45fe56..2b66f0f 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -77,6 +77,11 @@ namespace DeepDrftAPI builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Opus delivery format resolution + sidecar lookup (Phase 18.2). The seam 18.3 calls behind + // the ?format= stream param and the sidecar path. Stateless over the FileDatabase + content + // service singletons; the lossless branch reuses the existing read path unchanged (C2). + builder.Services.AddSingleton(); + return Task.CompletedTask; } diff --git a/DeepDrftContent/Processors/Opus/ResolvedAudio.cs b/DeepDrftContent/Processors/Opus/ResolvedAudio.cs new file mode 100644 index 0000000..85b3813 --- /dev/null +++ b/DeepDrftContent/Processors/Opus/ResolvedAudio.cs @@ -0,0 +1,23 @@ +using DeepDrftContent.FileDatabase.Models; +using DeepDrftModels.Enums; + +namespace DeepDrftContent.Processors.Opus; + +/// +/// The outcome of resolving a track + requested to a concrete artifact +/// (Phase 18.2). Carries the bytes, the content-type that matches what was actually returned, +/// and the format actually served — which may differ from the requested one when the C2 fallback fires +/// (Opus requested, no Opus artifact → the lossless artifact + its content-type). The delivery layer +/// (18.3) sets the response Content-Type from so the eventual decoder +/// picks the right decoder for the bytes it receives, not the bytes the listener asked for. +/// +/// The resolved audio artifact (never null when a resolution succeeds). +/// The MIME type of (e.g. audio/ogg for Opus, +/// or the source's real MIME for lossless). +/// The format actually returned. Equal to the requested format on a direct +/// hit; when an Opus request fell back. +public sealed record ResolvedAudio(AudioBinary Audio, string ContentType, AudioFormat ResolvedFormat) +{ + /// True when an Opus request was served the lossless artifact because no Opus existed (C2). + public bool DidFallBack(AudioFormat requested) => requested != ResolvedFormat; +} diff --git a/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs b/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs new file mode 100644 index 0000000..070e820 --- /dev/null +++ b/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs @@ -0,0 +1,91 @@ +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftModels.Enums; +using Microsoft.Extensions.Logging; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftContent.Processors.Opus; + +/// +/// The server-side format resolution + sidecar lookup seam (Phase 18.2). Given a track's +/// EntryKey and a requested , returns the correct audio artifact and the +/// content-type that matches it; given an EntryKey, returns the Opus seek/setup sidecar bytes. +/// Downstream waves call this — 18.3 wires it behind the ?format= stream param and serves the +/// sidecar over HTTP; this wave delivers only the seam, not the HTTP surface. +/// +/// Additive and non-breaking (C2): the lossless branch reads the source exactly as the existing stream +/// path does (via ), and an Opus request for a track +/// with no Opus artifact falls back to lossless rather than failing. Mirrors the +/// derived-artifact lookup precedent: read from the dedicated vault, +/// swallow misses to null (FileDatabase convention), let the caller decide. +/// +/// +public sealed class TrackFormatResolver +{ + private readonly FileDb _fileDatabase; + private readonly TrackContentService _trackContentService; + private readonly ILogger _logger; + + public TrackFormatResolver( + FileDb fileDatabase, + TrackContentService trackContentService, + ILogger logger) + { + _fileDatabase = fileDatabase; + _trackContentService = trackContentService; + _logger = logger; + } + + /// + /// Resolves + to the audio artifact to + /// serve plus its content-type. resolves the source artifact in the + /// tracks vault with its real MIME (WAV/MP3/FLAC). resolves the + /// derived Opus artifact (audio/ogg) when present, and falls back to lossless + /// when it is not (C2). Returns null only when even the lossless source is missing — i.e. the track has + /// no audio at all (an unknown key or a genuinely empty vault), the one case the caller treats as 404. + /// + public async Task ResolveAsync(string entryKey, AudioFormat requestedFormat) + { + if (requestedFormat == AudioFormat.Opus) + { + var opus = await _fileDatabase.LoadResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey)); + if (opus is not null) + return new ResolvedAudio(opus, MimeTypeExtensions.GetMimeType(opus.Extension), AudioFormat.Opus); + + // C2 fallback: no Opus artifact yet (legacy row, not backfilled, or transcode failed). Degrade + // to lossless rather than 404 — Opus is strictly additive; its absence never means "no audio". + _logger.LogInformation( + "Opus requested for {EntryKey} but no Opus artifact exists; falling back to lossless.", entryKey); + } + + return await ResolveLosslessAsync(entryKey); + } + + /// + /// Resolves the lossless source artifact and its real MIME — the existing read path, unchanged. Shared + /// by the explicit-lossless branch and the Opus fallback so both produce identical bytes + content-type. + /// + private async Task ResolveLosslessAsync(string entryKey) + { + var source = await _trackContentService.GetAudioBinaryAsync(entryKey); + if (source is null) + return null; + + return new ResolvedAudio(source, MimeTypeExtensions.GetMimeType(source.Extension), AudioFormat.Lossless); + } + + /// + /// Returns the Opus setup-header + seek-index sidecar bytes for , or null + /// when no sidecar is stored (no Opus artifact yet, or an older derive predating the sidecar). 18.3 + /// serves these on their own path; 18.4 fetches them once on track load and parses them into the + /// client's OpusSeekData. The bytes are the raw blob + /// ([uint32 setupHeaderLength][setup-header][seek-index]) exactly as 18.1 stored them. + /// + public async Task GetOpusSidecarAsync(string entryKey) + { + var sidecar = await _fileDatabase.LoadResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey)); + return sidecar?.Buffer; + } +} diff --git a/DeepDrftModels/Enums/AudioFormat.cs b/DeepDrftModels/Enums/AudioFormat.cs new file mode 100644 index 0000000..9b95067 --- /dev/null +++ b/DeepDrftModels/Enums/AudioFormat.cs @@ -0,0 +1,24 @@ +namespace DeepDrftModels.Enums; + +/// +/// The delivery format a listener requests for a track's audio (Phase 18). One TrackEntity / +/// EntryKey addresses both renderings — "one source, multiple views" applied to delivery (C5). +/// Lives here, not in the content library, because it is a cross-boundary contract: the API stream +/// endpoint (18.3) parses it off the ?format= query param, the WASM client (18.4 / 18.6) selects +/// it, and the content-side resolver (18.2) resolves it to bytes — all three reference one enum. +/// +public enum AudioFormat +{ + /// + /// The existing source artifact in the tracks vault, served byte-for-byte with its real MIME + /// (WAV/MP3/FLAC — do not assume WAV). The universal, always-present rendering. + /// + Lossless, + + /// + /// The derived low-data Ogg Opus 320 artifact in the track-opus vault (audio/ogg). A + /// best-effort derived artifact: when absent (not yet transcoded, or transcode failed) a request for + /// it falls back to rather than 404ing (C2). + /// + Opus +} diff --git a/DeepDrftTests/TrackFormatResolverTests.cs b/DeepDrftTests/TrackFormatResolverTests.cs new file mode 100644 index 0000000..8aa08f2 --- /dev/null +++ b/DeepDrftTests/TrackFormatResolverTests.cs @@ -0,0 +1,180 @@ +using DeepDrftContent; +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; +using DeepDrftModels.Enums; +using Microsoft.Extensions.Logging.Abstractions; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftTests; + +/// +/// Integration tests for the Phase 18.2 format resolution + sidecar lookup seam +/// () over a real . They exercise the four +/// resolution branches the brief specifies — lossless, Opus hit, the C2 Opus→lossless fallback, and +/// the unknown-track miss — plus sidecar hit/miss. Artifacts are seeded into the vaults exactly as +/// 18.1's stores them (Opus audio under the bare EntryKey, sidecar +/// under the -sidecar-suffixed key, the source in the tracks vault), so the test is faithful +/// to the real storage convention rather than a stand-in. +/// +[TestFixture] +public class TrackFormatResolverTests +{ + private string _testDir = string.Empty; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), "TrackFormatResolverTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* Best-effort cleanup — ignore failures */ } + } + + private static TrackFormatResolver CreateResolver(FileDb fileDatabase) + { + // The resolver only calls GetAudioBinaryAsync (a vault read), which never touches the router — + // but TrackContentService requires one, so supply a real router with real processors. + var router = new AudioProcessorRouter( + new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()); + var contentService = new TrackContentService(fileDatabase, router); + return new TrackFormatResolver( + fileDatabase, contentService, NullLogger.Instance); + } + + // Seeds a source artifact in the tracks vault with the given extension, mirroring how the upload path + // stores the original bytes (WAV/MP3/FLAC). Returns the bytes for downstream identity assertions. + private static async Task SeedSourceAsync(FileDb db, string entryKey, string extension) + { + await db.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio); + var bytes = new byte[] { 1, 2, 3, 4, 5 }; + var audio = new AudioBinary(new AudioBinaryParams(bytes, bytes.Length, extension, 12.0, 1411)); + var ok = await db.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audio); + Assert.That(ok, Is.True, "source seed must succeed"); + return bytes; + } + + // Seeds the Opus audio + sidecar in the track-opus vault exactly as OpusTranscodeService does: + // audio under OpusAudioKey (the bare EntryKey) with the .opus extension, sidecar under OpusSidecarKey. + private static async Task<(byte[] opus, byte[] sidecar)> SeedOpusAsync(FileDb db, string entryKey) + { + await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio); + + var opusBytes = new byte[] { 9, 9, 9 }; + var opusAudio = new AudioBinary(new AudioBinaryParams( + opusBytes, opusBytes.Length, OggOpusConstants.OpusExtension, 12.0, 320)); + var audioOk = await db.RegisterResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opusAudio); + Assert.That(audioOk, Is.True, "opus audio seed must succeed"); + + var sidecarBytes = new byte[] { 7, 7, 7, 7 }; + var sidecar = new MediaBinary(new MediaBinaryParams( + sidecarBytes, sidecarBytes.Length, OggOpusConstants.SidecarExtension)); + var sidecarOk = await db.RegisterResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar); + Assert.That(sidecarOk, Is.True, "sidecar seed must succeed"); + + return (opusBytes, sidecarBytes); + } + + [Test] + public async Task ResolveAsync_Lossless_ReturnsSourceArtifactWithItsRealMime() + { + var db = (await FileDb.FromAsync(_testDir))!; + const string entryKey = "lossless-track"; + // A FLAC source — proves the lossless branch does NOT assume WAV: the content-type tracks the + // stored extension's MIME, not a hard-coded audio/wav. + var bytes = await SeedSourceAsync(db, entryKey, ".flac"); + var resolver = CreateResolver(db); + + var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Lossless); + + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Lossless)); + Assert.That(resolved.ContentType, Is.EqualTo("audio/flac")); + Assert.That(resolved.Audio.Buffer, Is.EqualTo(bytes)); + Assert.That(resolved.DidFallBack(AudioFormat.Lossless), Is.False); + } + + [Test] + public async Task ResolveAsync_OpusWhenArtifactExists_ReturnsOpusWithOggContentType() + { + var db = (await FileDb.FromAsync(_testDir))!; + const string entryKey = "opus-track"; + await SeedSourceAsync(db, entryKey, ".wav"); + var (opusBytes, _) = await SeedOpusAsync(db, entryKey); + var resolver = CreateResolver(db); + + var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Opus); + + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Opus)); + Assert.That(resolved.ContentType, Is.EqualTo("audio/ogg")); + Assert.That(resolved.Audio.Buffer, Is.EqualTo(opusBytes)); + Assert.That(resolved.DidFallBack(AudioFormat.Opus), Is.False); + } + + [Test] + public async Task ResolveAsync_OpusWhenNoArtifact_FallsBackToLosslessNeverNull() + { + var db = (await FileDb.FromAsync(_testDir))!; + const string entryKey = "no-opus-track"; + // Source exists; no Opus artifact has been derived. The C2 rule: degrade to lossless, never 404. + var bytes = await SeedSourceAsync(db, entryKey, ".wav"); + var resolver = CreateResolver(db); + + var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Opus); + + Assert.That(resolved, Is.Not.Null, "Opus absence must degrade to lossless, not null/404"); + Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Lossless), + "the resolved format must reflect what was actually returned"); + Assert.That(resolved.ContentType, Is.EqualTo("audio/wav"), + "a fallback returns the lossless content-type so the decoder picks the right decoder"); + Assert.That(resolved.Audio.Buffer, Is.EqualTo(bytes)); + Assert.That(resolved.DidFallBack(AudioFormat.Opus), Is.True); + } + + [Test] + public async Task ResolveAsync_UnknownTrack_ReturnsNull() + { + var db = (await FileDb.FromAsync(_testDir))!; + var resolver = CreateResolver(db); + + // No source at all — the one case the caller maps to 404. Holds for both requested formats: + // Opus falls back to lossless, finds nothing, and returns null too. + Assert.That(await resolver.ResolveAsync("ghost", AudioFormat.Lossless), Is.Null); + Assert.That(await resolver.ResolveAsync("ghost", AudioFormat.Opus), Is.Null); + } + + [Test] + public async Task GetOpusSidecarAsync_WhenPresent_ReturnsBytes() + { + var db = (await FileDb.FromAsync(_testDir))!; + const string entryKey = "sidecar-track"; + var (_, sidecarBytes) = await SeedOpusAsync(db, entryKey); + var resolver = CreateResolver(db); + + var bytes = await resolver.GetOpusSidecarAsync(entryKey); + + Assert.That(bytes, Is.Not.Null); + Assert.That(bytes, Is.EqualTo(sidecarBytes)); + } + + [Test] + public async Task GetOpusSidecarAsync_WhenAbsent_ReturnsNull() + { + var db = (await FileDb.FromAsync(_testDir))!; + var resolver = CreateResolver(db); + + // No Opus artifacts derived for this track — the sidecar lookup misses to null, not an exception. + var bytes = await resolver.GetOpusSidecarAsync("no-sidecar-track"); + + Assert.That(bytes, Is.Null); + } +} From 740d01a67fdcc1e9325cd6a7fce1f69b3e26439e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 08:34:37 -0400 Subject: [PATCH 05/54] =?UTF-8?q?feature:=20Phase=2018.3=20=E2=80=94=20Opu?= =?UTF-8?q?s=20delivery=20transport=20(=3Fformat=3D=20stream=20+=20seek=20?= =?UTF-8?q?sidecar=20endpoint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DeepDrftAPI/Controllers/TrackController.cs | 77 ++++- .../Clients/TrackMediaClient.cs | 56 +++- .../Controllers/TrackProxyController.cs | 55 +++- DeepDrftTests/TrackFormatDeliveryTests.cs | 289 ++++++++++++++++++ 4 files changed, 462 insertions(+), 15 deletions(-) create mode 100644 DeepDrftTests/TrackFormatDeliveryTests.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index a277cbb..9878ca4 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -5,6 +5,7 @@ using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Services; using DeepDrftContent.FileDatabase.Models; using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; using DeepDrftData; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; @@ -20,6 +21,7 @@ public class TrackController : ControllerBase private readonly UnifiedTrackService _unifiedService; private readonly ITrackService _sqlTrackService; private readonly WaveformProfileService _waveformProfileService; + private readonly TrackFormatResolver _formatResolver; private readonly UploadStagingDirectory _stagingDirectory; private readonly ILogger _logger; @@ -35,6 +37,7 @@ public class TrackController : ControllerBase UnifiedTrackService unifiedService, ITrackService sqlTrackService, WaveformProfileService waveformProfileService, + TrackFormatResolver formatResolver, UploadStagingDirectory stagingDirectory, ILogger logger) { @@ -43,6 +46,7 @@ public class TrackController : ControllerBase _unifiedService = unifiedService; _sqlTrackService = sqlTrackService; _waveformProfileService = waveformProfileService; + _formatResolver = formatResolver; _stagingDirectory = stagingDirectory; _logger = logger; } @@ -642,10 +646,27 @@ public class TrackController : ControllerBase // --- Parameterized routes --- + // GET api/track/{trackId}?format=opus|lossless (unauthenticated) + // Streams the track's audio bytes with HTTP Range support. The optional `format` selector (Phase 18.3) + // picks the delivery rendering: absent or unrecognized ⇒ Lossless (byte-identical to pre-Phase-18 — + // the existing zero-copy disk-stream path, untouched); `opus` ⇒ the derived Ogg Opus 320 artifact + // when present, falling back to lossless when it is not (C2 — never 404/silence). The Opus path serves + // the resolved in-memory bytes via File(..., enableRangeProcessing: true) so Range: bytes=X- still + // yields 206 (load-bearing for streaming + seek), matching the lossless disk-stream's range behavior. [HttpGet("{trackId}")] - public async Task GetTrack(string trackId) + public async Task GetTrack(string trackId, [FromQuery] string? format = null) { - _logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId); + _logger.LogInformation("GetTrack called with trackId: {TrackId}, format: {Format}", trackId, format); + + // Only `opus` diverges from today's behavior; everything else (null, "lossless", garbage) takes the + // unchanged lossless disk-stream path below, preserving the large-file zero-copy streaming. Routing + // lossless through the resolver would force the whole source (up to ~1 GB) into memory per request — + // a regression the resolver's in-memory byte[] result is fine for Opus (small) but not for lossless. + if (Enum.TryParse(format, ignoreCase: true, out var requestedFormat) + && requestedFormat == AudioFormat.Opus) + { + return await GetTrackOpusAsync(trackId); + } try { @@ -700,6 +721,58 @@ public class TrackController : ControllerBase } } + // The ?format=opus arm of GetTrack. Resolves the Opus artifact (or the lossless fallback when none + // exists, C2) via TrackFormatResolver and serves the resolved bytes with explicit range processing. + // enableRangeProcessing:true is the load-bearing detail the 18.2 reviewer flagged: File(byte[], ...) + // does NOT get ASP.NET's automatic range handling unless asked, so without this flag a Range: bytes=X- + // would silently return the whole body as 200 instead of a 206 slice — breaking seek for the Opus path + // (and Phase 21 windowing). The resolver reports the *actually-served* format via ResolvedAudio, so the + // content-type matches the bytes (audio/ogg on a hit, the source MIME on a fallback) and the eventual + // client decoder dispatches correctly. + private async Task GetTrackOpusAsync(string trackId) + { + try + { + var resolved = await _formatResolver.ResolveAsync(trackId, AudioFormat.Opus); + if (resolved is null) + { + _logger.LogWarning("Track not found for Opus request: {TrackId}", trackId); + return NotFound(); + } + + _logger.LogInformation( + "Streaming track {TrackId} as {Format} ({Size} bytes, {ContentType})", + trackId, resolved.ResolvedFormat, resolved.Audio.Buffer.Length, resolved.ContentType); + + return File(resolved.Audio.Buffer, resolved.ContentType, enableRangeProcessing: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving track as Opus: {TrackId}", trackId); + return StatusCode(500, "Internal server error"); + } + } + + // GET api/track/{trackId}/opus/seekdata (unauthenticated) + // Returns the Opus setup-header + granule→byte seek-index sidecar bytes (Phase 18.3). The client + // fetches this once on track load and parses it into OpusSeekData (18.4) before issuing any Opus seek. + // Raw octet-stream — the bytes are the OpusSidecar blob exactly as 18.1 stored them. 404 when no sidecar + // is stored (no Opus artifact yet, or an older derive predating the sidecar); the client then degrades + // to lossless, mirroring the C2 posture of the audio path. Same public auth posture as the audio stream. + // The "opus/seekdata" literal suffix keeps this distinct from the audio and waveform routes. + [HttpGet("{trackId}/opus/seekdata")] + public async Task GetOpusSeekData(string trackId) + { + var sidecar = await _formatResolver.GetOpusSidecarAsync(trackId); + if (sidecar is null) + { + _logger.LogInformation("No Opus sidecar for track: {TrackId}", trackId); + return NotFound(); + } + + return File(sidecar, "application/octet-stream"); + } + // GET api/track/{trackId}/waveform (unauthenticated) // Returns the stored waveform loudness profile for a track, base64-encoded. Public listener // data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored diff --git a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs index f38d1d7..cd715e1 100644 --- a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; using Microsoft.Extensions.DependencyInjection; using NetBlocks.Models; @@ -45,23 +46,37 @@ public class TrackMediaClient } /// - /// Fetches the WAV stream for a track via an HTTP Range request starting at a + /// Fetches the audio stream for a track via an HTTP Range request starting at a /// file-absolute byte offset. is the position from - /// the start of the file on disk (including the WAV header) — callers seeking into - /// audio data must add the header size themselves. The cancellation token aborts - /// the in-flight server connection rather than leaving the server draining bytes - /// into a dead socket. + /// the start of the file on disk (including any container/header bytes) — callers + /// seeking into audio data must add the header size themselves. The cancellation + /// token aborts the in-flight server connection rather than leaving the server + /// draining bytes into a dead socket. + /// + /// selects the delivery rendering (Phase 18): the default + /// sends no format query param, so existing + /// callers hit the byte-identical pre-Phase-18 endpoint; + /// requests the low-data Ogg Opus artifact, which the server resolves and falls back to + /// lossless when absent (C2). The response + /// reports the format actually served, so the JS decoder dispatches on the real bytes. + /// /// public async Task> GetTrackMedia( string trackId, long byteOffset = 0, + AudioFormat format = AudioFormat.Lossless, CancellationToken cancellationToken = default) { try { // Same URL for every seek — only the Range header differs. byteOffset 0 is // not special-cased: "bytes=0-" requests the whole file from the start. - using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}"); + // Lossless omits the format param entirely so the request is byte-identical to + // the pre-Phase-18 endpoint; only Opus appends ?format=opus. + var uri = format == AudioFormat.Lossless + ? $"api/track/{trackId}" + : $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}"; + using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.Range = new RangeHeaderValue(byteOffset, null); // Use HttpCompletionOption.ResponseHeadersRead to get stream immediately @@ -115,4 +130,33 @@ public class TrackMediaClient return ApiResult.CreateFailResult(e.Message); } } + + /// + /// Fetches a track's Opus seek/setup sidecar — the combined OpusHead/OpusTags setup header plus the + /// granule→byte seek index (Phase 18). The caller (18.5 player wiring) fetches this once on track load + /// and parses it into the JS-side OpusSeekData before issuing any Opus seek. A 404 means no Opus + /// artifact / sidecar exists for the track (legacy row, not backfilled, or transcode failed); callers + /// treat that as "this track has no Opus seek data — stay on lossless" rather than an error, so it + /// surfaces as a fail result with a stable message rather than throwing (mirrors GetWaveformProfileAsync). + /// + public async Task> GetOpusSidecarAsync(string trackId, CancellationToken cancellationToken = default) + { + try + { + var response = await _http.GetAsync($"api/track/{trackId}/opus/seekdata", cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return ApiResult.CreateFailResult("No Opus sidecar available"); + } + + response.EnsureSuccessStatusCode(); + + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + return ApiResult.CreatePassResult(bytes); + } + catch (Exception e) + { + return ApiResult.CreateFailResult(e.Message); + } + } } diff --git a/DeepDrftPublic/Controllers/TrackProxyController.cs b/DeepDrftPublic/Controllers/TrackProxyController.cs index 104d2be..9ba40bc 100644 --- a/DeepDrftPublic/Controllers/TrackProxyController.cs +++ b/DeepDrftPublic/Controllers/TrackProxyController.cs @@ -205,21 +205,26 @@ public class TrackProxyController : ControllerBase /// /// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay. - /// Forwards the incoming Range header upstream and relays the upstream status - /// (200 full, 206 partial, 416 unsatisfiable) and range-related response headers - /// back to the browser verbatim. The proxy does not slice — the upstream already did. + /// Forwards the incoming Range header upstream and the optional format selector + /// (Phase 18.3 — opus|lossless, threaded the same way the listing params are), + /// and relays the upstream status (200 full, 206 partial, 416 unsatisfiable) and + /// range-related response headers back to the browser verbatim. The proxy does not + /// slice — the upstream already did. /// [HttpGet("{trackId}")] public async Task GetTrack( string trackId, + [FromQuery] string? format = null, CancellationToken ct = default) { var rangeHeader = Request.Headers.Range.ToString(); - _logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader); + _logger.LogInformation("Proxying track {TrackId} range '{Range}' format '{Format}'", trackId, rangeHeader, format); - var request = new HttpRequestMessage( - HttpMethod.Get, - $"api/track/{Uri.EscapeDataString(trackId)}"); + var path = $"api/track/{Uri.EscapeDataString(trackId)}"; + if (!string.IsNullOrWhiteSpace(format)) + path += $"?format={Uri.EscapeDataString(format)}"; + + var request = new HttpRequestMessage(HttpMethod.Get, path); // Forward the browser's Range header upstream so DeepDrftAPI slices the file. // TryAddWithoutValidation avoids RangeHeaderValue reparsing — we relay the raw @@ -355,4 +360,40 @@ public class TrackProxyController : ControllerBase return Content(json, "application/json"); } } + + /// + /// Proxies a track's Opus seek/setup sidecar (raw bytes) from DeepDrftAPI (Phase 18.3). Unauthenticated, + /// same posture as the audio stream forward. The sidecar is a small one-time fetch (≤ ~115 KB), so it is + /// buffered and relayed; a 404 (no Opus artifact / no sidecar stored) passes through so the client + /// degrades to lossless rather than treating it as an error. The "opus/seekdata" 3-segment route makes a + /// collision with the parameterized "{trackId}" audio route impossible. + /// + [HttpGet("{trackId}/opus/seekdata")] + public async Task GetOpusSeekData(string trackId, CancellationToken ct = default) + { + var path = $"api/track/{Uri.EscapeDataString(trackId)}/opus/seekdata"; + + HttpResponseMessage upstream; + try + { + upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Upstream call to DeepDrftAPI track/{TrackId}/opus/seekdata failed", trackId); + return StatusCode(502, "Upstream unavailable"); + } + + using (upstream) + { + if (!upstream.IsSuccessStatusCode) + { + _logger.LogWarning("DeepDrftAPI track/{TrackId}/opus/seekdata returned {Status}", trackId, (int)upstream.StatusCode); + return StatusCode((int)upstream.StatusCode); + } + + var bytes = await upstream.Content.ReadAsByteArrayAsync(ct); + return File(bytes, "application/octet-stream"); + } + } } diff --git a/DeepDrftTests/TrackFormatDeliveryTests.cs b/DeepDrftTests/TrackFormatDeliveryTests.cs new file mode 100644 index 0000000..eba434d --- /dev/null +++ b/DeepDrftTests/TrackFormatDeliveryTests.cs @@ -0,0 +1,289 @@ +using System.Text; +using DeepDrftAPI.Controllers; +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; +using DeepDrftModels.Enums; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ContentTrackService = DeepDrftContent.TrackContentService; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftTests; + +/// +/// Delivery-layer tests for the Phase 18.3 ?format= stream selector and the Opus seek/setup sidecar +/// endpoint on . These exercise the real , the real +/// , and the real over temp-directory +/// vaults — the same integration posture as . +/// +/// The SQL-only collaborators (UnifiedTrackService, ITrackService) are passed as null: the +/// actions under test (, ) +/// only touch the FileDatabase + resolver path, never the SQL services, so standing up a database is not +/// required to assert the delivery contract. +/// +/// The Range→206 contract is asserted at the load-bearing seam: ASP.NET performs the actual byte-slicing +/// for any whose .EnableRangeProcessing is true. The lossless +/// path proves this via the disk-stream ; the resolved Opus path via the +/// in-memory — both must report range processing enabled, the explicit fix +/// the 18.2 reviewer flagged for the byte[] path. +/// +[TestFixture] +public class TrackFormatDeliveryTests +{ + private string _testDir = string.Empty; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), "TrackFormatDeliveryTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* Best-effort cleanup — ignore failures */ } + } + + // --- Format resolution at the endpoint --- + + [Test] + public async Task GetTrack_FormatOpus_WhenOpusArtifactPresent_ServesOpusBytesAndOggContentType() + { + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false); + + var result = await controller.GetTrack(entryKey, format: "opus"); + + var file = result as FileContentResult; + Assert.That(file, Is.Not.Null, "Opus delivery serves an in-memory byte[] (FileContentResult)"); + Assert.Multiple(() => + { + Assert.That(file!.ContentType, Is.EqualTo("audio/ogg"), "Opus bytes must carry the audio/ogg content-type"); + Assert.That(file.FileContents, Is.EqualTo(OpusBytes), "The served bytes must be the Opus artifact, not the source"); + Assert.That(file.EnableRangeProcessing, Is.True, "Range processing must be enabled on the resolved Opus byte[] path"); + }); + } + + // --- The C2 fallback --- + + [Test] + public async Task GetTrack_FormatOpus_WhenNoOpusArtifact_FallsBackToLosslessBytesAndContentType() + { + // No Opus artifact stored — the resolver degrades to lossless (C2): the listener still gets audio, + // never a 404 or silence, and the content-type reports the lossless format actually served. + var (controller, entryKey) = await SeedAsync(withOpus: false, withSidecar: false); + + var result = await controller.GetTrack(entryKey, format: "opus"); + + var file = result as FileContentResult; + Assert.That(file, Is.Not.Null, "The fallback still serves resolved bytes via the byte[] path"); + Assert.Multiple(() => + { + Assert.That(file!.ContentType, Is.EqualTo("audio/wav"), "Fallback content-type must be the lossless source's MIME"); + Assert.That(file.FileContents, Is.EqualTo(_sourceWav), "Fallback must serve the lossless source bytes"); + Assert.That(file.EnableRangeProcessing, Is.True, "Range processing stays enabled on the fallback path too"); + }); + } + + [Test] + public async Task GetTrack_NoFormatParam_ServesLosslessDiskStream_ByteIdenticalToPrePhase18() + { + // The no-format path must be byte-identical to today: the zero-copy disk-stream FileStreamResult, + // NOT the resolver's in-memory byte[] path (which would force the whole source into memory). + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false); + + var result = await controller.GetTrack(entryKey, format: null); + + var file = result as FileStreamResult; + Assert.That(file, Is.Not.Null, "Lossless delivery streams from disk (FileStreamResult), not a byte[]"); + Assert.Multiple(() => + { + Assert.That(file!.ContentType, Is.EqualTo("audio/wav")); + Assert.That(file.EnableRangeProcessing, Is.True, "Range→206 must work on the lossless disk-stream path"); + }); + } + + [Test] + public async Task GetTrack_FormatLossless_TakesTheLosslessDiskStreamPath() + { + // An explicit format=lossless must behave exactly like no param — the disk-stream path, never Opus. + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false); + + var result = await controller.GetTrack(entryKey, format: "lossless"); + + Assert.That(result, Is.InstanceOf(), + "format=lossless must take the disk-stream path even when an Opus artifact exists"); + } + + [Test] + public async Task GetTrack_FormatOpus_WhenTrackDoesNotExist_Returns404() + { + var controller = BuildController(await FreshDbAsync()); + + var result = await controller.GetTrack("no-such-track", format: "opus"); + + Assert.That(result, Is.InstanceOf(), + "When even the lossless source is missing, the Opus request 404s (no audio at all)"); + } + + // --- Sidecar 200 / 404 --- + + [Test] + public async Task GetOpusSeekData_WhenSidecarPresent_Returns200WithRawBytes() + { + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: true); + + var result = await controller.GetOpusSeekData(entryKey); + + var file = result as FileContentResult; + Assert.That(file, Is.Not.Null, "A stored sidecar is served as raw bytes"); + Assert.Multiple(() => + { + Assert.That(file!.ContentType, Is.EqualTo("application/octet-stream")); + Assert.That(file.FileContents, Is.EqualTo(SidecarBytes), "The served bytes must be the stored sidecar blob"); + }); + } + + [Test] + public async Task GetOpusSeekData_WhenNoSidecar_Returns404() + { + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false); + + var result = await controller.GetOpusSeekData(entryKey); + + Assert.That(result, Is.InstanceOf(), + "No sidecar → 404, so the client degrades to lossless rather than treating it as an error"); + } + + // --- Fixtures + helpers --- + + private static readonly byte[] OpusBytes = Encoding.ASCII.GetBytes("OggS-fake-opus-payload-for-delivery-test"); + private static readonly byte[] SidecarBytes = Encoding.ASCII.GetBytes("setup-header+seek-index-sidecar-blob"); + + private byte[] _sourceWav = []; + + private async Task FreshDbAsync() + { + var db = await FileDb.FromAsync(_testDir); + Assert.That(db, Is.Not.Null); + return db!; + } + + // Seeds a track's lossless source in the tracks vault and, optionally, its Opus artifact and sidecar in + // the track-opus vault, then returns a controller wired over those real vaults plus the entry key. + private async Task<(TrackController Controller, string EntryKey)> SeedAsync(bool withOpus, bool withSidecar) + { + var db = await FreshDbAsync(); + var content = new ContentTrackService(db, new AudioProcessorRouter( + new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor())); + + var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav"); + _sourceWav = BuildMinimalPcmWav(2.0); + await File.WriteAllBytesAsync(wavPath, _sourceWav); + + var seeded = await content.AddTrackAsync(wavPath, "Track", "Artist"); + Assert.That(seeded, Is.Not.Null); + var entryKey = seeded!.EntryKey; + + // GetAudioBinaryAsync re-reads what AddTrackAsync stored, so the bytes we assert the fallback against + // are the exact stored source bytes (the processor may normalize the input WAV before storing). + var storedSource = await content.GetAudioBinaryAsync(entryKey); + Assert.That(storedSource, Is.Not.Null); + _sourceWav = storedSource!.Buffer; + + await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio); + + if (withOpus) + { + var opus = new AudioBinary(new AudioBinaryParams(OpusBytes, OpusBytes.Length, ".opus", 2.0, 320)); + Assert.That( + await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus), + Is.True); + } + + if (withSidecar) + { + var sidecar = new MediaBinary(new MediaBinaryParams(SidecarBytes, SidecarBytes.Length, ".opusidx")); + Assert.That( + await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar), + Is.True); + } + + return (BuildController(db, content), entryKey); + } + + private static TrackController BuildController(FileDb db, ContentTrackService? content = null) + { + content ??= new ContentTrackService(db, new AudioProcessorRouter( + new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor())); + + var waveforms = new WaveformProfileService( + db, new AudioProcessor(), new RmsLoudnessAlgorithm(), + Options.Create(new WaveformProfileOptions()), NullLogger.Instance); + + var resolver = new TrackFormatResolver(db, content, NullLogger.Instance); + + // SQL-only collaborators are null: the delivery actions under test never touch them. + var controller = new TrackController( + content, + db, + unifiedService: null!, + sqlTrackService: null!, + waveforms, + resolver, + stagingDirectory: null!, + NullLogger.Instance) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + return controller; + } + + // Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites. + private static byte[] BuildMinimalPcmWav(double durationSeconds) + { + const int sampleRate = 44100; + const ushort channels = 1; + const ushort bitsPerSample = 16; + const ushort blockAlign = channels * (bitsPerSample / 8); + const uint byteRate = sampleRate * blockAlign; + + var frames = (int)(sampleRate * durationSeconds); + var data = new byte[frames * blockAlign]; + for (var i = 0; i < frames; i++) + { + var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue; + data[i * 2] = (byte)(sample & 0xFF); + data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF); + } + + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true); + + w.Write(Encoding.ASCII.GetBytes("RIFF")); + w.Write((uint)(36 + data.Length)); + w.Write(Encoding.ASCII.GetBytes("WAVE")); + + w.Write(Encoding.ASCII.GetBytes("fmt ")); + w.Write(16u); + w.Write((ushort)1); // PCM + w.Write(channels); + w.Write((uint)sampleRate); + w.Write(byteRate); + w.Write(blockAlign); + w.Write(bitsPerSample); + + w.Write(Encoding.ASCII.GetBytes("data")); + w.Write((uint)data.Length); + w.Write(data); + + w.Flush(); + return ms.ToArray(); + } +} From 261289c1b8d9aecb60784c37d4271e28b66a4565 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 08:34:39 -0400 Subject: [PATCH 06/54] =?UTF-8?q?feature:=20OpusFormatDecoder=20=E2=80=94?= =?UTF-8?q?=20Ogg-page-aligned=20segmenting,=20sidecar=20parser,=20accurat?= =?UTF-8?q?e=20index-based=20seek=20(Phase=2018.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DeepDrftPublic/Interop/audio/AudioPlayer.ts | 38 ++- .../Interop/audio/IFormatDecoder.ts | 6 +- .../Interop/audio/OpusCapability.ts | 95 +++++++ .../Interop/audio/OpusFormatDecoder.test.ts | 261 ++++++++++++++++++ .../Interop/audio/OpusFormatDecoder.ts | 169 ++++++++++++ DeepDrftPublic/Interop/audio/OpusSidecar.ts | 141 ++++++++++ DeepDrftPublic/Interop/audio/index.ts | 15 + DeepDrftPublic/tsconfig.json | 3 +- 8 files changed, 723 insertions(+), 5 deletions(-) create mode 100644 DeepDrftPublic/Interop/audio/OpusCapability.ts create mode 100644 DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts create mode 100644 DeepDrftPublic/Interop/audio/OpusFormatDecoder.ts create mode 100644 DeepDrftPublic/Interop/audio/OpusSidecar.ts diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts index 8e7e183..be36e90 100644 --- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts +++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts @@ -14,6 +14,8 @@ import { IFormatDecoder } from './IFormatDecoder.js'; import { WavFormatDecoder } from './WavFormatDecoder.js'; import { Mp3FormatDecoder } from './Mp3FormatDecoder.js'; import { FlacFormatDecoder } from './FlacFormatDecoder.js'; +import { OpusFormatDecoder } from './OpusFormatDecoder.js'; +import { OpusSeekData, parseSidecar } from './OpusSidecar.js'; export interface AudioResult { success: boolean; @@ -62,6 +64,11 @@ export class AudioPlayer { private onEndCallback: EndCallback | null = null; private progressInterval: number | null = null; + // Pending Opus sidecar (setup header + seek index), parsed from the one-time sidecar fetch and + // applied to the OpusFormatDecoder when the next Opus stream initializes. Wave 18.5 sets this + // (via setOpusSidecar) before initializeStreaming; this class never fetches it. + private pendingOpusSidecar: OpusSeekData | null = null; + constructor() { this.contextManager = new AudioContextManager(); this.streamDecoder = new StreamDecoder(this.contextManager); @@ -103,7 +110,7 @@ export class AudioPlayer { // Initialize new stream with the format decoder selected from Content-Type. this.isStreamingMode = true; - const formatDecoder = AudioPlayer.createFormatDecoder(contentType); + const formatDecoder = this.createFormatDecoder(contentType); this.streamDecoder.initialize(totalStreamLength, formatDecoder); return { success: true }; } catch (error) { @@ -112,15 +119,40 @@ export class AudioPlayer { } /** - * Select a format decoder from the response Content-Type. + * Inject the Opus sidecar (setup header + seek index) for the next Opus stream. Wave 18.5 calls + * this with the raw sidecar bytes (from its one-time HTTP fetch) BEFORE initializeStreaming; the + * parsed result is applied to the OpusFormatDecoder when the stream initializes. This is the + * injection seam — the player owns no transport, only the parse + hand-off. + * + * @returns success:false with an error if the bytes are not a valid sidecar blob. */ - private static createFormatDecoder(contentType: string): IFormatDecoder { + setOpusSidecar(sidecarBytes: Uint8Array): AudioResult { + const parsed = parseSidecar(sidecarBytes); + if (!parsed) { + return { success: false, error: 'Invalid Opus sidecar blob' }; + } + this.pendingOpusSidecar = parsed; + return { success: true }; + } + + /** + * Select a format decoder from the response Content-Type. For Opus, applies the pending sidecar + * (if 18.5 has set one) so the decoder has its setup bytes + seek index before stream init. + */ + private createFormatDecoder(contentType: string): IFormatDecoder { if (contentType.includes('audio/mpeg') || contentType.includes('audio/mp3')) { return new Mp3FormatDecoder(); } if (contentType.includes('audio/flac') || contentType.includes('audio/x-flac')) { return new FlacFormatDecoder(); } + if (contentType.includes('audio/ogg') || contentType.includes('audio/opus')) { + const decoder = new OpusFormatDecoder(); + if (this.pendingOpusSidecar) { + decoder.setSidecar(this.pendingOpusSidecar); + } + return decoder; + } return new WavFormatDecoder(); // default (audio/wav, unknown) } diff --git a/DeepDrftPublic/Interop/audio/IFormatDecoder.ts b/DeepDrftPublic/Interop/audio/IFormatDecoder.ts index bd9fe1e..0964327 100644 --- a/DeepDrftPublic/Interop/audio/IFormatDecoder.ts +++ b/DeepDrftPublic/Interop/audio/IFormatDecoder.ts @@ -1,3 +1,5 @@ +import { OpusSeekData } from './OpusSidecar.js'; + /** * FormatInfo: parsed header data needed to stream and seek an audio file. * Populated by IFormatDecoder.tryParseHeader; used by StreamDecoder throughout playback. @@ -36,8 +38,10 @@ export interface FormatInfo { * MP3 VBR: Xing/VBRI TOC (100-entry Uint8Array, values are file-percentage * 255). * FLAC: SeekTable (array of {sampleNumber: number, streamOffset: number} — stream_offset * is bytes from the start of audio frames, i.e. after all metadata blocks). + * Opus: OpusSeekData — the precomputed granule->byte index + OpusHead/OpusTags setup bytes, + * parsed from the sidecar artifact (NOT byteRate math; see OpusFormatDecoder). */ - seekData?: Mp3VbrSeekData | FlacSeekData | null; + seekData?: Mp3VbrSeekData | FlacSeekData | OpusSeekData | null; } export interface Mp3VbrSeekData { diff --git a/DeepDrftPublic/Interop/audio/OpusCapability.ts b/DeepDrftPublic/Interop/audio/OpusCapability.ts new file mode 100644 index 0000000..d6b8719 --- /dev/null +++ b/DeepDrftPublic/Interop/audio/OpusCapability.ts @@ -0,0 +1,95 @@ +/** + * OpusCapability - runtime detection of Ogg-Opus decode support. + * + * The bespoke graph decodes segments via `AudioContext.decodeAudioData`. Ogg-Opus support there + * is long-standing in Chrome and Firefox but arrived in Safari only at 18.4 (macOS 15.4 / iOS 18.4, + * March 2025); older Safari decodes Opus only in a CAF container, not Ogg. iOS Safari is a primary + * music-listening surface, so a browser that cannot decode Ogg Opus must fall back to the lossless + * WAV path (§3.4 / OQ2). + * + * This module is the detection *seam* only — it answers "can this browser decode Ogg Opus?". The + * player (waves 18.5 / 18.6) consumes the answer to choose the delivery format; this module never + * touches the player or the stream request. + * + * Detection is a genuine probe: a tiny in-memory Ogg-Opus blob is handed to `decodeAudioData`. A + * UA/version gate was rejected because Safari's Opus story is version-specific and UA strings lie; + * a real decode attempt is authoritative. The result is cached after the first probe (capability + * does not change within a session). + */ + +/** + * A minimal, valid Ogg-Opus file: an OpusHead page, an OpusTags page, and one audio page carrying a + * single 20 ms silence packet (mono, 48 kHz). Base64-encoded; ~250 bytes decoded. This is the + * smallest blob a conformant decoder will accept and a non-supporting decoder will reject, which is + * exactly the discriminator we need. + */ +const PROBE_OGG_OPUS_BASE64 = + 'T2dnUwACAAAAAAAAAACRYwAAAAAAANieBHsBE09wdXNIZWFkAQEAAIC7AAAAAABPZ2dTAAAAAAAA' + + 'AAAAAJFjAAABAAAAUkOcUAEMT3B1c1RhZ3MAAAAAAAAAAE9nZ1MABABAAQAAAAAAkWMAAAIAAABU' + + '/9D/A2P4//////////////////////////////////////////////////////////////////8='; + +let cachedSupport: Promise | null = null; + +/** + * Resolve whether this browser can decode Ogg Opus via `decodeAudioData`. Cached after the first + * call. Never rejects — a probe failure resolves to `false` (treat as unsupported, fall back to + * lossless). Pass an existing `AudioContext`/`OfflineAudioContext` to avoid allocating one; if none + * is given, a short-lived `OfflineAudioContext` is created and torn down. + */ +export function canDecodeOggOpus(context?: BaseAudioContext): Promise { + if (cachedSupport === null) { + cachedSupport = probe(context); + } + return cachedSupport; +} + +async function probe(context?: BaseAudioContext): Promise { + let ctx = context; + let ownsContext = false; + + try { + if (!ctx) { + const OfflineCtor = + (globalThis as { OfflineAudioContext?: typeof OfflineAudioContext }).OfflineAudioContext ?? + (globalThis as { webkitOfflineAudioContext?: typeof OfflineAudioContext }).webkitOfflineAudioContext; + if (!OfflineCtor) return false; + // 1 channel, 1 frame, 48 kHz — the smallest legal context; we never render it. + ctx = new OfflineCtor(1, 1, OPUS_PROBE_SAMPLE_RATE); + ownsContext = true; + } + + const buffer = base64ToArrayBuffer(PROBE_OGG_OPUS_BASE64); + // decodeAudioData detaches the buffer; the probe blob is single-use, so that is fine. + await decode(ctx, buffer); + return true; + } catch { + // DOMException (unsupported / corrupt) or any allocation failure -> unsupported. + return false; + } finally { + // OfflineAudioContext has no close() in all engines; guard it. + if (ownsContext && ctx && 'close' in ctx && typeof (ctx as AudioContext).close === 'function') { + try { await (ctx as AudioContext).close(); } catch { /* best-effort teardown */ } + } + } +} + +const OPUS_PROBE_SAMPLE_RATE = 48000; + +/** Promisify decodeAudioData; older Safari only supports the callback form. */ +function decode(ctx: BaseAudioContext, buffer: ArrayBuffer): Promise { + return new Promise((resolve, reject) => { + const result = ctx.decodeAudioData(buffer, resolve, reject); + // Modern engines return a Promise; bridge it so a rejection isn't dropped. + if (result && typeof (result as Promise).then === 'function') { + (result as Promise).then(resolve, reject); + } + }); +} + +function base64ToArrayBuffer(b64: string): ArrayBuffer { + const binary = atob(b64); + const buffer = new ArrayBuffer(binary.length); + const out = new Uint8Array(buffer); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return buffer; +} diff --git a/DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts b/DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts new file mode 100644 index 0000000..67843f3 --- /dev/null +++ b/DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts @@ -0,0 +1,261 @@ +/** + * OpusFormatDecoder / OpusSidecar tests. + * + * There is no TS test runner configured in this repo (no package.json, no jest/vitest, no other + * *.test.ts). Rather than introduce a heavy harness, this file is a self-contained, zero-dependency + * test: a ~15-line inline assert/test harness, no `node:` imports, no DOM. It exercises the pure + * parser / resolver / alignment logic (none of which touches the DOM or Web Audio). + * + * It is EXCLUDED from the production tsc build (tsconfig `exclude: Interop/**\/*.test.ts`) so it + * never ships in wwwroot/js. To run it (Node 22+ strips TS types natively — no tsc, no deps), the + * test's `.js` import specifiers must resolve to the COMPILED decoder modules, so run a copy from + * the compiled output directory: + * + * # 1. produce the compiled decoder modules (the normal build already does this): + * dotnet build DeepDrftPublic/DeepDrftPublic.csproj + * # 2. run this test next to the compiled .js siblings (Node strips the types at load): + * cp DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts DeepDrftPublic/wwwroot/js/audio/ + * node DeepDrftPublic/wwwroot/js/audio/OpusFormatDecoder.test.ts + * + * A thrown error / non-zero exit signals failure; "ALL TESTS PASSED" signals success. (The + * copied file lives only in the gitignored wwwroot/js output; the source under Interop is the + * committed artifact.) + * + * The sidecar bytes built in `makeSidecar` reproduce the C# wire format byte-for-byte + * (DeepDrftContent.Processors.Opus.OpusSidecar.ToBytes / OggOpusSeekIndex.ToBytes): + * [uint32 setupHeaderLength][setup bytes] + * [uint64 totalByteLength][double totalDuration][uint32 count][uint16 preSkip][uint16 reserved] + * count x [uint64 granulePosition][uint64 byteOffset] — all little-endian. + * The C# serializer is the source of truth; this verifies the TS parser is its exact counterpart. + */ + +import { parseSidecar, presentationTimeSeconds, OPUS_SAMPLE_RATE } from './OpusSidecar.js'; +import type { OpusSeekData } from './OpusSidecar.js'; +import { OpusFormatDecoder } from './OpusFormatDecoder.js'; +import type { FormatInfo } from './IFormatDecoder.js'; + +// --- tiny inline harness (no dependencies) --------------------------------------------------- +let passed = 0; +const failures: string[] = []; +function test(name: string, fn: () => void): void { + try { + fn(); + passed++; + } catch (e) { + failures.push(`FAIL: ${name}\n ${(e as Error).message}`); + } +} +function assertEqual(actual: unknown, expected: unknown, msg?: string): void { + if (actual !== expected) { + throw new Error(`${msg ?? 'assertEqual'}: expected ${String(expected)}, got ${String(actual)}`); + } +} +function assertArray(actual: ArrayLike, expected: number[], msg?: string): void { + const a = Array.from(actual); + if (a.length !== expected.length || a.some((v, i) => v !== expected[i])) { + throw new Error(`${msg ?? 'assertArray'}: expected [${expected}], got [${a}]`); + } +} +function assertNull(actual: unknown, msg?: string): void { + if (actual !== null) throw new Error(`${msg ?? 'assertNull'}: expected null, got ${String(actual)}`); +} +function assertNotNull(actual: T | null, msg?: string): T { + if (actual === null) throw new Error(`${msg ?? 'assertNotNull'}: got null`); + return actual; +} + +interface SidecarSpec { + setupHeader: number[]; + totalByteLength: number; + totalDuration: number; + preSkip: number; + points: Array<{ granule: number; byteOffset: number }>; +} + +/** Serialize a sidecar blob exactly as the C# OpusSidecar/OggOpusSeekIndex writers do. */ +function makeSidecar(spec: SidecarSpec): Uint8Array { + const SEEK_INDEX_HEADER_SIZE = 24; + const SEEK_POINT_SIZE = 16; + const setupLen = spec.setupHeader.length; + const total = 4 + setupLen + SEEK_INDEX_HEADER_SIZE + spec.points.length * SEEK_POINT_SIZE; + + const bytes = new Uint8Array(total); + const view = new DataView(bytes.buffer); + + view.setUint32(0, setupLen, true); + bytes.set(spec.setupHeader, 4); + + let p = 4 + setupLen; + writeUint64(view, p, spec.totalByteLength); + view.setFloat64(p + 8, spec.totalDuration, true); + view.setUint32(p + 16, spec.points.length, true); + view.setUint16(p + 20, spec.preSkip, true); + // bytes 22-23 reserved (zero) + + p += SEEK_INDEX_HEADER_SIZE; + for (const pt of spec.points) { + writeUint64(view, p, pt.granule); + writeUint64(view, p + 8, pt.byteOffset); + p += SEEK_POINT_SIZE; + } + return bytes; +} + +function writeUint64(view: DataView, offset: number, value: number): void { + view.setUint32(offset, value >>> 0, true); + view.setUint32(offset + 4, Math.floor(value / 0x100000000), true); +} + +function formatInfoFor(sidecar: Uint8Array): FormatInfo { + const decoder = new OpusFormatDecoder(); + const parsed = assertNotNull(parseSidecar(sidecar), 'sidecar should parse'); + decoder.setSidecar(parsed); + return assertNotNull(decoder.tryParseHeader([], 0), 'tryParseHeader should build FormatInfo'); +} + +// --- parseSidecar: byte-for-byte round-trip against the C# layout ----------------------------- + +test('parseSidecar round-trips the C# binary layout exactly', () => { + const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; // "OpusHead" stand-in + const spec: SidecarSpec = { + setupHeader: setup, + totalByteLength: 1_234_567, + totalDuration: 212.5, + preSkip: 312, + points: [ + { granule: 312, byteOffset: 4096 }, // first point: granule == preSkip -> t=0 + { granule: 312 + 24000, byteOffset: 9000 }, // +0.5 s + { granule: 312 + 48000, byteOffset: 14000 }, // +1.0 s + ], + }; + + const parsed: OpusSeekData = assertNotNull(parseSidecar(makeSidecar(spec))); + assertEqual(parsed.kind, 'opus-sidecar', 'kind'); + assertArray(parsed.setupHeaderBytes, setup, 'setup header bytes'); + assertEqual(parsed.totalByteLength, spec.totalByteLength, 'totalByteLength'); + assertEqual(parsed.totalDurationSeconds, spec.totalDuration, 'totalDuration'); + assertEqual(parsed.preSkip, spec.preSkip, 'preSkip'); + assertEqual(parsed.points.length, 3, 'point count'); + assertEqual(parsed.points[1].granulePosition, 312 + 24000, 'point[1].granule'); + assertEqual(parsed.points[1].byteOffset, 9000, 'point[1].byteOffset'); +}); + +test('parseSidecar honours a borrowed view byteOffset (sidecar not at buffer start)', () => { + const blob = makeSidecar({ + setupHeader: [1, 2, 3, 4], + totalByteLength: 100, + totalDuration: 1.0, + preSkip: 0, + points: [{ granule: 0, byteOffset: 8 }], + }); + const padded = new Uint8Array(blob.length + 7); + padded.set(blob, 7); + const parsed = assertNotNull(parseSidecar(padded.subarray(7))); + assertArray(parsed.setupHeaderBytes, [1, 2, 3, 4], 'borrowed setup bytes'); + assertEqual(parsed.points[0].byteOffset, 8, 'borrowed point offset'); +}); + +test('parseSidecar returns null on a truncated blob', () => { + const blob = makeSidecar({ + setupHeader: [0], + totalByteLength: 1, + totalDuration: 0, + preSkip: 0, + points: [{ granule: 0, byteOffset: 0 }], + }); + assertNull(parseSidecar(blob.subarray(0, 3)), 'short of length prefix'); + assertNull(parseSidecar(blob.subarray(0, blob.length - 4)), 'declared count overruns'); +}); + +test('presentationTimeSeconds applies preSkip and clamps at zero (RFC 7845)', () => { + assertEqual(presentationTimeSeconds(312, 312), 0, 'granule == preSkip'); + assertEqual(presentationTimeSeconds(0, 312), 0, 'below preSkip clamps'); + assertEqual(presentationTimeSeconds(312 + OPUS_SAMPLE_RATE, 312), 1.0, '+48000 -> 1 s'); +}); + +// --- calculateByteOffset: binary search over the precomputed index (exact, not interpolation) - + +test('calculateByteOffset returns the page-start of the largest entry with time <= t', () => { + const points = [0, 1, 2, 3].map(i => ({ + granule: 1000 + i * (OPUS_SAMPLE_RATE / 2), + byteOffset: 4096 + i * 5000, + })); + const info = formatInfoFor(makeSidecar({ + setupHeader: [9, 9, 9, 9], totalByteLength: 999_999, totalDuration: 1.5, preSkip: 1000, points, + })); + const d = new OpusFormatDecoder(); + assertEqual(d.calculateByteOffset(info, 0.0), 4096, 't=0 -> first point'); + assertEqual(d.calculateByteOffset(info, 0.4), 4096, 'just before bucket 1'); + assertEqual(d.calculateByteOffset(info, 0.5), 9096, 'exactly bucket 1'); + assertEqual(d.calculateByteOffset(info, 0.9), 9096, 'within bucket 1'); + assertEqual(d.calculateByteOffset(info, 1.0), 14096, 'exactly bucket 2'); + assertEqual(d.calculateByteOffset(info, 99), 19096, 'past end -> last point'); +}); + +test('calculateByteOffset never interpolates between points', () => { + const info = formatInfoFor(makeSidecar({ + setupHeader: [0], totalByteLength: 10_000, totalDuration: 1.0, preSkip: 0, + points: [{ granule: 0, byteOffset: 100 }, { granule: OPUS_SAMPLE_RATE, byteOffset: 9000 }], + })); + const d = new OpusFormatDecoder(); + assertEqual(d.calculateByteOffset(info, 0.5), 100, 'midpoint snaps to lower page start'); +}); + +test('calculateByteOffset degrades to audioDataOffset with an empty index', () => { + const info = formatInfoFor(makeSidecar({ + setupHeader: [1, 2, 3, 4, 5], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [], + })); + const d = new OpusFormatDecoder(); + assertEqual(info.audioDataOffset, 5, 'audioDataOffset == setup header length'); + assertEqual(d.calculateByteOffset(info, 10), info.audioDataOffset, 'empty index degrades'); +}); + +// --- getAlignedSegmentSize: Ogg "OggS" page-boundary alignment -------------------------------- + +function withOggS(len: number, ...pageStarts: number[]): Uint8Array { + const out = new Uint8Array(len).fill(0xaa); + for (const s of pageStarts) { out[s] = 0x4f; out[s + 1] = 0x67; out[s + 2] = 0x67; out[s + 3] = 0x53; } + return out; +} +const stubInfo = { audioDataOffset: 0 } as FormatInfo; + +test('getAlignedSegmentSize cuts at the last OggS page start within the window', () => { + const raw = withOggS(64, 4, 40); + assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 40, 'last page start'); +}); + +test('getAlignedSegmentSize waits (returns 0) when no page boundary is found mid-stream', () => { + const raw = withOggS(64); + assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 0, 'no boundary'); +}); + +test('getAlignedSegmentSize flushes the whole candidate on stream completion without a boundary', () => { + const raw = withOggS(64); + assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, true, raw), 64, 'flush on complete'); +}); + +test('getAlignedSegmentSize ignores a page start at offset 0 (needs a real cut point)', () => { + const raw = withOggS(64, 0); + assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 0, 'offset 0 skipped'); +}); + +// --- wrapSegment: OpusHead/OpusTags setup-header carry ---------------------------------------- + +test('wrapSegment prepends the cached setup bytes to a page run', () => { + const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; // "OpusHead" + const info = formatInfoFor(makeSidecar({ + setupHeader: setup, totalByteLength: 100, totalDuration: 1, preSkip: 0, + points: [{ granule: 0, byteOffset: setup.length }], + })); + const pageRun = new Uint8Array([0x4f, 0x67, 0x67, 0x53, 0x11, 0x22]); // "OggS" + payload + const wrapped = new OpusFormatDecoder().wrapSegment(info, pageRun); + assertArray(wrapped.subarray(0, setup.length), setup, 'setup header first'); + assertArray(wrapped.subarray(setup.length), [0x4f, 0x67, 0x67, 0x53, 0x11, 0x22], 'page run follows'); +}); + +// --- report ---------------------------------------------------------------------------------- +if (failures.length > 0) { + console.error(failures.join('\n')); + throw new Error(`${failures.length} test(s) failed, ${passed} passed`); +} +console.log(`ALL ${passed} TESTS PASSED`); diff --git a/DeepDrftPublic/Interop/audio/OpusFormatDecoder.ts b/DeepDrftPublic/Interop/audio/OpusFormatDecoder.ts new file mode 100644 index 0000000..f89709a --- /dev/null +++ b/DeepDrftPublic/Interop/audio/OpusFormatDecoder.ts @@ -0,0 +1,169 @@ +/** + * OpusFormatDecoder - Ogg-Opus implementation of IFormatDecoder. + * + * Ogg Opus is a containerized, paged format — NOT raw-frame-sliceable the way WAV PCM is. Two + * things make a mid-stream byte slice decodable: (1) it must begin on an Ogg page boundary, and + * (2) the OpusHead/OpusTags setup pages must be prepended (analogous to FLAC's STREAMINFO carry). + * This decoder owns both, plus VBR-safe accurate seeking. + * + * Where the metadata comes from is the genuinely new part. WAV/MP3/FLAC parse everything out of + * the byte stream. Opus is VBR and container-paged, so a byteRate seek would be inaccurate; instead + * the seek transfer function (granule->byte) and the setup bytes are precomputed at transcode time + * (wave 18.1) and delivered as a one-time sidecar fetch (wave 18.5). The injection seam is + * `setSidecar(OpusSeekData)` — call it with the parsed sidecar BEFORE the stream is initialized so + * `tryParseHeader` can build FormatInfo from it. Without a sidecar the decoder cannot stream Opus + * (returns null from tryParseHeader); 18.5 guarantees the fetch precedes stream init. + * + * - getAlignedSegmentSize aligns to Ogg page boundaries by scanning for the "OggS" capture + * pattern (the Ogg analogue of FLAC's frame-sync scan; the interface passes rawData for this). + * - wrapSegment prepends the cached OpusHead/OpusTags setup bytes so any mid-stream page run is + * independently decodable. + * - calculateByteOffset binary-searches the precomputed index for the largest entry with + * presentation-time <= t and returns its exact page-start byte offset — NOT interpolation, + * NOT byteRate math (§3.4a A/C; C5 accurate seek). + */ + +import { FormatInfo, IFormatDecoder } from './IFormatDecoder.js'; +import { OpusSeekData, OPUS_SAMPLE_RATE, presentationTimeSeconds } from './OpusSidecar.js'; + +// "OggS" — every Ogg page begins with this 4-byte capture pattern. +const OGG_CAPTURE = [0x4f, 0x67, 0x67, 0x53]; // 'O' 'g' 'g' 'S' + +export class OpusFormatDecoder implements IFormatDecoder { + // The parsed sidecar: setup bytes + seek index + preSkip + totals. Injected by wave 18.5 via + // setSidecar before stream init. Held for the stream's lifetime (the format does not change + // across a seek/continuation), mirroring how FlacFormatDecoder retains streamInfoBytes. + private sidecar: OpusSeekData | null = null; + + /** + * Inject the parsed sidecar (setup header + seek index) for this stream. Wave 18.5 calls this + * after its one-time sidecar fetch + parseSidecar, before initializeStreaming. This is the seam + * that keeps the HTTP fetch out of the decoder: the decoder is pure and unit-testable against + * synthetic bytes, and 18.5 wires the real transport. + */ + setSidecar(sidecar: OpusSeekData): void { + this.sidecar = sidecar; + } + + tryParseHeader(_chunks: Uint8Array[], _totalSize: number): FormatInfo | null { + // Opus metadata is NOT parsed from the stream — it comes from the injected sidecar. Without + // it we cannot stream Opus; return null so StreamDecoder waits, and 18.5's contract (fetch + + // setSidecar before stream init) prevents that null from persisting. + const sidecar = this.sidecar; + if (!sidecar) return null; + + // For the initial full-file stream the server emits [setup pages][audio pages], and the + // sidecar's setup bytes are exactly those leading pages — so audio data begins right after + // them. This is the file-absolute offset of the first audio page (== the first index point's + // byteOffset by construction). + const audioDataOffset = sidecar.setupHeaderBytes.length; + + return { + // Opus always decodes at 48 kHz regardless of the source rate (RFC 7845). + sampleRate: OPUS_SAMPLE_RATE, + // Channel count is encoded in OpusHead; the decoder reads it from the prepended setup + // bytes at decode time. FormatInfo.channels is display-only here — 2 is the safe nominal. + channels: 2, + bitsPerSample: 16, + byteRate: 0, // VBR + paged; seeking uses the index, never byteRate. + blockAlign: 0, // No fixed alignment; segments align to Ogg page starts via OggS scan. + totalDuration: sidecar.totalDurationSeconds > 0 ? sidecar.totalDurationSeconds : null, + audioDataOffset, + seekData: sidecar + }; + } + + getAlignedSegmentSize( + info: FormatInfo, + availableBytes: number, + requestedSize: number, + streamComplete: boolean, + rawData?: Uint8Array + ): number { + if (availableBytes === 0) return 0; + const candidate = Math.min(requestedSize, availableBytes); + + if (!rawData || rawData.length === 0) { + // No scan data — conservative threshold to avoid tiny unusable segments (mirrors FLAC). + if (!streamComplete && availableBytes < 16384) return 0; + return candidate; + } + + // Scan backward from the candidate boundary for the start of the last Ogg page. Cutting on a + // page start keeps the next segment Ogg-sync-aligned and the current one a whole page run. + const boundary = OpusFormatDecoder.findLastOggPage(rawData, candidate); + if (boundary <= 0) { + if (streamComplete) return candidate; // flush remaining bytes (stream done) + return 0; // wait for more data — no full page boundary yet + } + return boundary; + } + + /** + * Scan backward from `maxBytes` in `rawData` for the start of the last "OggS" capture pattern. + * Returns that byte offset (the page start), or 0 if none is found (caller waits for more data). + * Skips offset 0 itself: a segment that is only "everything up to the very first page" carries + * no page and should wait, matching the FLAC frame-scan's `> 0` discipline. + */ + private static findLastOggPage(rawData: Uint8Array, maxBytes: number): number { + const limit = Math.min(maxBytes, rawData.length); + for (let i = limit - 4; i > 0; i--) { + if (rawData[i] === OGG_CAPTURE[0] && + rawData[i + 1] === OGG_CAPTURE[1] && + rawData[i + 2] === OGG_CAPTURE[2] && + rawData[i + 3] === OGG_CAPTURE[3]) { + return i; + } + } + return 0; + } + + wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array { + const sidecar = OpusFormatDecoder.opusSeekData(info); + const setupBytes = sidecar?.setupHeaderBytes; + if (!setupBytes || setupBytes.length === 0) { + // Defensive: without setup bytes a mid-stream page run is undecodable. tryParseHeader + // always populates the sidecar on success, so this path should not occur in practice. + return rawBytes; + } + + // Prepend OpusHead/OpusTags so the page run is self-contained for decodeAudioData. + const result = new Uint8Array(setupBytes.length + rawBytes.length); + result.set(setupBytes, 0); + result.set(rawBytes, setupBytes.length); + return result; + } + + calculateByteOffset(info: FormatInfo, positionSeconds: number): number { + const sidecar = OpusFormatDecoder.opusSeekData(info); + if (!sidecar || sidecar.points.length === 0) { + // No index: degrade to start of audio (seek restarts) — same graceful fallback as FLAC. + return info.audioDataOffset; + } + + const points = sidecar.points; + const preSkip = sidecar.preSkip; + + // Binary search for the largest entry whose presentation time is <= target. Presentation + // time = max(0, (granule - preSkip) / 48000), matching 18.1's RFC 7845 math exactly. + let lo = 0, hi = points.length - 1, best = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const t = presentationTimeSeconds(points[mid].granulePosition, preSkip); + if (t <= positionSeconds) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + // byteOffset is already a file-absolute page-start offset in the Opus file — no header math + // to add (unlike FLAC's audio-relative stream_offset). Return it directly. + return points[best].byteOffset; + } + + private static opusSeekData(info: FormatInfo): OpusSeekData | null { + return info.seekData?.kind === 'opus-sidecar' ? info.seekData : null; + } +} diff --git a/DeepDrftPublic/Interop/audio/OpusSidecar.ts b/DeepDrftPublic/Interop/audio/OpusSidecar.ts new file mode 100644 index 0000000..b952b98 --- /dev/null +++ b/DeepDrftPublic/Interop/audio/OpusSidecar.ts @@ -0,0 +1,141 @@ +/** + * OpusSidecar - parser for the per-track Opus seek/setup sidecar artifact. + * + * The sidecar is built once at transcode time (wave 18.1, C# `OpusSidecar` / + * `OggOpusSeekIndex`) and fetched once on track load (wired by wave 18.5). It carries + * everything the client needs to seek a VBR Opus stream accurately and to decode any + * mid-stream slice: + * - the verbatim OpusHead + OpusTags setup pages (prepended to every post-seek slice), + * - the precomputed granule->byte seek index (the exact time->byte transfer function), + * - the pre_skip and totals needed for presentation-time math and seek clamping. + * + * This module is the byte-for-byte counterpart to the C# serializer. It is pure: it parses + * a blob into an `OpusSeekData` accelerator with no I/O. Wave 18.5 owns the HTTP fetch and + * injects the parsed result into `OpusFormatDecoder.setSidecar`. + * + * Binary layout (all little-endian), matching DeepDrftContent.Processors.Opus: + * [uint32 setupHeaderLength] + * [setupHeaderLength bytes -> OpusHead + OpusTags pages] + * [seek-index blob]: + * header (24 bytes): + * uint64 totalByteLength + * double totalDurationSeconds (pre-skip-corrected) + * uint32 pointCount + * uint16 preSkip + * uint16 reserved + * pointCount x 16-byte points: + * uint64 granulePosition (48 kHz sample count) + * uint64 byteOffset (page-start offset in the Opus file) + */ + +/** Opus granule positions are always 48 kHz sample counts, regardless of input rate. */ +export const OPUS_SAMPLE_RATE = 48000; + +/** Size of the seek-index blob header: totalBytes(8) + duration(8) + count(4) + preSkip(2) + reserved(2). */ +const SEEK_INDEX_HEADER_SIZE = 24; +/** Size of one serialized seek point: granulepos(8) + byteOffset(8). */ +const SEEK_POINT_SIZE = 16; + +/** One (granule, byteOffset) seek-index entry. Both are page-start-accurate. */ +export interface OpusSeekPoint { + /** Page end granule position — a 48 kHz sample count. */ + granulePosition: number; + /** Byte offset of the page start in the Opus file. */ + byteOffset: number; +} + +/** + * Parsed sidecar: the `seekData` accelerator the `OpusFormatDecoder` holds for the stream's + * lifetime. Holds the setup bytes (for `wrapSegment` carry) and the index (for `calculateByteOffset`). + */ +export interface OpusSeekData { + kind: 'opus-sidecar'; + /** Verbatim OpusHead + OpusTags pages, prepended to every decodable segment. */ + setupHeaderBytes: Uint8Array; + /** Ordered (granule, byteOffset) entries, ascending by granule. */ + points: OpusSeekPoint[]; + /** Pre-skip-corrected total stream duration in seconds. */ + totalDurationSeconds: number; + /** Total Opus file byte length, for clamping a seek past the end. */ + totalByteLength: number; + /** pre_skip from OpusHead (RFC 7845 §5.1); samples to discard before presentation. */ + preSkip: number; +} + +/** + * Parse a sidecar blob produced by the C# `OpusSidecar.ToBytes`. Returns null on any structural + * inconsistency (short blob, length prefix overrun, declared point count that does not fit) — + * the format is exact, so a malformed blob is corruption, not a recoverable shape. + * + * Accepts a `Uint8Array`, an `ArrayBuffer`, or a typed-array view; copies nothing it can borrow. + */ +export function parseSidecar(input: Uint8Array | ArrayBuffer | ArrayBufferView): OpusSeekData | null { + const bytes = toUint8Array(input); + // DataView over the same backing buffer; honour the view's byteOffset so a borrowed view parses. + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + if (bytes.byteLength < 4) return null; + + const setupLength = view.getUint32(0, true); + const indexStart = 4 + setupLength; + // Need the setup region plus at least the index header. + if (bytes.byteLength < indexStart + SEEK_INDEX_HEADER_SIZE) return null; + + // subarray is zero-copy; setup bytes are retained for wrapSegment for the stream's lifetime. + const setupHeaderBytes = bytes.subarray(4, indexStart); + + // Seek-index blob header (relative to the DataView, which is bytes-relative). + const totalByteLength = readUint64(view, indexStart); + const totalDurationSeconds = view.getFloat64(indexStart + 8, true); + const pointCount = view.getUint32(indexStart + 16, true); + const preSkip = view.getUint16(indexStart + 20, true); + // bytes 22-23: reserved — ignored on read, for forward-compatibility (matches C#). + + const pointsStart = indexStart + SEEK_INDEX_HEADER_SIZE; + const expectedEnd = pointsStart + pointCount * SEEK_POINT_SIZE; + if (bytes.byteLength < expectedEnd) return null; + + const points: OpusSeekPoint[] = new Array(pointCount); + let cursor = pointsStart; + for (let i = 0; i < pointCount; i++) { + const granulePosition = readUint64(view, cursor); + const byteOffset = readUint64(view, cursor + 8); + points[i] = { granulePosition, byteOffset }; + cursor += SEEK_POINT_SIZE; + } + + return { + kind: 'opus-sidecar', + setupHeaderBytes, + points, + totalDurationSeconds, + totalByteLength, + preSkip + }; +} + +/** + * Pre-skip-corrected presentation time for a granule position: max(0, (granule - preSkip) / 48000). + * Matches the C# `OggOpusSeekIndex.PresentationTimeSeconds` so client and server agree on the + * seek transfer function. + */ +export function presentationTimeSeconds(granulePosition: number, preSkip: number): number { + return Math.max(0, (granulePosition - preSkip) / OPUS_SAMPLE_RATE); +} + +function toUint8Array(input: Uint8Array | ArrayBuffer | ArrayBufferView): Uint8Array { + if (input instanceof Uint8Array) return input; + if (input instanceof ArrayBuffer) return new Uint8Array(input); + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); +} + +/** + * Read a little-endian uint64 as a JS number. Opus byte offsets and granule positions are exact + * to 2^53 (~8 PB / ~5,700 years of audio at 48 kHz), far beyond any real file — no BigInt needed, + * matching the FLAC seektable's same 2^53 assumption. + */ +function readUint64(view: DataView, offset: number): number { + const lo = view.getUint32(offset, true); + const hi = view.getUint32(offset + 4, true); + return hi * 0x100000000 + lo; +} diff --git a/DeepDrftPublic/Interop/audio/index.ts b/DeepDrftPublic/Interop/audio/index.ts index 3239f24..c56a134 100644 --- a/DeepDrftPublic/Interop/audio/index.ts +++ b/DeepDrftPublic/Interop/audio/index.ts @@ -3,6 +3,7 @@ */ import { AudioPlayer, AudioResult, StreamingResult, AudioState } from './AudioPlayer.js'; +import { canDecodeOggOpus } from './OpusCapability.js'; // Player instances by ID const audioPlayers = new Map(); @@ -37,6 +38,20 @@ const DeepDrftAudio = { return player.initializeStreaming(totalStreamLength, contentType); }, + // Opus injection seam (wave 18.4). Wave 18.5 fetches the per-track sidecar (setup header + + // seek index) over HTTP and hands the raw bytes here BEFORE initializeStreaming on an Opus + // stream. This module never fetches the sidecar — it only parses + stores it on the player. + setOpusSidecar: (playerId: string, sidecarBytes: Uint8Array): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) return { success: false, error: 'Player not found' }; + return player.setOpusSidecar(sidecarBytes); + }, + + // Capability seam (wave 18.4). Resolves whether this browser can decode Ogg Opus via + // decodeAudioData (Safari < 18.4 cannot). Wave 18.5 / 18.6 consume this to choose lossless + // when unsupported; this module only reports the capability. + canDecodeOggOpus: (): Promise => canDecodeOggOpus(), + processStreamingChunk: async (playerId: string, chunk: Uint8Array): Promise => { const player = audioPlayers.get(playerId); if (!player) return { success: false, error: 'Player not found' }; diff --git a/DeepDrftPublic/tsconfig.json b/DeepDrftPublic/tsconfig.json index 4d11659..4bfd47c 100644 --- a/DeepDrftPublic/tsconfig.json +++ b/DeepDrftPublic/tsconfig.json @@ -21,6 +21,7 @@ "node_modules", "bin/**/*", "obj/**/*", - "publish/**/*" + "publish/**/*", + "Interop/**/*.test.ts" ] } \ No newline at end of file From 2bde4908d7183d6511b12ea16b6ad2855ca33a8b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 12:39:13 -0400 Subject: [PATCH 07/54] Wire Opus end-to-end playback + Backfill-Opus action (Phase 18.5) Player picks Opus when the browser can decode it and a sidecar exists (else lossless), injecting the sidecar before stream init; seek reuses the same format. Adds the Backfill-Opus bulk API endpoint + CMS action. --- DeepDrftAPI/Controllers/TrackController.cs | 47 ++++ DeepDrftAPI/Services/UnifiedTrackService.cs | 67 +++++ .../Processors/Opus/TrackFormatResolver.cs | 19 ++ .../Components/Pages/Tracks/Releases.razor | 71 +++++ DeepDrftManager/Services/CmsTrackService.cs | 39 +++ DeepDrftManager/Services/ICmsTrackService.cs | 17 ++ .../Services/AudioInteropService.cs | 30 +++ .../Services/StreamingAudioPlayerService.cs | 66 ++++- DeepDrftTests/OpusBackfillTests.cs | 251 ++++++++++++++++++ DeepDrftTests/OpusFormatSelectionTests.cs | 180 +++++++++++++ DeepDrftTests/ReplaceAudioOpusRegenTests.cs | 171 ++++++++++++ .../UploadDuplicateDetectionTests.cs | 4 + 12 files changed, 961 insertions(+), 1 deletion(-) create mode 100644 DeepDrftTests/OpusBackfillTests.cs create mode 100644 DeepDrftTests/OpusFormatSelectionTests.cs create mode 100644 DeepDrftTests/ReplaceAudioOpusRegenTests.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 9878ca4..d8dfe0f 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -269,6 +269,27 @@ public class TrackController : ControllerBase return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped }); } + // POST api/track/opus/backfill ([ApiKeyAuthorize], no body) + // Backfill-Opus (18.5, OQ4): enqueue a background Opus derive for every track lacking a complete Opus + // artifact (audio + sidecar). Mirrors the duration-backfill posture — enqueue-only and non-blocking, the + // transcodes run on the shared serial worker. Idempotent: a re-run only schedules tracks still missing + // Opus. Returns { enqueued, skipped }. Declared in the literal-route block (before "{trackId}") so the + // "opus/backfill" segment is never treated as a trackId; distinct shape from "{trackId}/opus" (per-track). + [ApiKeyAuthorize] + [HttpPost("opus/backfill")] + public async Task BackfillOpus(CancellationToken cancellationToken) + { + var result = await _unifiedService.BackfillOpusAsync(cancellationToken); + if (!result.Success) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("BackfillOpus failed: {Error}", error); + return StatusCode(500, error); + } + + return Ok(new { enqueued = result.Value.Enqueued, skipped = result.Value.Skipped }); + } + // POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out. // Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host // proxies the upload here so it never touches the vault disk path or SQL directly. @@ -875,6 +896,32 @@ public class TrackController : ControllerBase return Ok(); } + // POST api/track/{trackId}/opus ([ApiKeyAuthorize]) + // Per-track Opus (re)derive trigger (18.5): schedule a single track's background transcode. Enqueue-only + // and non-blocking — the transcode runs on the shared serial worker; this returns as soon as it is + // scheduled. Re-runnable: overwrites any prior artifact in place. trackId is the EntryKey. 404 when the + // track id is unknown. The "opus" literal suffix keeps this distinct from the audio/waveform routes and + // from the parameterized PUT "{trackId}". Returns 202 Accepted — the work is queued, not done inline. + [ApiKeyAuthorize] + [HttpPost("{trackId}/opus")] + public async Task GenerateOpus(string trackId, CancellationToken cancellationToken) + { + var result = await _unifiedService.EnqueueOpusAsync(trackId, cancellationToken); + if (result.Success) + { + return Accepted(); + } + + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal)) + { + return NotFound(); + } + + _logger.LogError("GenerateOpus failed for {TrackId}: {Error}", trackId, error); + return StatusCode(500, error); + } + [ApiKeyAuthorize] [HttpPut("{trackId}")] public async Task PutTrack(string trackId, [FromBody] AudioBinaryDto track) diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index f1ef417..75f5e00 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -2,6 +2,7 @@ using DeepDrftAPI.Services.Opus; using DeepDrftContent; using DeepDrftContent.Constants; using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; using DeepDrftData; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; @@ -41,6 +42,7 @@ public class UnifiedTrackService private readonly FileDb _fileDatabase; private readonly WaveformProfileService _waveformProfileService; private readonly IOpusTranscodeQueue _opusTranscodeQueue; + private readonly TrackFormatResolver _formatResolver; private readonly ILogger _logger; public UnifiedTrackService( @@ -49,6 +51,7 @@ public class UnifiedTrackService FileDb fileDatabase, WaveformProfileService waveformProfileService, IOpusTranscodeQueue opusTranscodeQueue, + TrackFormatResolver formatResolver, ILogger logger) { _contentTrackContentService = contentTrackContentService; @@ -56,6 +59,7 @@ public class UnifiedTrackService _fileDatabase = fileDatabase; _waveformProfileService = waveformProfileService; _opusTranscodeQueue = opusTranscodeQueue; + _formatResolver = formatResolver; _logger = logger; } @@ -395,6 +399,69 @@ public class UnifiedTrackService return ResultContainer<(int, int)>.CreatePassResult((updated, skipped)); } + /// + /// Backfill-Opus (18.5, OQ4): enqueue a background Opus derive for every non-deleted track that lacks a + /// complete Opus artifact (missing audio OR missing sidecar — a half-derived track is treated as missing + /// and re-derived). Mirrors the duration-backfill posture: enumerate SQL rows, check each against the + /// track-opus vault, schedule the misses. Enqueue-only and non-blocking — the actual transcodes run + /// on the shared background worker, serially (the same queue the upload/replace paths feed), so this + /// returns as soon as the misses are scheduled rather than waiting on CPU-heavy transcodes. Idempotent: + /// a re-run only enqueues tracks still missing Opus, and already-queued/in-flight derives simply overwrite + /// in place. Returns (enqueued, skipped) — skipped = tracks that already have a complete Opus artifact. + /// + public async Task> BackfillOpusAsync(CancellationToken ct) + { + var all = await _sqlTrackService.GetAll(); + if (!all.Success || all.Value is null) + { + var error = all.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("BackfillOpusAsync: failed to load tracks: {Error}", error); + return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}"); + } + + var enqueued = 0; + var skipped = 0; + foreach (var track in all.Value) + { + ct.ThrowIfCancellationRequested(); + + if (await _formatResolver.HasOpusAsync(track.EntryKey)) + { + skipped++; + continue; + } + + _opusTranscodeQueue.Enqueue(track.EntryKey); + enqueued++; + } + + _logger.LogInformation("BackfillOpusAsync complete: {Enqueued} enqueued, {Skipped} already had Opus.", + enqueued, skipped); + return ResultContainer<(int, int)>.CreatePassResult((enqueued, skipped)); + } + + /// + /// Per-track Opus (re)derive trigger (18.5): schedule a background transcode for one track. Returns false + /// only when the track id is unknown; the enqueue itself is non-blocking and best-effort, like the bulk + /// backfill. Re-runnable — overwrites any prior artifact in place. + /// + public async Task EnqueueOpusAsync(string entryKey, CancellationToken ct) + { + var lookup = await _sqlTrackService.GetByEntryKey(entryKey); + if (!lookup.Success) + { + var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("EnqueueOpusAsync: lookup failed for {EntryKey}: {Error}", entryKey, error); + return Result.CreateFailResult("Failed to load track."); + } + + if (lookup.Value is null) + return Result.CreateFailResult(TrackNotFoundMessage); + + _opusTranscodeQueue.Enqueue(entryKey); + return Result.CreatePassResult(); + } + /// /// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete /// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete diff --git a/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs b/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs index 070e820..bf75d70 100644 --- a/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs +++ b/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs @@ -88,4 +88,23 @@ public sealed class TrackFormatResolver VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey)); return sidecar?.Buffer; } + + /// + /// Reports whether already has a complete Opus derive — both the audio bytes + /// AND the seek/setup sidecar present in the track-opus vault. The Backfill-Opus pass (18.5) uses + /// this to enqueue only tracks that are missing or half-derived (audio without sidecar = unseekable, so + /// treated as incomplete and re-derived). Both halves are required because the transcode stores them in + /// sequence and a sidecar-write failure leaves a track the delivery layer must not treat as Opus-ready. + /// + public async Task HasOpusAsync(string entryKey) + { + var audio = await _fileDatabase.LoadResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey)); + if (audio is null) + return false; + + var sidecar = await _fileDatabase.LoadResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey)); + return sidecar is not null; + } } diff --git a/DeepDrftManager/Components/Pages/Tracks/Releases.razor b/DeepDrftManager/Components/Pages/Tracks/Releases.razor index 1aaf845..6c9580e 100644 --- a/DeepDrftManager/Components/Pages/Tracks/Releases.razor +++ b/DeepDrftManager/Components/Pages/Tracks/Releases.razor @@ -51,6 +51,26 @@ Backfill High-res (@MissingHighResCount) } + @* Backfill-Opus (Phase 18.5). Unlike the two waveform buttons, the Opus derive runs on a + server-side background worker: the API decides which tracks lack Opus and enqueues them, so + there is no client-side "missing N" count to gate on and no per-track progress to render — the + action schedules the work and reports the (enqueued / skipped) outcome. Re-runnable: a second + press only enqueues tracks still missing Opus. Disabled while a press is in flight. *@ + + @if (_opusBackfillRunning) + { + + Scheduling… + } + else + { + Backfill Opus + } + @@ -150,6 +170,11 @@ private int _highResBulkTotal; private int _highResBulkDone; + // Local state for the "Backfill Opus" action. The Opus derive is server-side and background-queued, so + // there is no client-side per-track loop or progress total — this flag only guards the button while the + // single scheduling call is in flight. + private bool _opusBackfillRunning; + protected override async Task OnInitializedAsync() { // Seed the active tab from ?medium= so a catalogue card deep-links straight to its medium. Panel 0 @@ -291,4 +316,50 @@ Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning); } } + + /// + /// Kick off the catalogue-wide Backfill-Opus pass. The API enumerates the tracks lacking a complete Opus + /// artifact, enqueues a background derive for each, and returns the (enqueued, skipped) counts. This is a + /// single scheduling call — the transcodes run server-side afterward — so there is no per-track progress + /// to render here, just a busy flag and a result snackbar. Re-runnable: a second press only schedules + /// tracks still missing Opus. + /// + private async Task BackfillOpusAsync() + { + _opusBackfillRunning = true; + StateHasChanged(); + try + { + var result = await CmsTrackService.BackfillOpusAsync(); + if (!result.Success) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to start the Opus backfill."; + Snackbar.Add(error, Severity.Error); + return; + } + + var (enqueued, skipped) = (result.Value.Enqueued, result.Value.Skipped); + if (enqueued == 0) + { + Snackbar.Add($"All {skipped} track(s) already have Opus — nothing to backfill.", Severity.Info); + } + else + { + Snackbar.Add( + $"Scheduled {enqueued} Opus transcode(s) in the background ({skipped} already had Opus). " + + "They will appear as each finishes.", + Severity.Success); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Opus backfill failed to start"); + Snackbar.Add("Failed to start the Opus backfill.", Severity.Error); + } + finally + { + _opusBackfillRunning = false; + StateHasChanged(); + } + } } diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index 663312e..d3c40e3 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -765,6 +765,45 @@ public class CmsTrackService : ICmsTrackService } } + public async Task> BackfillOpusAsync(CancellationToken ct = default) + { + var client = _httpClientFactory.CreateClient(ContentCmsClientName); + + HttpResponseMessage response; + try + { + response = await client.PostAsync("api/track/opus/backfill", null, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Content API call failed for Opus backfill"); + return ResultContainer.CreateFailResult("Content API is unreachable."); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("Content API Opus backfill failed: {Status} {Body}", (int)response.StatusCode, body); + return ResultContainer.CreateFailResult("Failed to start the Opus backfill."); + } + + OpusBackfillResult payload; + try + { + payload = await response.Content.ReadFromJsonAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize Opus backfill response from Content API"); + return ResultContainer.CreateFailResult("Content API returned an unexpected response."); + } + + return ResultContainer.CreatePassResult(payload); + } + } + public async Task>> GetReleasesAsync(CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index 3796c51..f1452eb 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -152,6 +152,15 @@ public interface ICmsTrackService /// Task GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default); + /// + /// Trigger the catalogue-wide Backfill-Opus pass via POST api/track/opus/backfill (Phase 18.5). + /// The API enqueues a background Opus derive for every track lacking a complete Opus artifact and returns + /// the (enqueued, skipped) counts. Enqueue-only — the transcodes run server-side on a serial background + /// worker, so this call returns as soon as the work is scheduled, not when transcoding finishes. The + /// Enqueued count is how many derives were scheduled; Skipped is how many already had Opus. + /// + Task> BackfillOpusAsync(CancellationToken ct = default); + /// Returns all releases with track counts from GET api/track/albums. Task>> GetReleasesAsync(CancellationToken ct = default); @@ -160,3 +169,11 @@ public interface ICmsTrackService /// Task> GetTrackCountAsync(CancellationToken ct = default); } + +/// +/// Outcome of a Backfill-Opus pass (Phase 18.5): how many tracks had a background derive scheduled +/// () and how many were skipped because they already carry a complete Opus +/// artifact (). Both are counts of tracks, not finished transcodes — the work +/// runs asynchronously on the API's background worker after this returns. +/// +public readonly record struct OpusBackfillResult(int Enqueued, int Skipped); diff --git a/DeepDrftPublic.Client/Services/AudioInteropService.cs b/DeepDrftPublic.Client/Services/AudioInteropService.cs index 48ea050..4c109ee 100644 --- a/DeepDrftPublic.Client/Services/AudioInteropService.cs +++ b/DeepDrftPublic.Client/Services/AudioInteropService.cs @@ -70,6 +70,36 @@ public class AudioInteropService : IAsyncDisposable return await InvokeJsAsync("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType); } + /// + /// Probes whether this browser can decode Ogg Opus via decodeAudioData (Safari < 18.4 cannot). + /// Phase 18 capability gate (OQ2): the player only requests Opus when this returns true, otherwise it + /// stays on the universal lossless path (AC7 — no listener ever gets silence over a codec gap). Probe + /// failures degrade to false (assume incapable) so an interop error can never silence playback. + /// + public async Task CanDecodeOggOpus(string playerId) + { + try + { + return await _jsRuntime.InvokeAsync("DeepDrftAudio.canDecodeOggOpus"); + } + catch + { + return false; + } + } + + /// + /// Hands the raw Opus seek/setup sidecar bytes (setup header + granule→byte seek index) to the JS player + /// so the next Opus stream's decoder has them BEFORE init (the 18.4 set-before-init contract). The player + /// parses and stashes them; applies them when it builds the Opus decoder. + /// Must be called before on an Opus stream. Returns the parse result — + /// a failure means the bytes were not a valid sidecar, and the caller falls back to lossless. + /// + public async Task SetOpusSidecar(string playerId, byte[] sidecarBytes) + { + return await InvokeJsAsync("DeepDrftAudio.setOpusSidecar", playerId, sidecarBytes); + } + public async Task ProcessStreamingChunk(string playerId, byte[] audioChunk) { return await InvokeJsAsync("DeepDrftAudio.processStreamingChunk", playerId, audioChunk); diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index f795535..bf49532 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -1,4 +1,5 @@ using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; using DeepDrftPublic.Client.Clients; using System.Buffers; using Microsoft.Extensions.Logging; @@ -33,6 +34,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS private readonly ILogger _logger; private string? _currentTrackId; + // The delivery format the active load resolved to (Phase 18). Captured once per LoadTrackStreaming and + // reused by the seek-beyond-buffer re-fetch so the Range continuation requests the SAME artifact the + // initial stream did — a seek must never switch formats mid-track (the JS decoder, the cached setup + // header, and the byte offsets all belong to one artifact). Defaults to Lossless until a load resolves. + private AudioFormat _currentFormat = AudioFormat.Lossless; + // Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at // most one bucketed play event per session, behind the engagement floor. Attached after construction // by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no @@ -174,11 +181,18 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS await NotifyStateChanged(); + // Resolve the delivery format for this load BEFORE requesting bytes (Phase 18, default policy + // OQ2). When Opus is chosen the sidecar is fetched and injected into the JS player here, ahead of + // InitializeStreaming, honouring the 18.4 set-before-init contract. The result is captured so the + // seek-beyond-buffer re-fetch reuses the same artifact. + _currentFormat = await ResolveStreamFormatAsync(track.EntryKey, loadCts.Token); + // Pass the streaming token to the HTTP layer so a navigation/track switch // aborts the server connection instead of leaving it draining bytes. var mediaResult = await _trackMediaClient.GetTrackMedia( track.EntryKey, byteOffset: 0, + format: _currentFormat, cancellationToken: loadCts.Token); if (!mediaResult.Success) { @@ -250,6 +264,50 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS } } + /// + /// Resolves which delivery format this load should request (Phase 18 default policy, OQ2): Opus when the + /// browser can decode Ogg Opus AND a sidecar exists for the track, otherwise lossless. When Opus is + /// chosen the sidecar is injected into the JS player here (set-before-init, the 18.4 contract) so the + /// decoder has its setup header + seek index before InitializeStreaming builds it. + /// + /// This is the single, deliberately-overridable seam for the listener quality preference (wave 18.6). + /// 18.6 overrides this to honour the user's "streaming quality" toggle — returning lossless when the + /// listener picked it, and otherwise falling through to this capability-gated default. The capability + /// gate (AC7) and the sidecar-absent → lossless fallback (C2) stay here so any override inherits both: + /// a browser that cannot decode Opus, or a track with no sidecar, always lands on lossless and plays. + /// + /// + protected virtual async Task ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken) + { + // Capability gate first (AC7): never hand Ogg Opus to a browser that cannot decode it. + if (!await _audioInterop.CanDecodeOggOpus(PlayerId)) + { + return AudioFormat.Lossless; + } + + // The sidecar must be present (and parseable by the JS decoder) to seek an Opus stream. Its absence + // means the track has no Opus artifact yet (legacy / not backfilled / transcode failed) — request + // lossless rather than Opus-without-a-sidecar (the server would C2-fall-back anyway, but asking for + // lossless keeps the request honest and avoids a wasted Opus-then-fallback round-trip). + var sidecar = await _trackMediaClient.GetOpusSidecarAsync(entryKey, cancellationToken); + if (!sidecar.Success || sidecar.Value is not { Length: > 0 } sidecarBytes) + { + return AudioFormat.Lossless; + } + + // Inject BEFORE InitializeStreaming (the set-before-init contract). A parse failure here means the + // bytes are not a usable sidecar — fall back to lossless so a malformed sidecar never breaks playback. + var injected = await _audioInterop.SetOpusSidecar(PlayerId, sidecarBytes); + if (!injected.Success) + { + _logger.LogWarning("Opus sidecar for {EntryKey} failed to parse ({Error}); falling back to lossless.", + entryKey, injected.Error); + return AudioFormat.Lossless; + } + + return AudioFormat.Opus; + } + /// /// Fetches and decodes the track's waveform loudness profile, then notifies state so the /// seek zone re-renders with real bars. Best-effort: a 404 (no stored profile) or any other @@ -544,10 +602,15 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS CurrentTime = seekPosition; await NotifyStateChanged(); - // Request new stream from offset + // Request new stream from offset. Reuse the format the initial load resolved to (_currentFormat): + // an Opus seek must come back as Opus bytes so the cached setup header + page-aligned byteOffset + // (resolved by the JS decoder's index-based calculateByteOffset) match the continuation. The + // offset itself is computed JS-side from the Opus seek index for Opus, exactly as it is from the + // WAV header for lossless — one seam, format-appropriate math (AC9 / §3.4a C). var mediaResult = await _trackMediaClient.GetTrackMedia( _currentTrackId, byteOffset, + format: _currentFormat, cancellationToken: seekCts.Token); if (!mediaResult.Success || mediaResult.Value == null) { @@ -653,6 +716,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS _streamingPlaybackStarted = false; IsSeekingBeyondBuffer = false; _currentTrackId = null; + _currentFormat = AudioFormat.Lossless; await NotifyStateChanged(); } diff --git a/DeepDrftTests/OpusBackfillTests.cs b/DeepDrftTests/OpusBackfillTests.cs new file mode 100644 index 0000000..248fb36 --- /dev/null +++ b/DeepDrftTests/OpusBackfillTests.cs @@ -0,0 +1,251 @@ +using System.Text; +using Data.Data.Repositories; +using Data.Managers; +using DeepDrftAPI.Services; +using DeepDrftContent; +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; +using DeepDrftData; +using DeepDrftData.Data; +using DeepDrftData.Repositories; +using DeepDrftModels.DTOs; +using DeepDrftModels.Entities; +using DeepDrftModels.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftTests; + +/// +/// Tests for the Phase 18.5 Backfill-Opus scheduling contract +/// ( and ). +/// These assert the enqueue decision — which tracks get a background derive scheduled — over a real +/// , a real , and an in-memory SQL store, with a +/// recording standing in for the background worker (the actual transcode +/// is not exercised here — it needs ffmpeg and is out of scope for the scheduling contract). +/// +/// The decision under test: a track is enqueued iff it lacks a COMPLETE Opus artifact (both the Opus audio +/// bytes and the seek/setup sidecar). A track with both is skipped; a half-derived track (audio without +/// sidecar) is treated as incomplete and re-enqueued so a backfill heals it. +/// +[TestFixture] +public class OpusBackfillTests +{ + private string _testDir = string.Empty; + private DeepDrftContext _context = null!; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), "OpusBackfillTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new DeepDrftContext(options); + } + + [TearDown] + public void TearDown() + { + _context.Dispose(); + try { Directory.Delete(_testDir, recursive: true); } + catch { /* Best-effort cleanup — ignore failures */ } + } + + private TrackManager CreateManager() + { + var repository = new TrackRepository( + _context, NullLogger>.Instance); + return new TrackManager( + repository, NullLogger>.Instance); + } + + private sealed record Harness( + UnifiedTrackService Service, + NoOpOpusTranscodeQueue Queue, + TrackContentService Content, + FileDb FileDatabase, + ITrackService Sql); + + private async Task BuildAsync() + { + var fileDatabase = await FileDb.FromAsync(_testDir); + Assert.That(fileDatabase, Is.Not.Null); + + var content = new TrackContentService( + fileDatabase!, new AudioProcessorRouter( + new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor())); + var waveforms = new WaveformProfileService( + fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(), + Options.Create(new WaveformProfileOptions()), NullLogger.Instance); + var resolver = new TrackFormatResolver( + fileDatabase!, content, NullLogger.Instance); + var queue = new NoOpOpusTranscodeQueue(); + var sql = CreateManager(); + + var service = new UnifiedTrackService( + content, sql, fileDatabase!, waveforms, queue, resolver, + NullLogger.Instance); + + await fileDatabase!.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio); + + return new Harness(service, queue, content, fileDatabase!, sql); + } + + // Seeds a track: stores a real source WAV in the tracks vault and a SQL row pointing at the same EntryKey. + // Returns the EntryKey so the test can selectively add Opus artifacts to a subset. + private async Task SeedTrackAsync(Harness h, string title) + { + var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav"); + await File.WriteAllBytesAsync(wavPath, BuildMinimalPcmWav(2.0)); + var unpersisted = await h.Content.AddTrackAsync(wavPath, title, "Artist"); + Assert.That(unpersisted, Is.Not.Null); + + var dto = new TrackDto { EntryKey = unpersisted!.EntryKey, TrackName = title }; + var created = await h.Sql.Create(dto); + Assert.That(created.Success, Is.True, created.Messages.FirstOrDefault()?.Message); + + return unpersisted.EntryKey; + } + + private async Task StoreOpusAudioAsync(Harness h, string entryKey) + { + var opus = new AudioBinary(new AudioBinaryParams("opus"u8.ToArray(), 4, ".opus", 2.0, 320)); + Assert.That( + await h.FileDatabase.RegisterResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus), + Is.True); + } + + private async Task StoreSidecarAsync(Harness h, string entryKey) + { + var sidecar = new MediaBinary(new MediaBinaryParams("idx"u8.ToArray(), 3, ".opusidx")); + Assert.That( + await h.FileDatabase.RegisterResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar), + Is.True); + } + + [Test] + public async Task BackfillOpus_EnqueuesOnlyTracksWithoutCompleteOpus() + { + var h = await BuildAsync(); + + // Three tracks: one fully derived (audio + sidecar), one bare (no Opus), one half-derived (audio only). + var complete = await SeedTrackAsync(h, "Complete"); + await StoreOpusAudioAsync(h, complete); + await StoreSidecarAsync(h, complete); + + var bare = await SeedTrackAsync(h, "Bare"); + + var halfDerived = await SeedTrackAsync(h, "HalfDerived"); + await StoreOpusAudioAsync(h, halfDerived); // audio but no sidecar → unseekable → treated as incomplete + + var result = await h.Service.BackfillOpusAsync(CancellationToken.None); + + Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message); + Assert.Multiple(() => + { + Assert.That(result.Value.Enqueued, Is.EqualTo(2), "the bare and half-derived tracks must be enqueued"); + Assert.That(result.Value.Skipped, Is.EqualTo(1), "the fully-derived track must be skipped"); + Assert.That(h.Queue.Enqueued, Does.Contain(bare)); + Assert.That(h.Queue.Enqueued, Does.Contain(halfDerived)); + Assert.That(h.Queue.Enqueued, Does.Not.Contain(complete), "a complete Opus artifact is not re-enqueued"); + }); + } + + [Test] + public async Task BackfillOpus_WhenAllTracksHaveOpus_EnqueuesNothing() + { + var h = await BuildAsync(); + + var a = await SeedTrackAsync(h, "A"); + await StoreOpusAudioAsync(h, a); + await StoreSidecarAsync(h, a); + + var result = await h.Service.BackfillOpusAsync(CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.Multiple(() => + { + Assert.That(result.Value.Enqueued, Is.Zero); + Assert.That(result.Value.Skipped, Is.EqualTo(1)); + Assert.That(h.Queue.Enqueued, Is.Empty, "an all-derived catalogue schedules no transcodes"); + }); + } + + [Test] + public async Task EnqueueOpus_KnownTrack_Enqueues() + { + var h = await BuildAsync(); + var entryKey = await SeedTrackAsync(h, "Solo"); + + var result = await h.Service.EnqueueOpusAsync(entryKey, CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(h.Queue.Enqueued, Does.Contain(entryKey)); + } + + [Test] + public async Task EnqueueOpus_UnknownTrack_FailsWithNotFound_AndEnqueuesNothing() + { + var h = await BuildAsync(); + + var result = await h.Service.EnqueueOpusAsync("no-such-track", CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.Success, Is.False); + Assert.That(result.Messages.FirstOrDefault()?.Message, Is.EqualTo(UnifiedTrackService.TrackNotFoundMessage)); + Assert.That(h.Queue.Enqueued, Is.Empty, "an unknown track must not schedule a transcode"); + }); + } + + // Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites. + private static byte[] BuildMinimalPcmWav(double durationSeconds) + { + const int sampleRate = 44100; + const ushort channels = 1; + const ushort bitsPerSample = 16; + const ushort blockAlign = channels * (bitsPerSample / 8); + const uint byteRate = sampleRate * blockAlign; + + var frames = (int)(sampleRate * durationSeconds); + var data = new byte[frames * blockAlign]; + for (var i = 0; i < frames; i++) + { + var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue; + data[i * 2] = (byte)(sample & 0xFF); + data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF); + } + + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true); + + w.Write(Encoding.ASCII.GetBytes("RIFF")); + w.Write((uint)(36 + data.Length)); + w.Write(Encoding.ASCII.GetBytes("WAVE")); + + w.Write(Encoding.ASCII.GetBytes("fmt ")); + w.Write(16u); + w.Write((ushort)1); // PCM + w.Write(channels); + w.Write((uint)sampleRate); + w.Write(byteRate); + w.Write(blockAlign); + w.Write(bitsPerSample); + + w.Write(Encoding.ASCII.GetBytes("data")); + w.Write((uint)data.Length); + w.Write(data); + + w.Flush(); + return ms.ToArray(); + } +} diff --git a/DeepDrftTests/OpusFormatSelectionTests.cs b/DeepDrftTests/OpusFormatSelectionTests.cs new file mode 100644 index 0000000..6adc20a --- /dev/null +++ b/DeepDrftTests/OpusFormatSelectionTests.cs @@ -0,0 +1,180 @@ +using System.Net; +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Clients; +using DeepDrftPublic.Client.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.JSInterop; +using Microsoft.JSInterop.Infrastructure; + +namespace DeepDrftTests; + +/// +/// Unit tests for the Phase 18.5 player-side format-selection seam +/// (): the default policy (OQ2) of "Opus +/// when the browser can decode Ogg Opus AND a sidecar exists, else lossless", the capability gate (AC7), and +/// the sidecar-absent → lossless fallback (C2). The seam is the single, overridable hook 18.6 will use to +/// inject the listener's quality preference; these tests pin the capability-gated default it falls through to. +/// +/// The seam touches two collaborators: (over a fake +/// — canDecodeOggOpus + setOpusSidecar) and (over a stub HTTP +/// handler — the one-time sidecar fetch). Both are real instances wired over the fakes; only the network/JS +/// boundary is faked, so the selection logic under test is exercised exactly as it runs in the browser. +/// +[TestFixture] +public class OpusFormatSelectionTests +{ + // A scriptable JS runtime: canDecodeOggOpus returns a configured bool; setOpusSidecar returns a + // configured success/failure; every other invocation returns default. Records the calls so a test can + // assert the set-before-init contract was honoured (the sidecar was actually handed to the player). + private sealed class FakeJsRuntime : IJSRuntime + { + private readonly bool _canDecode; + private readonly bool _sidecarParseSucceeds; + + public FakeJsRuntime(bool canDecode, bool sidecarParseSucceeds) + { + _canDecode = canDecode; + _sidecarParseSucceeds = sidecarParseSucceeds; + } + + public int SetSidecarCallCount { get; private set; } + + public ValueTask InvokeAsync(string identifier, object?[]? args) + { + if (identifier == "DeepDrftAudio.canDecodeOggOpus") + return ValueTask.FromResult((TValue)(object)_canDecode); + + if (identifier == "DeepDrftAudio.setOpusSidecar") + { + SetSidecarCallCount++; + var result = new AudioOperationResult + { + Success = _sidecarParseSucceeds, + Error = _sidecarParseSucceeds ? null : "Invalid Opus sidecar blob", + }; + return ValueTask.FromResult((TValue)(object)result); + } + + return ValueTask.FromResult(default!); + } + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + => InvokeAsync(identifier, args); + } + + // Returns a configured status (with a body) for GET api/track/{id}/opus/seekdata; any other request 404s. + private sealed class StubSidecarHandler : HttpMessageHandler + { + private readonly HttpStatusCode _status; + private readonly byte[] _body; + + public StubSidecarHandler(HttpStatusCode status, byte[]? body = null) + { + _status = status; + _body = body ?? []; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(_status); + if (_status == HttpStatusCode.OK) + response.Content = new ByteArrayContent(_body); + return Task.FromResult(response); + } + } + + private sealed class SingleClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + public SingleClientFactory(HttpMessageHandler handler) => _handler = handler; + + public HttpClient CreateClient(string name) => + new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://content.test/") }; + } + + // Exposes the protected seam for direct assertion. The 18.6 override will replace this same method. + private sealed class TestablePlayer : StreamingAudioPlayerService + { + public TestablePlayer(AudioInteropService interop, TrackMediaClient media) + : base(interop, media, NullLogger.Instance) { } + + public Task ResolveFormatForTest(string entryKey) => + ResolveStreamFormatAsync(entryKey, CancellationToken.None); + } + + private static TestablePlayer BuildPlayer( + bool canDecode, bool sidecarParseSucceeds, HttpStatusCode sidecarStatus, byte[]? sidecarBody) + { + var js = new FakeJsRuntime(canDecode, sidecarParseSucceeds); + var interop = new AudioInteropService(js); + var media = new TrackMediaClient(new SingleClientFactory(new StubSidecarHandler(sidecarStatus, sidecarBody))); + return new TestablePlayer(interop, media); + } + + private static readonly byte[] SidecarBytes = "setup-header+seek-index"u8.ToArray(); + + // Capable browser + present sidecar → Opus. The happy path: the default policy picks the low-data format. + [Test] + public async Task ResolveStreamFormat_CapableBrowser_SidecarPresent_ChoosesOpus() + { + var player = BuildPlayer(canDecode: true, sidecarParseSucceeds: true, + HttpStatusCode.OK, SidecarBytes); + + var format = await player.ResolveFormatForTest("track-1"); + + Assert.That(format, Is.EqualTo(AudioFormat.Opus)); + } + + // Capability gate (AC7): a browser that cannot decode Ogg Opus always gets lossless, and the sidecar is + // never even fetched/injected — Opus is off the table before any sidecar work. + [Test] + public async Task ResolveStreamFormat_IncapableBrowser_ChoosesLossless_AndDoesNotInjectSidecar() + { + var js = new FakeJsRuntime(canDecode: false, sidecarParseSucceeds: true); + var interop = new AudioInteropService(js); + var media = new TrackMediaClient(new SingleClientFactory( + new StubSidecarHandler(HttpStatusCode.OK, SidecarBytes))); + var player = new TestablePlayer(interop, media); + + var format = await player.ResolveFormatForTest("track-1"); + + Assert.Multiple(() => + { + Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "incapable browser must fall back to lossless"); + Assert.That(js.SetSidecarCallCount, Is.Zero, "no sidecar should be injected when Opus is gated out"); + }); + } + + // C2 fallback: capable browser but no sidecar (legacy / not-yet-transcoded track, 404) → lossless. + [Test] + public async Task ResolveStreamFormat_CapableBrowser_NoSidecar_FallsBackToLossless() + { + var player = BuildPlayer(canDecode: true, sidecarParseSucceeds: true, + HttpStatusCode.NotFound, sidecarBody: null); + + var format = await player.ResolveFormatForTest("track-1"); + + Assert.That(format, Is.EqualTo(AudioFormat.Lossless), + "a capable browser with no Opus sidecar must request lossless, not Opus"); + } + + // A present-but-unparseable sidecar (the JS decoder rejects the bytes) → lossless, so a malformed sidecar + // never breaks playback. The injection was attempted (set-before-init), but its failure degrades safely. + [Test] + public async Task ResolveStreamFormat_SidecarPresentButUnparseable_FallsBackToLossless() + { + var js = new FakeJsRuntime(canDecode: true, sidecarParseSucceeds: false); + var interop = new AudioInteropService(js); + var media = new TrackMediaClient(new SingleClientFactory( + new StubSidecarHandler(HttpStatusCode.OK, SidecarBytes))); + var player = new TestablePlayer(interop, media); + + var format = await player.ResolveFormatForTest("track-1"); + + Assert.Multiple(() => + { + Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "an unparseable sidecar must degrade to lossless"); + Assert.That(js.SetSidecarCallCount, Is.EqualTo(1), "the player attempted the set-before-init injection"); + }); + } +} diff --git a/DeepDrftTests/ReplaceAudioOpusRegenTests.cs b/DeepDrftTests/ReplaceAudioOpusRegenTests.cs new file mode 100644 index 0000000..0caf766 --- /dev/null +++ b/DeepDrftTests/ReplaceAudioOpusRegenTests.cs @@ -0,0 +1,171 @@ +using System.Text; +using DeepDrftAPI.Services; +using DeepDrftContent; +using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; +using DeepDrftData; +using DeepDrftModels.DTOs; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Models.Common; +using NetBlocks.Models; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftTests; + +/// +/// Confirms the Phase 18.5 acceptance point that +/// regenerates the Opus artifact: a replace must schedule a background Opus re-derive for the track, because +/// the stale Opus no longer matches the new source bytes (the same reason it regenerates the waveform datums +/// and re-derives duration). The enqueue was wired in 18.1; this test pins it so a future refactor cannot +/// silently drop it, leaving a track serving Opus that does not match its lossless source. +/// +/// The vault + content + waveform collaborators are real (over a temp-dir ); the SQL +/// service is a focused fake. The fake is used here rather than the in-memory EF store because the replace +/// path's duration write goes through SetDurationExecuteUpdateAsync, which the EF in-memory +/// provider does not support — the fake lets the orchestration run to the post-write enqueue under test. +/// +[TestFixture] +public class ReplaceAudioOpusRegenTests +{ + private string _testDir = string.Empty; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), "ReplaceAudioOpusRegenTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* Best-effort cleanup — ignore failures */ } + } + + // A focused ITrackService fake: only GetById (returns the seeded track) and SetDuration (records the + // write and reports success) are meaningful — the two members the replace path calls. Everything else + // throws, documenting that the replace orchestration touches nothing else on the SQL boundary. + private sealed class FakeTrackService : ITrackService + { + private readonly TrackDto _track; + public double? LastDurationWritten { get; private set; } + + public FakeTrackService(TrackDto track) => _track = track; + + public Task> GetById(long id) => + Task.FromResult(ResultContainer.CreatePassResult(id == _track.Id ? _track : null)); + + public Task> SetDuration(long id, double durationSeconds, CancellationToken ct = default) + { + LastDurationWritten = durationSeconds; + return Task.FromResult(ResultContainer.CreatePassResult(1)); + } + + // Unused by the replace path — fail loudly if the orchestration ever reaches them. + public Task> GetByEntryKey(string entryKey) => throw new NotSupportedException(); + public Task> GetRandom(CancellationToken ct = default) => throw new NotSupportedException(); + public Task>> GetAll() => throw new NotSupportedException(); + public Task>> GetPaged(int p, int s, string? c, bool d, TrackFilter? f = null, CancellationToken ct = default) => throw new NotSupportedException(); + public Task>> GetReleases(CancellationToken ct = default) => throw new NotSupportedException(); + public Task>> GetDistinctGenres(CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetHomeStats(CancellationToken ct = default) => throw new NotSupportedException(); + public Task>> GetTracksMissingDuration(CancellationToken ct = default) => throw new NotSupportedException(); + public Task> UpdateDuration(long id, double d, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> FindOrCreateRelease(string t, string a, ReleaseDto r, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetReleaseByTitleAndArtist(string t, string a, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> Create(TrackDto t) => throw new NotSupportedException(); + public Task> Update(TrackDto t) => throw new NotSupportedException(); + public Task Delete(long id) => throw new NotSupportedException(); + public Task DeleteRelease(long id, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> CountLiveTracksByRelease(long releaseId, CancellationToken ct = default) => throw new NotSupportedException(); + } + + [Test] + public async Task ReplaceAudio_EnqueuesOpusRegen_ForTheReplacedTrack() + { + var fileDatabase = await FileDb.FromAsync(_testDir); + Assert.That(fileDatabase, Is.Not.Null); + + var content = new TrackContentService( + fileDatabase!, new AudioProcessorRouter( + new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor())); + var waveforms = new WaveformProfileService( + fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(), + Options.Create(new WaveformProfileOptions()), NullLogger.Instance); + var resolver = new TrackFormatResolver( + fileDatabase!, content, NullLogger.Instance); + var queue = new NoOpOpusTranscodeQueue(); + + // Seed the source WAV in the vault and point a fake SQL row at the same EntryKey. + var originalPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav"); + await File.WriteAllBytesAsync(originalPath, BuildMinimalPcmWav(2.0)); + var unpersisted = await content.AddTrackAsync(originalPath, "Original", "Artist"); + Assert.That(unpersisted, Is.Not.Null); + + const long trackId = 42; + var sql = new FakeTrackService(new TrackDto { Id = trackId, EntryKey = unpersisted!.EntryKey, TrackName = "Original" }); + + var service = new UnifiedTrackService( + content, sql, fileDatabase!, waveforms, queue, resolver, + NullLogger.Instance); + + // Replace the audio with a longer take. + var replacementPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav"); + await File.WriteAllBytesAsync(replacementPath, BuildMinimalPcmWav(6.0)); + + var result = await service.ReplaceAudioAsync(trackId, replacementPath, CancellationToken.None); + + Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message); + Assert.Multiple(() => + { + Assert.That(queue.Enqueued, Does.Contain(unpersisted.EntryKey), + "a replace must schedule an Opus re-derive so the artifact tracks the new source"); + Assert.That(sql.LastDurationWritten, Is.GreaterThan(0), + "the replace must also write the new duration (the enqueue follows a successful duration write)"); + }); + } + + // Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites. + private static byte[] BuildMinimalPcmWav(double durationSeconds) + { + const int sampleRate = 44100; + const ushort channels = 1; + const ushort bitsPerSample = 16; + const ushort blockAlign = channels * (bitsPerSample / 8); + const uint byteRate = sampleRate * blockAlign; + + var frames = (int)(sampleRate * durationSeconds); + var data = new byte[frames * blockAlign]; + for (var i = 0; i < frames; i++) + { + var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue; + data[i * 2] = (byte)(sample & 0xFF); + data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF); + } + + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true); + + w.Write(Encoding.ASCII.GetBytes("RIFF")); + w.Write((uint)(36 + data.Length)); + w.Write(Encoding.ASCII.GetBytes("WAVE")); + + w.Write(Encoding.ASCII.GetBytes("fmt ")); + w.Write(16u); + w.Write((ushort)1); // PCM + w.Write(channels); + w.Write((uint)sampleRate); + w.Write(byteRate); + w.Write(blockAlign); + w.Write(bitsPerSample); + + w.Write(Encoding.ASCII.GetBytes("data")); + w.Write((uint)data.Length); + w.Write(data); + + w.Flush(); + return ms.ToArray(); + } +} diff --git a/DeepDrftTests/UploadDuplicateDetectionTests.cs b/DeepDrftTests/UploadDuplicateDetectionTests.cs index 97af62e..2f33fae 100644 --- a/DeepDrftTests/UploadDuplicateDetectionTests.cs +++ b/DeepDrftTests/UploadDuplicateDetectionTests.cs @@ -4,6 +4,7 @@ using Data.Managers; using DeepDrftAPI.Services; using DeepDrftContent; using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; using DeepDrftData; using DeepDrftData.Data; using DeepDrftData.Repositories; @@ -74,10 +75,13 @@ public class UploadDuplicateDetectionTests var waveforms = new WaveformProfileService( fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(), Options.Create(new WaveformProfileOptions()), NullLogger.Instance); + var resolver = new TrackFormatResolver( + fileDatabase!, content, NullLogger.Instance); return new UnifiedTrackService( content, sqlTrackService, fileDatabase!, waveforms, new NoOpOpusTranscodeQueue(), + resolver, NullLogger.Instance); } From c63c7ca0337308e1ff14bd3f570845d0c8d95fd5 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 14:06:19 -0400 Subject: [PATCH 08/54] =?UTF-8?q?feature:=20Phase=2018.6=20Track=20A=20?= =?UTF-8?q?=E2=80=94=20public=20Settings=20menu=20+=20streaming-quality=20?= =?UTF-8?q?toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/PublicSiteSettings.cs | 29 +++++++++ DeepDrftPublic.Client/Common/SettingsItem.cs | 12 ++++ DeepDrftPublic.Client/Common/StreamQuality.cs | 18 +++++ .../Controls/AudioPlayerProvider.razor.cs | 9 ++- .../Controls/Settings/SettingsMenu.razor | 40 ++++++++++++ .../Settings/StreamQualitySetting.razor | 65 +++++++++++++++++++ .../Layout/DeepDrftMenu.razor | 5 +- DeepDrftPublic.Client/Layout/MainLayout.razor | 16 ++++- .../Services/AudioInteropService.cs | 2 +- .../PreferenceAwareStreamingPlayerService.cs | 49 ++++++++++++++ .../Services/SettingsCookieService.cs | 34 ++++++++++ .../Services/SettingsServiceBase.cs | 28 ++++++++ .../Services/StreamingAudioPlayerService.cs | 2 +- DeepDrftPublic.Client/Startup.cs | 7 ++ DeepDrftPublic/Components/App.razor | 2 + DeepDrftPublic/Services/SettingsService.cs | 25 +++++++ DeepDrftPublic/Startup.cs | 5 ++ .../wwwroot/styles/deepdrft-styles.css | 40 ++++++++++++ 18 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 DeepDrftPublic.Client/Common/PublicSiteSettings.cs create mode 100644 DeepDrftPublic.Client/Common/SettingsItem.cs create mode 100644 DeepDrftPublic.Client/Common/StreamQuality.cs create mode 100644 DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor create mode 100644 DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor create mode 100644 DeepDrftPublic.Client/Services/PreferenceAwareStreamingPlayerService.cs create mode 100644 DeepDrftPublic.Client/Services/SettingsCookieService.cs create mode 100644 DeepDrftPublic.Client/Services/SettingsServiceBase.cs create mode 100644 DeepDrftPublic/Services/SettingsService.cs diff --git a/DeepDrftPublic.Client/Common/PublicSiteSettings.cs b/DeepDrftPublic.Client/Common/PublicSiteSettings.cs new file mode 100644 index 0000000..1b83b16 --- /dev/null +++ b/DeepDrftPublic.Client/Common/PublicSiteSettings.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Common; + +/// +/// The single public-site listener-settings object (Phase 18 wave 18.6, §4a). The generalized analogue of +/// : one scoped holder for every remembered listener preference, seeded at +/// server prerender, carried into WASM via , and persisted to a cookie on +/// change. Today it carries one preference — streaming quality; tomorrow dark mode (and whatever follows) +/// folds in here as another property without disturbing the menu that reads it. +/// +/// Built design-for-adaptability per §4a: a new preference is a new [PersistentState] property here +/// plus a new in the menu — not a rewire. Dark mode is intentionally +/// not migrated in now (it keeps its own seam); this object is shaped +/// so that consolidation is later a merge of two identical seams, not a reconciliation of two different ones. +/// +/// +public class PublicSiteSettings +{ + /// + /// The listener's streaming-quality preference. Defaults to (Opus, + /// capability-gated — OQ2). Seeded from the streamQuality cookie at prerender; persisted on change + /// by the client cookie service. The player reads this to decide which ?format= to request, but + /// the capability gate and C2 fallback still apply on top, so a + /// preference never forces an unplayable stream. + /// + [PersistentState] + public StreamQuality StreamQuality { get; set; } = StreamQuality.LowData; +} diff --git a/DeepDrftPublic.Client/Common/SettingsItem.cs b/DeepDrftPublic.Client/Common/SettingsItem.cs new file mode 100644 index 0000000..9cdfa6b --- /dev/null +++ b/DeepDrftPublic.Client/Common/SettingsItem.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Common; + +/// +/// One entry in the public-site Settings menu (Phase 18 wave 18.6, §4a). The settings-item abstraction the +/// menu renders instead of a hard-coded control list: a plus a +/// fragment bound to a persisted preference. Adding a future tenant (e.g. dark mode) is appending one of +/// these — not rewiring the menu. The control fragment owns its own binding to +/// and its own persistence call, so each item is self-contained and the menu stays preference-agnostic. +/// +public sealed record SettingsItem(string Label, RenderFragment Control); diff --git a/DeepDrftPublic.Client/Common/StreamQuality.cs b/DeepDrftPublic.Client/Common/StreamQuality.cs new file mode 100644 index 0000000..5dd1508 --- /dev/null +++ b/DeepDrftPublic.Client/Common/StreamQuality.cs @@ -0,0 +1,18 @@ +namespace DeepDrftPublic.Client.Common; + +/// +/// The listener's streaming-quality preference (Phase 18 wave 18.6, §4). This is the user's intent, +/// not the wire format that ultimately gets served: means "give me Opus if you can," +/// but the player still capability-gates and C2-falls-back to lossless when Opus can't play (a browser that +/// can't decode Ogg Opus, or a track with no Opus artifact). It is therefore deliberately distinct from +/// DeepDrftModels.Enums.AudioFormat (the delivery rendering resolved per request): one is the +/// remembered preference, the other is what a given stream request actually asks for. +/// +public enum StreamQuality +{ + /// Bandwidth-friendly Opus (capability-gated; the default before any choice — OQ2). + LowData, + + /// The lossless WAV path, always playable everywhere. + Lossless +} diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs index 5202023..22e4a8a 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs @@ -1,3 +1,4 @@ +using DeepDrftPublic.Client.Common; using DeepDrftPublic.Client.Services; using DeepDrftPublic.Client.Clients; using Microsoft.AspNetCore.Components; @@ -13,6 +14,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable [Inject] public required BeaconInterop Beacon { get; set; } [Inject] public required IPlayEventSink PlayEventSink { get; set; } [Inject] public required IAnonIdProvider AnonId { get; set; } + [Inject] public required PublicSiteSettings Settings { get; set; } private IStreamingPlayerService? _audioPlayerService; private QueueService? _queueService; @@ -26,7 +28,12 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable // EnsureInitializedAsync — that path is correct because audio contexts // require a user gesture anyway. Initializing eagerly here causes 4+ // SignalR round-trips before any content is stable. - var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger); + // Construct the preference-aware player (Phase 18 wave 18.6): it honours the listener's streaming- + // quality choice via the ResolveStreamFormatAsync seam while inheriting the 18.5 capability gate and + // C2 fallback. PublicSiteSettings is scoped data (already prerender-seeded + WASM-bridged), so passing + // it through the constructor is cheap and carries no lifecycle — the telemetry tracker still binds + // post-construction below, exactly as before. + var player = new PreferenceAwareStreamingPlayerService(AudioInterop, TrackMediaClient, Logger, Settings); // Phase 16: bind the play-session tracker to the player after construction, the same way the // queue binds — the player is built with `new`, not DI, so threading telemetry through its diff --git a/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor b/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor new file mode 100644 index 0000000..9fd1a60 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor @@ -0,0 +1,40 @@ +@using DeepDrftPublic.Client.Common +@using DeepDrftPublic.Client.Controls.Settings + +@* + The public-site Settings menu (Phase 18 wave 18.6, §4a). An app-bar trigger opening a MudMenu that renders + a settings-item list — NOT a hard-coded control stack. Each entry is a SettingsItem (label + a control + fragment bound to a persisted preference), so a future tenant (dark mode) plugs in as a new list entry, not + a menu rewire. Today the list holds one item: the streaming-quality toggle. + + The MudMenu items carry OnClick="@(() => {})" + OnTouch so a click inside a control row does not dismiss the + menu (MudMenu auto-closes on item activation otherwise), keeping the radio group usable. +*@ +
+ +
+
Settings
+ @foreach (var item in _items) + { +
+
@item.Label
+ @item.Control +
+ } +
+
+
+ +@code { + // The settings-item list. Built once; adding a preference is appending one SettingsItem with its control + // fragment — the menu body above renders whatever is here without knowing what each item is. + private readonly List _items = + [ + new SettingsItem("Streaming quality", @) + ]; +} diff --git a/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor b/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor new file mode 100644 index 0000000..4fcff26 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor @@ -0,0 +1,65 @@ +@using DeepDrftPublic.Client.Common +@using DeepDrftPublic.Client.Services + +@* + The streaming-quality control (Phase 18 wave 18.6, §4) — the first occupant of the Settings menu. Binds + the listener's choice to PublicSiteSettings.StreamQuality and persists it via the cookie seam. Honest + capability gate (OQ2 / AC7): on a browser that cannot decode Ogg Opus the Low-data option still selects, + but a note tells the listener the effective stream is lossless — we never let the choice silently imply a + format that can't play. +*@ +
+ + + Low-data (Opus) + + + Lossless (WAV) + + + + @if (_opusUnavailable && _quality == StreamQuality.LowData) + { +
+ This browser can't decode Opus — you'll stream lossless. +
+ } +
+ +@code { + [Inject] public required PublicSiteSettings Settings { get; set; } + [Inject] public required SettingsCookieService CookieService { get; set; } + [Inject] public required AudioInteropService AudioInterop { get; set; } + + private StreamQuality _quality; + + // Null until the capability probe runs (post-render JS interop). false → can decode Opus; true → cannot. + private bool _opusUnavailable; + + protected override void OnInitialized() + { + // Read the current preference (already seeded at prerender + bridged into WASM). + _quality = Settings.StreamQuality; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + // Capability probe is JS interop — only valid once interactive. Surfaces the honest note when the + // browser can't decode Ogg Opus, so a Low-data pick reads as "effectively lossless" rather than + // silently failing. The player applies the same gate independently; this is purely the UI honesty. + var canDecodeOpus = await AudioInterop.CanDecodeOggOpus(); + if (canDecodeOpus == _opusUnavailable) + { + _opusUnavailable = !canDecodeOpus; + StateHasChanged(); + } + } + + private async Task OnQualityChanged(StreamQuality quality) + { + _quality = quality; + await CookieService.SetStreamQualityAsync(quality); + } +} diff --git a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor index f8808bf..27b7028 100644 --- a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor +++ b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor @@ -1,5 +1,6 @@ @using DeepDrftPublic.Client.Common @using DeepDrftPublic.Client.Controls +@using DeepDrftPublic.Client.Controls.Settings @using DeepDrftPublic.Client.Services @* Desktop Menu *@ @@ -42,6 +43,7 @@
+
@@ -74,7 +76,8 @@ @onclick="ToggleMobileMenu"> - + + diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor b/DeepDrftPublic.Client/Layout/MainLayout.razor index bdfc432..66ec0d5 100644 --- a/DeepDrftPublic.Client/Layout/MainLayout.razor +++ b/DeepDrftPublic.Client/Layout/MainLayout.razor @@ -42,6 +42,7 @@ @code { private string _audioPlayerClass = "minimized"; private const string DarkModeKey = "darkMode"; + private const string StreamQualityKey = "streamQuality"; private bool _isDarkMode = false; private bool? _lastAppliedDarkMode = null; private PersistingComponentStateSubscription _persistingSubscription; @@ -49,6 +50,7 @@ [Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required DarkModeSettings DarkModeSettings { get; set; } + [Inject] public required PublicSiteSettings PublicSiteSettings { get; set; } [Inject] public required IJSRuntime JS { get; set; } protected override void OnInitialized() @@ -66,8 +68,17 @@ _isDarkMode = DarkModeSettings.IsDarkMode; } + // Restore the prerender-seeded streaming-quality preference (Phase 18 wave 18.6). Same bridge dark + // mode uses: the server SettingsService seeded PublicSiteSettings from the streamQuality cookie, and + // this carries it into WASM so the client boots already knowing the preference (no re-read flash, no + // wrong default before the first stream). + if (PersistentState.TryTakeFromJson(StreamQualityKey, out var restoredQuality)) + { + PublicSiteSettings.StreamQuality = restoredQuality; + } + // Register to persist state when prerendering completes - _persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode); + _persistingSubscription = PersistentState.RegisterOnPersisting(PersistState); } // Sync dark mode class on so portaled MudBlazor elements (popovers, menus, selects) @@ -91,9 +102,10 @@ // Theme wrapper class for CSS targeting private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light"; - private Task PersistDarkMode() + private Task PersistState() { PersistentState.PersistAsJson(DarkModeKey, _isDarkMode); + PersistentState.PersistAsJson(StreamQualityKey, PublicSiteSettings.StreamQuality); return Task.CompletedTask; } diff --git a/DeepDrftPublic.Client/Services/AudioInteropService.cs b/DeepDrftPublic.Client/Services/AudioInteropService.cs index 4c109ee..6ea87e5 100644 --- a/DeepDrftPublic.Client/Services/AudioInteropService.cs +++ b/DeepDrftPublic.Client/Services/AudioInteropService.cs @@ -76,7 +76,7 @@ public class AudioInteropService : IAsyncDisposable /// stays on the universal lossless path (AC7 — no listener ever gets silence over a codec gap). Probe /// failures degrade to false (assume incapable) so an interop error can never silence playback. ///
- public async Task CanDecodeOggOpus(string playerId) + public async Task CanDecodeOggOpus() { try { diff --git a/DeepDrftPublic.Client/Services/PreferenceAwareStreamingPlayerService.cs b/DeepDrftPublic.Client/Services/PreferenceAwareStreamingPlayerService.cs new file mode 100644 index 0000000..1375bed --- /dev/null +++ b/DeepDrftPublic.Client/Services/PreferenceAwareStreamingPlayerService.cs @@ -0,0 +1,49 @@ +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Clients; +using DeepDrftPublic.Client.Common; +using Microsoft.Extensions.Logging; + +namespace DeepDrftPublic.Client.Services; + +/// +/// The production player that honours the listener's streaming-quality preference (Phase 18 wave 18.6). +/// Extends through the single deliberately-overridable seam, +/// , so the rest of the streaming stack +/// (seek, telemetry, the seek-beyond-buffer format reuse) is inherited verbatim. +/// +/// The override is one branch: a preference returns +/// immediately; anything else falls through to base, which keeps +/// the 18.5 invariants intact — the capability gate (AC7: a browser that can't decode Ogg Opus gets lossless) +/// and the sidecar-absent → lossless fallback (C2: a legacy / un-backfilled / failed-transcode track gets +/// lossless). So a Lossless pick always yields lossless; a Low-data pick yields Opus only when it can +/// actually play, and lossless otherwise. No path produces an unplayable stream. +/// +/// +public class PreferenceAwareStreamingPlayerService : StreamingAudioPlayerService +{ + private readonly PublicSiteSettings _settings; + + public PreferenceAwareStreamingPlayerService( + AudioInteropService audioInterop, + TrackMediaClient trackMediaClient, + ILogger logger, + PublicSiteSettings settings) + : base(audioInterop, trackMediaClient, logger) + { + _settings = settings; + } + + protected override async Task ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken) + { + // Listener explicitly chose lossless — request it directly, no Opus probe / sidecar fetch needed. + if (_settings.StreamQuality == StreamQuality.Lossless) + { + return AudioFormat.Lossless; + } + + // Low-data preference: defer to the base capability-gated resolution, which probes Opus support and + // the sidecar's presence and degrades to lossless when either is missing. Both 18.5 invariants are + // inherited here, not re-implemented. + return await base.ResolveStreamFormatAsync(entryKey, cancellationToken); + } +} diff --git a/DeepDrftPublic.Client/Services/SettingsCookieService.cs b/DeepDrftPublic.Client/Services/SettingsCookieService.cs new file mode 100644 index 0000000..e58f08b --- /dev/null +++ b/DeepDrftPublic.Client/Services/SettingsCookieService.cs @@ -0,0 +1,34 @@ +using DeepDrftPublic.Client.Common; +using Microsoft.JSInterop; + +namespace DeepDrftPublic.Client.Services; + +/// +/// Client-side runtime writer for public-site settings (Phase 18 wave 18.6), the analogue of +/// . Reads the current preference off the in-memory +/// (already seeded at prerender and bridged into WASM), and writes a +/// 365-day cookie via document.cookie interop when the listener changes it in the Settings menu — +/// the same durable-truth seam dark mode uses, so the choice survives the session and seeds the next visit's +/// prerender (no flash). +/// +public class SettingsCookieService(PublicSiteSettings settings, IJSRuntime js) : SettingsServiceBase +{ + private const int ExpiryDays = 365; + + public StreamQuality GetStreamQuality() => settings.StreamQuality; + + public async ValueTask SetStreamQualityAsync(StreamQuality quality) + { + if (settings.StreamQuality == quality) return; + + await WriteCookieAsync(StreamQualityCookieName, FormatStreamQuality(quality)); + settings.StreamQuality = quality; + } + + private async ValueTask WriteCookieAsync(string name, string value) + { + var expires = DateTime.UtcNow.AddDays(ExpiryDays).ToString("R"); + await js.InvokeVoidAsync("eval", + $"document.cookie = '{name}={value}; expires={expires}; path=/; SameSite=Lax'"); + } +} diff --git a/DeepDrftPublic.Client/Services/SettingsServiceBase.cs b/DeepDrftPublic.Client/Services/SettingsServiceBase.cs new file mode 100644 index 0000000..73845c5 --- /dev/null +++ b/DeepDrftPublic.Client/Services/SettingsServiceBase.cs @@ -0,0 +1,28 @@ +using DeepDrftPublic.Client.Common; + +namespace DeepDrftPublic.Client.Services; + +/// +/// Shared cookie contract for the public-site settings seam (Phase 18 wave 18.6), the analogue of +/// . Holds the cookie names and the (de)serialization for each preference +/// so the server prerender-read service and the client cookie-write service agree on one wire format — +/// the load-bearing reason this is shared rather than duplicated. Each new preference adds its cookie name +/// and a parse/format pair here, keeping the round-trip in one place. +/// +public abstract class SettingsServiceBase +{ + protected const string StreamQualityCookieName = "streamQuality"; + + /// + /// Parses the streamQuality cookie value into , defaulting to + /// (the OQ2 default) for an absent, empty, or unrecognized value so + /// a missing/garbled cookie never produces a surprising preference. + /// + protected static StreamQuality ParseStreamQuality(string? cookieValue) => + Enum.TryParse(cookieValue, ignoreCase: true, out var parsed) + ? parsed + : StreamQuality.LowData; + + /// Formats a for cookie storage (round-trips with ). + protected static string FormatStreamQuality(StreamQuality quality) => quality.ToString(); +} diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index bf49532..9b6f856 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -280,7 +280,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS protected virtual async Task ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken) { // Capability gate first (AC7): never hand Ogg Opus to a browser that cannot decode it. - if (!await _audioInterop.CanDecodeOggOpus(PlayerId)) + if (!await _audioInterop.CanDecodeOggOpus()) { return AudioFormat.Lossless; } diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs index 55781aa..89bccf7 100644 --- a/DeepDrftPublic.Client/Startup.cs +++ b/DeepDrftPublic.Client/Startup.cs @@ -14,6 +14,13 @@ public static class Startup services.AddScoped(); services.AddScoped(); + // Public-site listener settings (Phase 18 wave 18.6). PublicSiteSettings is the generalized, + // prerender-seeded preference object (today: streaming quality); SettingsCookieService writes the + // 365-day cookie at runtime. Same scoped lifetime + cookie seam as the dark-mode pair above, so the + // preference survives SPA nav within a session and seeds the next visit's prerender. + services.AddScoped(); + services.AddScoped(); + // Track Client. The HTTP-backed ITrackDataService is used by both WASM and SSR // prerender — both call DeepDrftAPI over the "DeepDrft.API" client. services.AddScoped(); diff --git a/DeepDrftPublic/Components/App.razor b/DeepDrftPublic/Components/App.razor index e36ba5b..851143b 100644 --- a/DeepDrftPublic/Components/App.razor +++ b/DeepDrftPublic/Components/App.razor @@ -34,11 +34,13 @@ @code { [Inject] public required DarkModeService DarkModeService { get; set; } + [Inject] public required SettingsService SettingsService { get; set; } protected override void OnInitialized() { base.OnInitialized(); DarkModeService.CheckDarkMode(); + SettingsService.CheckSettings(); } } diff --git a/DeepDrftPublic/Services/SettingsService.cs b/DeepDrftPublic/Services/SettingsService.cs new file mode 100644 index 0000000..efc475e --- /dev/null +++ b/DeepDrftPublic/Services/SettingsService.cs @@ -0,0 +1,25 @@ +using DeepDrftPublic.Client.Common; +using DeepDrftPublic.Client.Services; + +namespace DeepDrftPublic.Services; + +/// +/// Server-side prerender reader for public-site listener settings (Phase 18 wave 18.6), the sibling of +/// . Reads each preference's cookie via +/// during prerender and seeds the scoped , which MainLayout then +/// rounds through PersistentComponentState into WASM — so the first paint already reflects the +/// listener's choice with no wrong-default flash (the streaming-quality analogue of the wrong-theme fix). +/// Inherits the shared cookie names + parsers from so the server read and +/// the client write agree on one wire format. +/// +public class SettingsService(PublicSiteSettings settings, IHttpContextAccessor httpAccessor) : SettingsServiceBase +{ + public void CheckSettings() + { + var cookies = httpAccessor.HttpContext?.Request.Cookies; + if (cookies is null) return; + + cookies.TryGetValue(StreamQualityCookieName, out var streamQuality); + settings.StreamQuality = ParseStreamQuality(streamQuality); + } +} diff --git a/DeepDrftPublic/Startup.cs b/DeepDrftPublic/Startup.cs index 4ab30e1..de3cd8e 100644 --- a/DeepDrftPublic/Startup.cs +++ b/DeepDrftPublic/Startup.cs @@ -11,5 +11,10 @@ public static class Startup builder.Services .AddHttpContextAccessor() .AddScoped(); + + // Server prerender read for public-site listener settings (Phase 18 wave 18.6), sibling to + // DarkModeService. PublicSiteSettings itself is registered in the client Startup (shared by SSR and + // WASM); this seeds it from the streamQuality cookie during prerender. + builder.Services.AddScoped(); } } diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index f2c3b5c..ea4a26a 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -463,6 +463,46 @@ h2, h3, h4, h5, h6, flex: 1 1 auto; } +/* Public-site Settings menu (Phase 18 wave 18.6). The MudMenu body renders inside .mud-popover, which + already re-points --mud-palette-surface to the theme-aware --deepdrft-popover-surface (see above), so the + panel inherits the correct surface + text in both themes with no dark override. These rules are layout + only: padding, the section heading, and each settings item's label/control stacking. Scoped via the + global stylesheet (not CSS isolation) because the menu body portals out of the component's DOM scope. */ +.dd-settings-panel { + padding: 0.75rem 1rem; + min-width: 240px; + max-width: 320px; + color: var(--deepdrft-page-text); +} + +.dd-settings-heading { + font-family: "DM Sans", sans-serif; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + opacity: 0.7; + margin-bottom: 0.5rem; +} + +.dd-settings-item + .dd-settings-item { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--deepdrft-popover-surface); +} + +.dd-settings-item-label { + font-weight: 500; + margin-bottom: 0.25rem; +} + +/* The honest capability note under the quality control (OQ2 / AC7). */ +.dd-setting-note { + font-size: 0.75rem; + opacity: 0.75; + margin-top: 0.25rem; +} + .deepdrft-share-embed-field .mud-input-slot { font-family: var(--deepdrft-font-mono) !important; font-size: 0.75rem; From 59f48bb8cb5c78457e8de75c2c815fad6becd12d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 14:06:21 -0400 Subject: [PATCH 09/54] =?UTF-8?q?feature:=20CMS=20Opus=20status=20surfaces?= =?UTF-8?q?=20=E2=80=94=20backfill=20missing-N=20badge=20+=20upload=20Post?= =?UTF-8?q?-Processing=20phase=20(18.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DeepDrftAPI/Controllers/TrackController.cs | 34 ++++++ .../Components/Pages/Tracks/BatchEdit.razor | 12 +- .../Components/Pages/Tracks/BatchRowModel.cs | 7 +- .../Pages/Tracks/BatchTrackList.razor | 2 + .../Components/Pages/Tracks/BatchUpload.razor | 17 ++- .../Components/Pages/Tracks/Releases.razor | 107 +++++++++++++++++- DeepDrftManager/Services/CmsTrackService.cs | 44 +++++++ DeepDrftManager/Services/ICmsTrackService.cs | 8 ++ DeepDrftModels/DTOs/OpusStatusDto.cs | 19 ++++ 9 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 DeepDrftModels/DTOs/OpusStatusDto.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index d8dfe0f..2340e8e 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -249,6 +249,40 @@ public class TrackController : ControllerBase return Ok(status); } + // GET api/track/opus-status ([ApiKeyAuthorize]) + // Admin Post-Processing view (18.6): returns every track with a flag for whether it carries a COMPLETE + // Opus artifact — both the Opus audio AND the seek/setup sidecar present (TrackFormatResolver.HasOpusAsync, + // the same completeness rule the 18.5 Backfill-Opus pass enqueues against; a half-derived track counts as + // missing). Mirrors GET waveform-status exactly: same ApiKey auth, same unpaged whole-catalogue shape, same + // literal-route placement before "{trackId}". The CMS reads it to show the Backfill-Opus "missing N" badge + // and to poll per-track Post-Processing status after an upload. + [ApiKeyAuthorize] + [HttpGet("opus-status")] + public async Task GetOpusStatus() + { + var tracks = await _sqlTrackService.GetAll(); + if (!tracks.Success || tracks.Value is null) + { + var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetOpusStatus failed to load tracks: {Error}", error); + return StatusCode(500, "Failed to load tracks"); + } + + var status = new List(tracks.Value.Count); + foreach (var track in tracks.Value) + { + status.Add(new OpusStatusDto + { + TrackId = track.Id, + EntryKey = track.EntryKey, + TrackName = track.TrackName, + HasOpus = await _formatResolver.HasOpusAsync(track.EntryKey), + }); + } + + return Ok(status); + } + // POST api/track/duration/backfill ([ApiKeyAuthorize], no body) // One-time admin backfill: for every track whose SQL duration is still null, read the duration from // the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index c0422dc..eebb7cc 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -518,7 +518,10 @@ { var row = _tracks[i]; - if (row.Status == BatchRowStatus.Done) + // Skip rows already processed in a prior submit attempt. PostProcessing counts as processed: + // the track persisted successfully (only its background Opus derive is still settling), so a + // re-submit after a partial failure must NOT re-upload it and mint a duplicate. + if (row.Status is BatchRowStatus.Done or BatchRowStatus.PostProcessing) { _processedCount++; continue; @@ -638,7 +641,12 @@ } } - row.Status = BatchRowStatus.Done; + // §3.1a: a new-track upload persists the track (live + lossless) and the server + // derives Opus in the background, so the row enters the visible Post-Processing + // phase — same as BatchUpload. (The metadata-only update path above stays Done: it + // changes no audio, so it triggers no transcode.) Non-blocking; the Releases view + // polls the durable Opus status to settle it. + row.Status = BatchRowStatus.PostProcessing; succeeded++; } } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchRowModel.cs b/DeepDrftManager/Components/Pages/Tracks/BatchRowModel.cs index 1d4ab85..93730ae 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchRowModel.cs +++ b/DeepDrftManager/Components/Pages/Tracks/BatchRowModel.cs @@ -40,4 +40,9 @@ public class BatchRowModel : 0; } -public enum BatchRowStatus { Queued, Uploading, Done, Failed } +// Done is the terminal success state (track persisted + playable losslessly). PostProcessing is the +// visible third upload phase (§3.1a): the byte transfer and server persist are finished and the track is +// live, but the server-side background Opus transcode is still running. It is NOT a failure and never +// blocks completion — the form may navigate away while a row sits in PostProcessing; the Releases browse +// view polls the durable Opus status from there. +public enum BatchRowStatus { Queued, Uploading, PostProcessing, Done, Failed } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor b/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor index 48c1121..3750ceb 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor @@ -126,6 +126,8 @@ { BatchRowStatus.Uploading => @ Uploading, + BatchRowStatus.PostProcessing => @ + Post-Processing, BatchRowStatus.Done => @Done, BatchRowStatus.Failed => @Failed, _ => @Queued diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor index 00b0bda..9ce1244 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -69,6 +69,15 @@ Value="@_tracks[0].UploadPercent" aria-label="Uploading track" /> } + else if (_tracks[0].Status == BatchRowStatus.PostProcessing) + { + @* §3.1a: track is live + plays lossless; the Opus transcode runs in the background. + Indeterminate (no client-side progress for a server-side job) and non-blocking. *@ + + + Post-Processing (deriving Opus)… + + } } @@ -519,7 +528,13 @@ } } - row.Status = BatchRowStatus.Done; + // §3.1a: the byte transfer + server persist are done and the track is live and plays + // losslessly — the upload is successful HERE. The server then derives Opus on a + // background worker, so the row enters the visible Post-Processing phase rather than + // jumping straight to Done. This never blocks: the loop continues and the form may + // navigate away while rows sit in Post-Processing; the Releases view polls the durable + // Opus status to settle each one. + row.Status = BatchRowStatus.PostProcessing; succeeded++; } } diff --git a/DeepDrftManager/Components/Pages/Tracks/Releases.razor b/DeepDrftManager/Components/Pages/Tracks/Releases.razor index 6c9580e..66a125a 100644 --- a/DeepDrftManager/Components/Pages/Tracks/Releases.razor +++ b/DeepDrftManager/Components/Pages/Tracks/Releases.razor @@ -9,6 +9,7 @@ @inject ISnackbar Snackbar @inject ILogger Logger @inject NavigationManager NavigationManager +@implements IDisposable @attribute [Authorize] Releases — Deep DRFT Management @@ -51,11 +52,13 @@ Backfill High-res (@MissingHighResCount) } - @* Backfill-Opus (Phase 18.5). Unlike the two waveform buttons, the Opus derive runs on a - server-side background worker: the API decides which tracks lack Opus and enqueues them, so - there is no client-side "missing N" count to gate on and no per-track progress to render — the - action schedules the work and reports the (enqueued / skipped) outcome. Re-runnable: a second - press only enqueues tracks still missing Opus. Disabled while a press is in flight. *@ + @* Backfill-Opus (Phase 18.5 + 18.6 badge). The Opus derive runs on a server-side background + worker: pressing the button enqueues every track lacking a complete Opus artifact and reports + the (enqueued / skipped) outcome. The "missing N" badge (18.6) reads the same opus-status map + the page polls, giving visual parity with the two waveform backfill buttons — but unlike them, + the count is informational, not a per-track client loop (the work is scheduled, not driven from + here). The button stays pressable at N=0 (a no-op re-run is harmless); it only disables while a + press is in flight or another bulk run holds the page. *@ Backfill Opus + Backfill Opus (@MissingOpusCount) } @@ -160,6 +163,22 @@ private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile); private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes); + // EntryKey → HasOpus (a complete audio+sidecar derive). Loaded alongside the waveform status on init and + // re-read after a Backfill-Opus run so the "missing N" badge settles. Also the source the Post-Processing + // poll watches: a freshly uploaded track lands here with HasOpus=false and flips to true once the + // server-side background transcode finishes — the durable surface for the upload meter's Post-Processing + // phase after the form returns the admin to this view (§3.1a). + private IReadOnlyList _opusStatus = Array.Empty(); + + private int MissingOpusCount => _opusStatus.Count(s => !s.HasOpus); + + // Post-Processing poll: while any track is still missing Opus (a transcode in flight or not yet + // backfilled), re-read the opus-status map on an interval so the "missing N" badge and any per-track + // Post-Processing indicator settle without a manual refresh. Stops itself once nothing is missing, and is + // torn down on dispose. Non-blocking — it never gates an upload or a button; it only refreshes a count. + private const int OpusPollIntervalMs = 4000; + private CancellationTokenSource? _opusPollCts; + // Local state for the parent-owned "Generate All Profiles" bulk run. private bool _bulkRunning; private int _bulkTotal; @@ -195,9 +214,81 @@ _waveformStatus = result.Success && result.Value is not null ? result.Value : Array.Empty(); + + await RefreshOpusStatusAsync(); StateHasChanged(); } + /// + /// Re-read the per-track Opus derive status and (re)arm the Post-Processing poll when work is still + /// pending. Called on init, after a Backfill-Opus run, and from the poll itself. Best-effort: a failed + /// fetch leaves the previous map in place rather than zeroing the badge on a transient API blip. Does not + /// call StateHasChanged itself — callers batch it with their own render (the poll path renders explicitly). + /// + private async Task RefreshOpusStatusAsync() + { + var opusResult = await CmsTrackService.GetOpusStatusAsync(); + if (opusResult.Success && opusResult.Value is not null) + { + _opusStatus = opusResult.Value; + } + + if (MissingOpusCount > 0) + { + EnsureOpusPollRunning(); + } + } + + // Start the Post-Processing poll if it is not already running. The loop re-reads opus-status every + // OpusPollIntervalMs and renders; it exits as soon as nothing is missing (or on cancel/dispose). Guarded + // by a non-null CTS so overlapping callers (init + backfill) cannot start two loops. + private void EnsureOpusPollRunning() + { + if (_opusPollCts is not null) + { + return; + } + + _opusPollCts = new CancellationTokenSource(); + _ = PollOpusStatusAsync(_opusPollCts.Token); + } + + private async Task PollOpusStatusAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && MissingOpusCount > 0) + { + await Task.Delay(OpusPollIntervalMs, ct); + + var result = await CmsTrackService.GetOpusStatusAsync(); + if (result.Success && result.Value is not null) + { + _opusStatus = result.Value; + await InvokeAsync(StateHasChanged); + } + } + } + catch (OperationCanceledException) + { + // Expected on dispose / navigation away — nothing to do. + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Opus Post-Processing poll stopped on an unexpected error."); + } + finally + { + _opusPollCts?.Dispose(); + _opusPollCts = null; + } + } + + public void Dispose() + { + _opusPollCts?.Cancel(); + } + // Invalidates the cached per-track waveform status on all embedded grids so the next row expand // re-fetches fresh data. Called after each catalogue-wide bulk run so already-expanded rows // reflect the new waveform state on the next expand interaction. @@ -338,6 +429,10 @@ return; } + // Re-read the status map so the "missing N" badge reflects the just-enqueued work and the + // Post-Processing poll arms to watch the transcodes settle from N→0 as each finishes. + await RefreshOpusStatusAsync(); + var (enqueued, skipped) = (result.Value.Enqueued, result.Value.Skipped); if (enqueued == 0) { diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index d3c40e3..622ba31 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -804,6 +804,50 @@ public class CmsTrackService : ICmsTrackService } } + public async Task> GetOpusStatusAsync(CancellationToken ct = default) + { + var client = _httpClientFactory.CreateClient(ContentCmsClientName); + + HttpResponseMessage response; + try + { + response = await client.GetAsync("api/track/opus-status", ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Content API call failed for Opus status"); + return ResultContainer.CreateFailResult("Content API is unreachable."); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Content API Opus status failed: {Status}", (int)response.StatusCode); + return ResultContainer.CreateFailResult("Failed to load Opus status."); + } + + OpusStatusDto[]? status; + try + { + status = await response.Content.ReadFromJsonAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize Opus status from Content API response"); + return ResultContainer.CreateFailResult("Content API returned an unexpected response."); + } + + if (status is null) + { + _logger.LogError("Content API returned a null Opus status list"); + return ResultContainer.CreateFailResult("Content API returned an empty response."); + } + + return ResultContainer.CreatePassResult(status); + } + } + public async Task>> GetReleasesAsync(CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index f1452eb..57e1c36 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -161,6 +161,14 @@ public interface ICmsTrackService /// Task> BackfillOpusAsync(CancellationToken ct = default); + /// + /// Fetch per-track Opus derive status from GET api/track/opus-status (Phase 18.6) for the CMS + /// Post-Processing surfaces. Unpaged — the admin catalogue is small. Each row's HasOpus is true only + /// when the track carries a complete Opus artifact (audio + sidecar). Drives the Backfill-Opus "missing N" + /// badge and the post-upload Post-Processing poll. Idempotent read — safe to poll on an interval. + /// + Task> GetOpusStatusAsync(CancellationToken ct = default); + /// Returns all releases with track counts from GET api/track/albums. Task>> GetReleasesAsync(CancellationToken ct = default); diff --git a/DeepDrftModels/DTOs/OpusStatusDto.cs b/DeepDrftModels/DTOs/OpusStatusDto.cs new file mode 100644 index 0000000..a6edb40 --- /dev/null +++ b/DeepDrftModels/DTOs/OpusStatusDto.cs @@ -0,0 +1,19 @@ +namespace DeepDrftModels.DTOs; + +/// +/// Per-track Opus derive status for the CMS Post-Processing surfaces (Phase 18.6). Mirrors +/// : one row per track, flagging whether the track already carries a +/// complete Opus artifact. "Complete" means BOTH the Opus audio bytes AND the seek/setup +/// sidecar are present in the track-opus vault — a half-derived track (audio without sidecar) is +/// unseekable and counts as missing, so the Backfill-Opus pass re-derives it. is the +/// vault key the per-track enqueue trigger and the polling Post-Processing affordance key on. +/// +public class OpusStatusDto +{ + public long TrackId { get; set; } + public string EntryKey { get; set; } = string.Empty; + public string TrackName { get; set; } = string.Empty; + + /// True only when both the Opus audio and the seek/setup sidecar are stored (a complete derive). + public bool HasOpus { get; set; } +} From 77c6c42c9474511debd523477ec5a5bb81008fbb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 14:17:34 -0400 Subject: [PATCH 10/54] remediate: replace eval cookie writes with safe JS helper + add tests (18.6 Track A) Both SettingsCookieService and DarkModeCookieService now call window.DeepDrftSettings.setCookie (new Interop/settings/settings.ts) instead of eval. New tests cover SettingsServiceBase parse/format round-trip and the PreferenceAwareStreamingPlayerService invariant (Lossless skips probe; LowData inherits base). --- .../Services/DarkModeCookieService.cs | 4 +- .../Services/SettingsCookieService.cs | 4 +- DeepDrftPublic/Components/App.razor | 1 + DeepDrftPublic/Interop/settings/settings.ts | 33 +++ ...ferenceAwareStreamingPlayerServiceTests.cs | 209 ++++++++++++++++++ DeepDrftTests/SettingsServiceBaseTests.cs | 73 ++++++ 6 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 DeepDrftPublic/Interop/settings/settings.ts create mode 100644 DeepDrftTests/PreferenceAwareStreamingPlayerServiceTests.cs create mode 100644 DeepDrftTests/SettingsServiceBaseTests.cs diff --git a/DeepDrftPublic.Client/Services/DarkModeCookieService.cs b/DeepDrftPublic.Client/Services/DarkModeCookieService.cs index 177894e..30e6b2f 100644 --- a/DeepDrftPublic.Client/Services/DarkModeCookieService.cs +++ b/DeepDrftPublic.Client/Services/DarkModeCookieService.cs @@ -14,9 +14,7 @@ public class DarkModeCookieService(DarkModeSettings darkModeSetting, IJSRuntime public async ValueTask SetDarkModeAsync(bool isDarkMode) { - var expires = DateTime.UtcNow.AddDays(EXPIRY_DAYS).ToString("R"); - await js.InvokeVoidAsync("eval", - $"document.cookie = '{COOKIE_NAME}={isDarkMode.ToString().ToLower()}; expires={expires}; path=/; SameSite=Lax'"); + await js.InvokeVoidAsync("DeepDrftSettings.setCookie", COOKIE_NAME, isDarkMode.ToString().ToLower(), EXPIRY_DAYS); darkModeSetting.IsDarkMode = isDarkMode; } } \ No newline at end of file diff --git a/DeepDrftPublic.Client/Services/SettingsCookieService.cs b/DeepDrftPublic.Client/Services/SettingsCookieService.cs index e58f08b..d0bbce7 100644 --- a/DeepDrftPublic.Client/Services/SettingsCookieService.cs +++ b/DeepDrftPublic.Client/Services/SettingsCookieService.cs @@ -27,8 +27,6 @@ public class SettingsCookieService(PublicSiteSettings settings, IJSRuntime js) : private async ValueTask WriteCookieAsync(string name, string value) { - var expires = DateTime.UtcNow.AddDays(ExpiryDays).ToString("R"); - await js.InvokeVoidAsync("eval", - $"document.cookie = '{name}={value}; expires={expires}; path=/; SameSite=Lax'"); + await js.InvokeVoidAsync("DeepDrftSettings.setCookie", name, value, ExpiryDays); } } diff --git a/DeepDrftPublic/Components/App.razor b/DeepDrftPublic/Components/App.razor index 851143b..4576b1f 100644 --- a/DeepDrftPublic/Components/App.razor +++ b/DeepDrftPublic/Components/App.razor @@ -24,6 +24,7 @@ diff --git a/DeepDrftPublic/Interop/telemetry/anonid.ts b/DeepDrftPublic/Interop/session/anonid.ts similarity index 94% rename from DeepDrftPublic/Interop/telemetry/anonid.ts rename to DeepDrftPublic/Interop/session/anonid.ts index 60457e1..0091b5a 100644 --- a/DeepDrftPublic/Interop/telemetry/anonid.ts +++ b/DeepDrftPublic/Interop/session/anonid.ts @@ -8,7 +8,8 @@ * iframe) it returns null rather than throwing, and the caller simply sends no anonId. Over-counting is * the known, accepted direction of error (§3). * - * Exposed on window.DeepDrftAnonId; imported once in App.razor alongside the audio engine and beacon. + * Exposed on window.DeepDrftAnonId; served from js/session/anonid.js, imported once in App.razor + * alongside the audio engine and the unload-lifecycle module. */ const STORAGE_KEY = 'deepdrft.anonId'; diff --git a/DeepDrftPublic/Interop/telemetry/beacon.ts b/DeepDrftPublic/Interop/session/lifecycle.ts similarity index 65% rename from DeepDrftPublic/Interop/telemetry/beacon.ts rename to DeepDrftPublic/Interop/session/lifecycle.ts index 5bf8264..585a0a7 100644 --- a/DeepDrftPublic/Interop/telemetry/beacon.ts +++ b/DeepDrftPublic/Interop/session/lifecycle.ts @@ -1,10 +1,16 @@ /** - * Telemetry beacon interop (Phase 16 §2.2). A thin wrapper over navigator.sendBeacon for fire-and-forget - * play/share events, plus a page-unload handler that lets the player close an open play session as the - * tab goes away. sendBeacon (not fetch) is the load-bearing choice: it survives page unload, where a - * fetch would be cancelled — exactly the tab-close edge case the play metric must still record. + * Page-lifecycle unload transport. A thin wrapper over navigator.sendBeacon for the single edge case where + * an awaited fetch cannot run: the page is being torn down (tab close, navigation, bfcache freeze, mobile + * backgrounding). It exposes a sendBeacon POST plus a page-unload handler that lets the player close an + * open play session as the tab goes away. sendBeacon (not fetch) is the load-bearing choice here: it + * survives page unload, where a fetch would be cancelled. * - * Exposed on window.DeepDrftBeacon; imported once in App.razor alongside the audio engine. + * Normal play closes (organic end / track-switch / stop) and all share events do NOT use this module — + * they go over a first-party same-origin HttpClient POST from C#, which privacy/tracking heuristics do not + * block. This module is named off the former telemetry/beacon path (DeepDrftLifecycle, served from + * js/session/lifecycle.js) so even this retained unload fallback is not caught by name-based blockers. + * + * Exposed on window.DeepDrftLifecycle; imported once in App.razor alongside the audio engine. */ // .NET interop type — a DotNetObjectReference the unload handler invokes back into. @@ -47,11 +53,11 @@ function wireUnloadOnce(): void { }); } -const DeepDrftBeacon = { +const DeepDrftLifecycle = { /** - * Queue a fire-and-forget POST of a small JSON body. Returns false if the browser refused to queue - * the beacon (e.g. over the per-origin byte budget) — callers ignore it; a dropped telemetry event - * is acceptable by design. + * Queue a fire-and-forget sendBeacon POST of a small JSON body, for the page-unload edge only. Returns + * false if the browser refused to queue the beacon (e.g. over the per-origin byte budget) — callers + * ignore it; a dropped telemetry event is acceptable by design. */ send: (url: string, json: string): boolean => { try { @@ -79,10 +85,10 @@ const DeepDrftBeacon = { declare global { interface Window { - DeepDrftBeacon: typeof DeepDrftBeacon; + DeepDrftLifecycle: typeof DeepDrftLifecycle; } } -window.DeepDrftBeacon = DeepDrftBeacon; +window.DeepDrftLifecycle = DeepDrftLifecycle; -export { DeepDrftBeacon }; +export { DeepDrftLifecycle }; diff --git a/DeepDrftTests/AnonIdPayloadTests.cs b/DeepDrftTests/AnonIdPayloadTests.cs index 1310f03..bc1481f 100644 --- a/DeepDrftTests/AnonIdPayloadTests.cs +++ b/DeepDrftTests/AnonIdPayloadTests.cs @@ -7,24 +7,38 @@ using Microsoft.JSInterop; namespace DeepDrftTests; /// -/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the beacon payloads emitted by +/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the event payloads emitted by /// and , and omitted when the provider has -/// no token. Both sinks serialize internally and dispatch through BeaconInterop → the -/// DeepDrftBeacon.send(url, json) JS call, so the assertions capture that JSON string off a fake -/// JS runtime and inspect the anonId field — the same bytes the browser would POST. +/// no token. After the transport-resilience split, normal play closes and shares serialize and POST over +/// the first-party , so those assertions capture the JSON off a fake poster. +/// The play sink's unload arm still serializes the same bytes through BeaconInterop → +/// DeepDrftLifecycle.send, asserted off a fake JS runtime — proving both arms carry the id. /// [TestFixture] public class AnonIdPayloadTests { - // Captures the JSON body of the most recent DeepDrftBeacon.send(url, json) invocation. The beacon is - // fire-and-forget (returns bool); other interop calls (unload registration) are tolerated and ignored. + // Captures the JSON body of the most recent first-party POST. The poster is fire-and-forget; the + // caller never reads its result. + private sealed class CapturingEventPoster : IEventPoster + { + public string? LastJson { get; private set; } + + public Task PostAsync(string url, string json) + { + LastJson = json; + return Task.CompletedTask; + } + } + + // Captures the JSON body of the most recent DeepDrftLifecycle.send(url, json) invocation (the unload + // arm). Other interop calls (unload registration) are tolerated and ignored. private sealed class CapturingJsRuntime : IJSRuntime { public string? LastJson { get; private set; } public ValueTask InvokeAsync(string identifier, object?[]? args) { - if (identifier == "DeepDrftBeacon.send" && args is { Length: 2 } && args[1] is string json) + if (identifier == "DeepDrftLifecycle.send" && args is { Length: 2 } && args[1] is string json) LastJson = json; return ValueTask.FromResult(default!); } @@ -33,6 +47,12 @@ public class AnonIdPayloadTests => InvokeAsync(identifier, args); } + // A no-op poster for the unload-arm test, where the beacon (not the poster) is the asserted transport. + private sealed class NoopEventPoster : IEventPoster + { + public Task PostAsync(string url, string json) => Task.CompletedTask; + } + private sealed class StubAnonIdProvider : IAnonIdProvider { public StubAnonIdProvider(string? current) => Current = current; @@ -64,61 +84,79 @@ public class AnonIdPayloadTests private static bool HasAnonIdProperty(string json) => FindAnonId(json).Present; - // A play emitted while the provider holds a token carries that token in the payload. + private static BeaconPlayEventSink PlaySink(IEventPoster poster, IJSRuntime js, string? anonId) + => new(poster, new BeaconInterop(js), new StubAnonIdProvider(anonId), new TestNavigationManager()); + + // --- Play sink, first-party fetch arm (normal close) --- + + // A play emitted while the provider holds a token carries that token in the fetch payload. [Test] - public void PlaySink_WithAnonId_IncludesItInPayload() + public async Task PlaySink_FetchArm_WithAnonId_IncludesItInPayload() { - var js = new CapturingJsRuntime(); - var sink = new BeaconPlayEventSink( - new BeaconInterop(js), new StubAnonIdProvider("listener-42"), new TestNavigationManager()); + var poster = new CapturingEventPoster(); + var sink = PlaySink(poster, new CapturingJsRuntime(), "listener-42"); - sink.EmitPlay("track-key", PlayBucket.Complete); + await sink.EmitPlayAsync("track-key", PlayBucket.Complete); - Assert.That(js.LastJson, Is.Not.Null); - Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-42")); + Assert.That(poster.LastJson, Is.Not.Null); + Assert.That(ReadAnonId(poster.LastJson!), Is.EqualTo("listener-42")); } // A play emitted when the provider has no token (storage unavailable / not warmed) omits anonId // entirely rather than sending anonId:null. [Test] - public void PlaySink_WithoutAnonId_OmitsItFromPayload() + public async Task PlaySink_FetchArm_WithoutAnonId_OmitsItFromPayload() + { + var poster = new CapturingEventPoster(); + var sink = PlaySink(poster, new CapturingJsRuntime(), null); + + await sink.EmitPlayAsync("track-key", PlayBucket.Partial); + + Assert.That(poster.LastJson, Is.Not.Null); + Assert.That(HasAnonIdProperty(poster.LastJson!), Is.False, "null anonId is omitted from the wire payload"); + } + + // --- Play sink, sendBeacon arm (page unload) --- + + // The unload arm serializes the same payload through sendBeacon, carrying the token too. + [Test] + public void PlaySink_UnloadArm_WithAnonId_IncludesItInPayload() { var js = new CapturingJsRuntime(); - var sink = new BeaconPlayEventSink( - new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager()); + var sink = PlaySink(new NoopEventPoster(), js, "listener-99"); - sink.EmitPlay("track-key", PlayBucket.Partial); + sink.EmitPlayOnUnload("track-key", PlayBucket.Complete); Assert.That(js.LastJson, Is.Not.Null); - Assert.That(HasAnonIdProperty(js.LastJson!), Is.False, "null anonId is omitted from the wire payload"); + Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-99")); } + // --- Share tracker (always first-party fetch) --- + // A share recorded while the provider holds a token carries it in the payload. [Test] public void ShareTracker_WithAnonId_IncludesItInPayload() { - var js = new CapturingJsRuntime(); - var tracker = new ShareTracker( - new BeaconInterop(js), new StubAnonIdProvider("listener-7"), new TestNavigationManager()); + var poster = new CapturingEventPoster(); + var tracker = new ShareTracker(poster, new StubAnonIdProvider("listener-7"), new TestNavigationManager()); tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link); - Assert.That(js.LastJson, Is.Not.Null); - Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-7")); + Assert.That(poster.LastJson, Is.Not.Null); + Assert.That(ReadAnonId(poster.LastJson!), Is.EqualTo("listener-7")); } // A share recorded with no token omits anonId from the payload. [Test] public void ShareTracker_WithoutAnonId_OmitsItFromPayload() { - var js = new CapturingJsRuntime(); - var tracker = new ShareTracker( - new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager()); + var poster = new CapturingEventPoster(); + var tracker = new ShareTracker(poster, new StubAnonIdProvider(null), new TestNavigationManager()); tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link); - Assert.That(js.LastJson, Is.Not.Null); - Assert.That(HasAnonIdProperty(js.LastJson!), Is.False); + Assert.That(poster.LastJson, Is.Not.Null); + Assert.That(HasAnonIdProperty(poster.LastJson!), Is.False); } // A JS runtime that throws on every call — models localStorage interop being unavailable (private diff --git a/DeepDrftTests/PlayTrackerTests.cs b/DeepDrftTests/PlayTrackerTests.cs index 8fd433c..92ba6c7 100644 --- a/DeepDrftTests/PlayTrackerTests.cs +++ b/DeepDrftTests/PlayTrackerTests.cs @@ -13,11 +13,27 @@ namespace DeepDrftTests; [TestFixture] public class PlayTrackerTests { - // Captures emitted plays so assertions read the (key, bucket) the tracker classified. + // Captures emitted plays so assertions read the (key, bucket) the tracker classified. The two arms are + // captured separately so a test can assert which transport a given close selected (fetch vs unload). + // Emitted folds both arms for the floor/bucket assertions that don't care about transport. private sealed class FakeSink : IPlayEventSink { public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new(); - public void EmitPlay(string trackEntryKey, PlayBucket bucket) => Emitted.Add((trackEntryKey, bucket)); + public List<(string Key, PlayBucket Bucket)> FetchEmitted { get; } = new(); + public List<(string Key, PlayBucket Bucket)> UnloadEmitted { get; } = new(); + + public Task EmitPlayAsync(string trackEntryKey, PlayBucket bucket) + { + FetchEmitted.Add((trackEntryKey, bucket)); + Emitted.Add((trackEntryKey, bucket)); + return Task.CompletedTask; + } + + public void EmitPlayOnUnload(string trackEntryKey, PlayBucket bucket) + { + UnloadEmitted.Add((trackEntryKey, bucket)); + Emitted.Add((trackEntryKey, bucket)); + } } private FakeSink _sink = null!; @@ -202,4 +218,34 @@ public class PlayTrackerTests PlaySession("t", duration: 100, highWater: 95); Assert.That(_sink.Emitted, Has.Count.EqualTo(2)); } + + // --- Transport-arm selection (telemetry transport-resilience) --- + + // A normal close (organic end / track-switch / stop) emits over the first-party fetch arm — the page + // is alive, so the awaitable HttpClient POST is the heuristic-safe transport. + [Test] + public void Close_NormalClose_EmitsOverFetchArm() + { + _tracker.OnPlaybackStarted("t"); + _tracker.SetDuration(100); + _tracker.OnProgress(95); + _tracker.Close(); // viaUnload defaults to false + + Assert.That(_sink.FetchEmitted, Has.Count.EqualTo(1)); + Assert.That(_sink.UnloadEmitted, Is.Empty); + } + + // The page-unload close emits over the sendBeacon arm — an awaited fetch would be cancelled as the + // page freezes, so this rare edge keeps the beacon. + [Test] + public void Close_ViaUnload_EmitsOverBeaconArm() + { + _tracker.OnPlaybackStarted("t"); + _tracker.SetDuration(100); + _tracker.OnProgress(95); + _tracker.Close(viaUnload: true); + + Assert.That(_sink.UnloadEmitted, Has.Count.EqualTo(1)); + Assert.That(_sink.FetchEmitted, Is.Empty); + } } diff --git a/DeepDrftTests/ShareTrackerTests.cs b/DeepDrftTests/ShareTrackerTests.cs index f18c9eb..5d6b18e 100644 --- a/DeepDrftTests/ShareTrackerTests.cs +++ b/DeepDrftTests/ShareTrackerTests.cs @@ -1,33 +1,27 @@ using DeepDrftModels.Enums; using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; -using Microsoft.JSInterop; -using Microsoft.JSInterop.Infrastructure; namespace DeepDrftTests; /// /// Unit tests for the Phase 16 share tracker (): the per-(target,channel) /// debounce (§1b — at most one event per target+channel per 60s window per session). The tracker fires -/// through a beacon that wraps ; the tests use a no-op JS runtime (the send is +/// through the first-party ; the tests use a no-op poster (the POST is /// fire-and-forget and its outcome is irrelevant) and assert on the debounce decision via the bool the /// recorder returns — true when an event fired, false when debounced. /// [TestFixture] public class ShareTrackerTests { - // sendBeacon interop is fire-and-forget; the tracker never reads the result, so a no-op runtime that - // returns default for any invocation is sufficient to exercise the debounce logic. - private sealed class NoopJsRuntime : IJSRuntime + // The first-party POST is fire-and-forget; the tracker never reads the result, so a no-op poster that + // completes immediately is sufficient to exercise the debounce logic. + private sealed class NoopEventPoster : IEventPoster { - public ValueTask InvokeAsync(string identifier, object?[]? args) - => ValueTask.FromResult(default!); - - public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) - => ValueTask.FromResult(default!); + public Task PostAsync(string url, string json) => Task.CompletedTask; } - // Minimal NavigationManager so the tracker can compose the (unused-in-test) beacon URL. + // Minimal NavigationManager so the tracker can compose the (unused-in-test) event URL. private sealed class TestNavigationManager : NavigationManager { public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/"); @@ -49,7 +43,7 @@ public class ShareTrackerTests [SetUp] public void SetUp() => _tracker = new ShareTracker( - new BeaconInterop(new NoopJsRuntime()), + new NoopEventPoster(), new StubAnonIdProvider("anon-1"), new TestNavigationManager()); From 8d1272e36fad4ffe627a86a3e66d34df23fd4b4e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 26 Jun 2026 22:22:05 -0400 Subject: [PATCH 54/54] docs: fix stale anonid.ts path after telemetry module rename --- COMPLETED.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COMPLETED.md b/COMPLETED.md index 52ef8fe..c96c7c6 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -324,7 +324,7 @@ _Note: Two distinct efforts share the "Phase 18" label — phase numbers are org - **Shape:** - **Client — `IAnonIdProvider` / `AnonIdProvider`** (`DeepDrftPublic.Client/Services/IAnonIdProvider.cs`, `AnonIdProvider.cs`): `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via JS interop — idempotent, best-effort, never throws). `AnonIdProvider` is the production implementation over the `window.DeepDrftAnonId.get` interop call. Degrades to null when `localStorage` is unavailable (private mode / blocked / partitioned iframe) — missing id is the accepted graceful path; over-counting is the direction of error (§3). Scoped (per-session cache); the token itself outlives the session in `localStorage`. - - **Client — TypeScript interop** (`DeepDrftPublic/Interop/telemetry/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable. + - **Client — TypeScript interop** (`DeepDrftPublic/Interop/session/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable. - **Client — `BeaconPlayEventSink`** (`DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId`. Null id produces an anonId-less payload (the field is omitted from the wire JSON entirely via `WhenWritingNull` — the API treats absent and null identically). - **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` at share time and sets `ShareEventDto.AnonId`. Same null-omit posture as the play sink. - **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `TryNormalizeAnonId` helper on both `POST api/event/play` and `POST api/event/share` — whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars is rejected with `400 Bad Request` rather than truncated (truncation would collide distinct listeners onto one prefix); valid tokens are trimmed and passed through.