using Microsoft.JSInterop; namespace DeepDrftPublic.Client.Services; public class AudioInteropService : IAsyncDisposable { private readonly IJSRuntime _jsRuntime; private readonly Dictionary _callbacks = new(); public AudioInteropService(IJSRuntime jsRuntime) { _jsRuntime = jsRuntime; } public async Task 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("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 WaitForModuleReadyAsync() { const int timeoutMs = 5000; const int pollIntervalMs = 50; var elapsed = 0; while (elapsed < timeoutMs) { try { if (await _jsRuntime.InvokeAsync("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 InitializeStreaming(string playerId, long totalStreamLength, string contentType) { return await InvokeJsAsync("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType); } /// /// Probes whether this browser can stream-decode Ogg Opus via WebCodecs (AudioDecoder + /// codec:'opus'; Safari < 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 false /// (assume incapable) so an interop error can never silence playback. /// public async Task CanDecodeOggOpus() { 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); } public async Task StartStreamingPlayback(string playerId) { return await InvokeJsAsync("DeepDrftAudio.startStreamingPlayback", playerId); } public async Task MarkStreamCompleteAsync(string playerId) { return await InvokeJsAsync("DeepDrftAudio.markStreamComplete", playerId); } public async Task EnsureAudioContextReady(string playerId) { return await InvokeJsAsync("DeepDrftAudio.ensureAudioContextReady", playerId); } public async Task PlayAsync(string playerId) { return await InvokeJsAsync("DeepDrftAudio.play", playerId); } public async Task PauseAsync(string playerId) { return await InvokeJsAsync("DeepDrftAudio.pause", playerId); } public async Task StopAsync(string playerId) { return await InvokeJsAsync("DeepDrftAudio.stop", playerId); } public async Task UnloadAsync(string playerId) { return await InvokeJsAsync("DeepDrftAudio.unload", playerId); } public async Task SeekAsync(string playerId, double position) { return await InvokeJsAsync("DeepDrftAudio.seek", playerId, position); } /// /// Resolve the file-absolute byte offset to begin a stream at with no /// active playback or buffered audio — the "load at timestamp" seam (Phase 18 wave 18.6 format switch). /// Returns on 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. /// public async Task ResolveStreamOffsetAsync(string playerId, double position) { return await InvokeJsAsync("DeepDrftAudio.resolveStreamOffset", playerId, position); } // New methods for seek-beyond-buffer support public async Task GetBufferedDuration(string playerId) { try { return await _jsRuntime.InvokeAsync("DeepDrftAudio.getBufferedDuration", playerId); } catch { return 0; } } /// /// 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 ProductionPaused 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. /// public async Task IsProductionPaused(string playerId) { try { return await _jsRuntime.InvokeAsync("DeepDrftAudio.isProductionPaused", playerId); } catch { return false; } } public async Task ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition) { return await InvokeJsAsync("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition); } /// /// 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 so no silent false end fires and a /// retry is possible. Routes through so an interop failure during /// recovery still yields a failure result rather than throwing into the seek path. /// public async Task RecoverFromFailedRefill(string playerId, double seekPosition) { return await InvokeJsAsync("DeepDrftAudio.recoverFromFailedRefill", playerId, seekPosition); } public async Task SetVolumeAsync(string playerId, double volume) { return await InvokeJsAsync("DeepDrftAudio.setVolume", playerId, volume); } public async Task GetCurrentTimeAsync(string playerId) { try { return await _jsRuntime.InvokeAsync("DeepDrftAudio.getCurrentTime", playerId); } catch (Exception) { return 0; } } public async Task GetStateAsync(string playerId) { try { return await _jsRuntime.InvokeAsync("DeepDrftAudio.getState", playerId); } catch (Exception) { return null; } } public async Task SetOnProgressCallbackAsync(string playerId, Func callback) { return await SetCallbackAsync(playerId, "_progress", "setOnProgressCallback", "OnProgressCallback", wrapper => wrapper.OnProgress = callback); } public async Task SetOnEndCallbackAsync(string playerId, Func callback) { return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback", wrapper => wrapper.OnEnd = callback); } // Spectrum analyzer methods public async Task GetSpectrumDataAsync(string playerId) { try { return await _jsRuntime.InvokeAsync("DeepDrftAudio.getSpectrumData", playerId); } catch { return null; } } public async Task SetSpectrumHighPassAsync(string playerId, double freq) { return await InvokeJsAsync("DeepDrftAudio.setSpectrumHighPass", playerId, freq); } public async Task SetSpectrumLowPassAsync(string playerId, double freq) { return await InvokeJsAsync("DeepDrftAudio.setSpectrumLowPass", playerId, freq); } public async Task SetSpectrumSlopeAsync(string playerId, double dbPerDecade) { return await InvokeJsAsync("DeepDrftAudio.setSpectrumSlope", playerId, dbPerDecade); } public async Task StartSpectrumAnimationAsync(string playerId, string callbackId, Func callback) { try { var callbackWrapper = new SpectrumCallback { OnData = callback }; var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper); _callbacks[playerId + "_spectrum_" + callbackId] = dotNetObjectRef; return await _jsRuntime.InvokeAsync( "DeepDrftAudio.startSpectrumAnimation", playerId, callbackId, dotNetObjectRef, "OnSpectrumDataCallback"); } catch (Exception ex) { return new AudioOperationResult { Success = false, Error = ex.Message }; } } public async Task 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("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId); } public async Task StartLevelAnimationAsync(string playerId, string callbackId, Func callback) { try { var callbackWrapper = new LevelCallback { OnData = callback }; var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper); _callbacks[playerId + "_level_" + callbackId] = dotNetObjectRef; return await _jsRuntime.InvokeAsync( "DeepDrftAudio.startLevelAnimation", playerId, callbackId, dotNetObjectRef, "OnLevelDataCallback"); } catch (Exception ex) { return new AudioOperationResult { Success = false, Error = ex.Message }; } } public async Task 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("DeepDrftAudio.stopLevelAnimation", playerId, callbackId); } public async Task DisposePlayerAsync(string playerId) { CleanupPlayerCallbacks(playerId); return await InvokeJsAsync("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 InvokeJsAsync(string identifier, params object[] args) { try { return await _jsRuntime.InvokeAsync(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 SetCallbackAsync(string playerId, string suffix, string jsMethod, string callbackMethod, Action configureCallback) { try { var callbackWrapper = new AudioPlayerCallback(); configureCallback(callbackWrapper); var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper); _callbacks[playerId + suffix] = dotNetObjectRef; return await _jsRuntime.InvokeAsync($"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? OnProgress { get; set; } public Func? 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? OnData { get; set; } [JSInvokable] public async Task OnSpectrumDataCallback(double[] data) { if (OnData != null) await OnData(data); } } public class LevelCallback { public Func? 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; } }