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.
This commit is contained in:
@@ -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<StreamingAudioPlayerService> _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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>InitializeStreaming</c> builds it.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
protected virtual async Task<AudioFormat> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user