Files
deepdrft/DeepDrftPublic.Client/Services/AudioInteropService.cs
T
daniel-c-harvey d686fe48ce Apply stream-quality change live by reloading at current position
Finish the Settings "Apply" behavior so changing streaming quality mid-track
switches format immediately instead of only persisting the cookie for the next
play.

- SettingsMenu reads the AudioPlayerProvider cascade and threads the player into
  StreamQualitySetting as an explicit parameter (the MudMenu panel portals to
  MudPopoverProvider, outside the cascade scope, so a [CascadingParameter] there
  lands null). StreamQualitySetting's Apply persists the cookie, then asks the
  player to reload preserving position.

- Add a "load at timestamp" path to the player rather than restart-from-0-then-
  seek (which audibly played the start and raced the just-started scheduler into
  a crash). ReloadPreservingPositionAsync loads the track in the newly-resolved
  format beginning DIRECTLY at the saved position:
    * new JS resolveStreamOffset(position) resolves the file-absolute byte offset
      with no playback/buffer state (Opus from its sidecar immediately; WAV after
      a header probe),
    * StartFromPositionAsync converges onto the existing seek/refill loop
      (RunSegmentedStreamAsync with a non-null seekPosition) so the decoder
      reinitializes for a header-less Range continuation and starts playback at
      the target,
    * ProbeHeaderAsync feeds the byte-0 segment to the decoder WITHOUT starting
      playback until the WAV header parses (bounded by 256 KB); the probe buffers
      are dropped by the continuation's clearForSeek, so nothing is audible.

- IStreamingPlayerService gains ReloadPreservingPositionAsync; the QueueService
  test fake implements it.
2026-06-24 22:55:03 -04:00

480 lines
17 KiB
C#

using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services;
public class AudioInteropService : IAsyncDisposable
{
private readonly IJSRuntime _jsRuntime;
private readonly Dictionary<string, IDisposable> _callbacks = new();
public AudioInteropService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<AudioOperationResult> CreatePlayerAsync(string playerId)
{
try
{
if (!await WaitForModuleReadyAsync())
{
return new AudioOperationResult
{
Success = false,
Error = "Audio engine failed to load (timed out waiting for DeepDrftAudio module)."
};
}
var result = await _jsRuntime.InvokeAsync<AudioOperationResult>("DeepDrftAudio.createPlayer", playerId);
return result;
}
catch (Exception ex)
{
return new AudioOperationResult { Success = false, Error = ex.Message };
}
}
// The audio engine is loaded as an ES module via a deferred `import(...)` in
// App.razor, so on a slow WASM boot or cache miss it may not have executed by
// the time the first track is selected. Poll its readiness probe (tolerating
// the window before `window.DeepDrftAudio` even exists, when the call throws)
// up to a short timeout before the first interop call.
private async Task<bool> WaitForModuleReadyAsync()
{
const int timeoutMs = 5000;
const int pollIntervalMs = 50;
var elapsed = 0;
while (elapsed < timeoutMs)
{
try
{
if (await _jsRuntime.InvokeAsync<bool>("DeepDrftAudio.isReady"))
return true;
}
catch
{
// window.DeepDrftAudio not attached yet — keep polling until timeout.
}
await Task.Delay(pollIntervalMs);
elapsed += pollIntervalMs;
}
return false;
}
// Streaming methods
public async Task<AudioOperationResult> InitializeStreaming(string playerId, long totalStreamLength, string contentType)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType);
}
/// <summary>
/// Probes whether this browser can stream-decode Ogg Opus via WebCodecs (<c>AudioDecoder</c> +
/// <c>codec:'opus'</c>; Safari &lt; 16.4 / older Firefox 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 <c>false</c>
/// (assume incapable) so an interop error can never silence playback.
/// </summary>
public async Task<bool> CanDecodeOggOpus()
{
try
{
return await _jsRuntime.InvokeAsync<bool>("DeepDrftAudio.canDecodeOggOpus");
}
catch
{
return false;
}
}
/// <summary>
/// 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; <see cref="InitializeStreaming"/> applies them when it builds the Opus decoder.
/// Must be called before <see cref="InitializeStreaming"/> 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.
/// </summary>
public async Task<AudioOperationResult> SetOpusSidecar(string playerId, byte[] sidecarBytes)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setOpusSidecar", playerId, sidecarBytes);
}
public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk)
{
return await InvokeJsAsync<StreamingResult>("DeepDrftAudio.processStreamingChunk", playerId, audioChunk);
}
public async Task<AudioOperationResult> StartStreamingPlayback(string playerId)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.startStreamingPlayback", playerId);
}
public async Task<AudioOperationResult> MarkStreamCompleteAsync(string playerId)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.markStreamComplete", playerId);
}
public async Task<AudioOperationResult> EnsureAudioContextReady(string playerId)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.ensureAudioContextReady", playerId);
}
public async Task<AudioOperationResult> PlayAsync(string playerId)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.play", playerId);
}
public async Task<AudioOperationResult> PauseAsync(string playerId)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.pause", playerId);
}
public async Task<AudioOperationResult> StopAsync(string playerId)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stop", playerId);
}
public async Task<AudioOperationResult> UnloadAsync(string playerId)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.unload", playerId);
}
public async Task<SeekResult> SeekAsync(string playerId, double position)
{
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.seek", playerId, position);
}
/// <summary>
/// Resolve the file-absolute byte offset to begin a stream at <paramref name="position"/> with no
/// active playback or buffered audio — the "load at timestamp" seam (Phase 18 wave 18.6 format switch).
/// Returns <see cref="SeekResult.ByteOffset"/> on success; <see cref="AudioOperationResult.Success"/> is
/// false when the decoder cannot yet resolve an offset (e.g. a WAV stream whose header has not been
/// parsed), so the caller can feed header bytes and retry.
/// </summary>
public async Task<SeekResult> ResolveStreamOffsetAsync(string playerId, double position)
{
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.resolveStreamOffset", playerId, position);
}
// New methods for seek-beyond-buffer support
public async Task<double> GetBufferedDuration(string playerId)
{
try
{
return await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.getBufferedDuration", playerId);
}
catch
{
return 0;
}
}
/// <summary>
/// Phase 21.2a back-pressure poll: ask whether the scheduler is still over its forward
/// high/low-water band. The read loop calls this only WHILE already throttled, to learn when it
/// may resume reading — the steady-state loop reads the piggybacked <c>ProductionPaused</c> flag
/// off each chunk result instead. Defaults to false on any interop failure so a torn-down player
/// never wedges a loop that is exiting anyway.
/// </summary>
public async Task<bool> IsProductionPaused(string playerId)
{
try
{
return await _jsRuntime.InvokeAsync<bool>("DeepDrftAudio.isProductionPaused", playerId);
}
catch
{
return false;
}
}
public async Task<AudioOperationResult> ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition);
}
/// <summary>
/// Phase 21.3 / AC6 clean-failure recovery: after a window-miss refill (seek-back past the
/// retained tail) fails its Range fetch or reinit, halt the starved scheduler and leave the
/// player paused-but-loaded at <paramref name="seekPosition"/> so no silent false end fires and a
/// retry is possible. Routes through <see cref="InvokeJsAsync{T}"/> so an interop failure during
/// recovery still yields a failure result rather than throwing into the seek path.
/// </summary>
public async Task<AudioOperationResult> RecoverFromFailedRefill(string playerId, double seekPosition)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.recoverFromFailedRefill", playerId, seekPosition);
}
public async Task<AudioOperationResult> SetVolumeAsync(string playerId, double volume)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setVolume", playerId, volume);
}
public async Task<double> GetCurrentTimeAsync(string playerId)
{
try
{
return await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.getCurrentTime", playerId);
}
catch (Exception)
{
return 0;
}
}
public async Task<AudioPlayerState?> GetStateAsync(string playerId)
{
try
{
return await _jsRuntime.InvokeAsync<AudioPlayerState>("DeepDrftAudio.getState", playerId);
}
catch (Exception)
{
return null;
}
}
public async Task<AudioOperationResult> SetOnProgressCallbackAsync(string playerId, Func<double, Task> callback)
{
return await SetCallbackAsync(playerId, "_progress", "setOnProgressCallback", "OnProgressCallback",
wrapper => wrapper.OnProgress = callback);
}
public async Task<AudioOperationResult> SetOnEndCallbackAsync(string playerId, Func<Task> callback)
{
return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback",
wrapper => wrapper.OnEnd = callback);
}
// Spectrum analyzer methods
public async Task<double[]?> GetSpectrumDataAsync(string playerId)
{
try
{
return await _jsRuntime.InvokeAsync<double[]>("DeepDrftAudio.getSpectrumData", playerId);
}
catch
{
return null;
}
}
public async Task<AudioOperationResult> SetSpectrumHighPassAsync(string playerId, double freq)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumHighPass", playerId, freq);
}
public async Task<AudioOperationResult> SetSpectrumLowPassAsync(string playerId, double freq)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumLowPass", playerId, freq);
}
public async Task<AudioOperationResult> SetSpectrumSlopeAsync(string playerId, double dbPerDecade)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumSlope", playerId, dbPerDecade);
}
public async Task<AudioOperationResult> StartSpectrumAnimationAsync(string playerId, string callbackId, Func<double[], Task> callback)
{
try
{
var callbackWrapper = new SpectrumCallback { OnData = callback };
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
_callbacks[playerId + "_spectrum_" + callbackId] = dotNetObjectRef;
return await _jsRuntime.InvokeAsync<AudioOperationResult>(
"DeepDrftAudio.startSpectrumAnimation",
playerId, callbackId, dotNetObjectRef, "OnSpectrumDataCallback");
}
catch (Exception ex)
{
return new AudioOperationResult { Success = false, Error = ex.Message };
}
}
public async Task<AudioOperationResult> StopSpectrumAnimationAsync(string playerId, string callbackId)
{
var key = playerId + "_spectrum_" + callbackId;
if (_callbacks.TryGetValue(key, out var callback))
{
callback?.Dispose();
_callbacks.Remove(key);
}
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId);
}
public async Task<AudioOperationResult> StartLevelAnimationAsync(string playerId, string callbackId, Func<double, Task> callback)
{
try
{
var callbackWrapper = new LevelCallback { OnData = callback };
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
_callbacks[playerId + "_level_" + callbackId] = dotNetObjectRef;
return await _jsRuntime.InvokeAsync<AudioOperationResult>(
"DeepDrftAudio.startLevelAnimation",
playerId, callbackId, dotNetObjectRef, "OnLevelDataCallback");
}
catch (Exception ex)
{
return new AudioOperationResult { Success = false, Error = ex.Message };
}
}
public async Task<AudioOperationResult> StopLevelAnimationAsync(string playerId, string callbackId)
{
var key = playerId + "_level_" + callbackId;
if (_callbacks.TryGetValue(key, out var callback))
{
callback?.Dispose();
_callbacks.Remove(key);
}
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopLevelAnimation", playerId, callbackId);
}
public async Task<AudioOperationResult> DisposePlayerAsync(string playerId)
{
CleanupPlayerCallbacks(playerId);
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.disposePlayer", playerId);
}
// TODO: The typeof(T) switch below requires updating whenever a new result type is added.
// Consider introducing a shared marker interface (e.g. IAudioResult with a static factory
// method) so InvokeJsAsync can construct the failure result generically without a type switch.
private async Task<T> InvokeJsAsync<T>(string identifier, params object[] args)
{
try
{
return await _jsRuntime.InvokeAsync<T>(identifier, args);
}
catch (Exception ex)
{
if (typeof(T) == typeof(AudioOperationResult))
return (T)(object)new AudioOperationResult { Success = false, Error = ex.Message };
if (typeof(T) == typeof(StreamingResult))
return (T)(object)new StreamingResult { Success = false, Error = ex.Message };
if (typeof(T) == typeof(SeekResult))
return (T)(object)new SeekResult { Success = false, Error = ex.Message };
throw;
}
}
private async Task<AudioOperationResult> SetCallbackAsync(string playerId, string suffix, string jsMethod, string callbackMethod, Action<AudioPlayerCallback> configureCallback)
{
try
{
var callbackWrapper = new AudioPlayerCallback();
configureCallback(callbackWrapper);
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
_callbacks[playerId + suffix] = dotNetObjectRef;
return await _jsRuntime.InvokeAsync<AudioOperationResult>($"DeepDrftAudio.{jsMethod}",
playerId, dotNetObjectRef, callbackMethod);
}
catch (Exception ex)
{
return new AudioOperationResult { Success = false, Error = ex.Message };
}
}
private void CleanupPlayerCallbacks(string playerId)
{
var keysToRemove = _callbacks.Keys.Where(k => k.StartsWith(playerId + "_")).ToList();
foreach (var key in keysToRemove)
{
_callbacks[key]?.Dispose();
_callbacks.Remove(key);
}
}
public async ValueTask DisposeAsync()
{
foreach (var callback in _callbacks.Values)
{
callback?.Dispose();
}
_callbacks.Clear();
}
}
public class AudioPlayerCallback
{
public Func<double, Task>? OnProgress { get; set; }
public Func<Task>? OnEnd { get; set; }
[JSInvokable]
public async Task OnProgressCallback(double currentTime)
{
if (OnProgress != null)
await OnProgress(currentTime);
}
[JSInvokable]
public async Task OnEndCallback()
{
if (OnEnd != null)
await OnEnd();
}
}
public class SpectrumCallback
{
public Func<double[], Task>? OnData { get; set; }
[JSInvokable]
public async Task OnSpectrumDataCallback(double[] data)
{
if (OnData != null)
await OnData(data);
}
}
public class LevelCallback
{
public Func<double, Task>? OnData { get; set; }
[JSInvokable]
public async Task OnLevelDataCallback(double db)
{
if (OnData != null)
await OnData(db);
}
}
public class AudioOperationResult
{
public bool Success { get; set; }
public string? Error { get; set; }
}
public class SeekResult : AudioOperationResult
{
public bool SeekBeyondBuffer { get; set; }
public long ByteOffset { get; set; }
}
public class StreamingResult : AudioOperationResult
{
public bool CanStartStreaming { get; set; }
public bool HeaderParsed { get; set; }
public int BufferCount { get; set; }
public double? Duration { get; set; } // Duration in seconds calculated from WAV header
// Phase 21.2a back-pressure: true when the scheduler's forward decoded fill is over the
// high-water mark and the C# read loop should stop calling ReadAsync until it drains. Read off
// the chunk result the loop already awaits — no extra interop hop in the unthrottled steady state.
public bool ProductionPaused { get; set; }
}
public class AudioPlayerState
{
public bool IsPlaying { get; set; }
public bool IsPaused { get; set; }
public double CurrentTime { get; set; }
public double Duration { get; set; }
public double Volume { get; set; }
public double LoadProgress { get; set; }
}