From 2bde4908d7183d6511b12ea16b6ad2855ca33a8b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 12:39:13 -0400 Subject: [PATCH] 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); }