d686fe48ce
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.
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
/**
|
|
* Audio Interop - Exposes AudioPlayer to Blazor via window.DeepDrftAudio
|
|
*/
|
|
|
|
import { AudioPlayer, AudioResult, StreamingResult, AudioState } from './AudioPlayer.js';
|
|
import { canDecodeOggOpus } from './OpusCapability.js';
|
|
|
|
// Player instances by ID
|
|
const audioPlayers = new Map<string, AudioPlayer>();
|
|
|
|
// Readiness state, flipped true at the end of module execution once the API is
|
|
// attached to window. Read via DeepDrftAudio.isReady().
|
|
let ready = false;
|
|
|
|
// .NET interop type
|
|
interface DotNetObjectReference {
|
|
invokeMethodAsync(methodName: string, ...args: unknown[]): Promise<unknown>;
|
|
}
|
|
|
|
// Global API exposed to Blazor
|
|
const DeepDrftAudio = {
|
|
createPlayer: async (playerId: string): Promise<AudioResult> => {
|
|
try {
|
|
const player = new AudioPlayer();
|
|
const result = await player.initialize();
|
|
if (result.success) {
|
|
audioPlayers.set(playerId, player);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
return { success: false, error: (error as Error).message };
|
|
}
|
|
},
|
|
|
|
initializeStreaming: (playerId: string, totalStreamLength: number, contentType: string): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
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. Resolves whether this browser can stream-decode Ogg Opus via WebCodecs
|
|
// (AudioDecoder + codec:'opus'; Safari < 16.4 / older Firefox cannot). The player consumes this
|
|
// to choose lossless when unsupported; this module only reports the capability.
|
|
canDecodeOggOpus: (): Promise<boolean> => canDecodeOggOpus(),
|
|
|
|
processStreamingChunk: async (playerId: string, chunk: Uint8Array): Promise<StreamingResult> => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.processStreamingChunk(chunk);
|
|
},
|
|
|
|
startStreamingPlayback: async (playerId: string): Promise<AudioResult> => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.startStreamingPlayback();
|
|
},
|
|
|
|
markStreamComplete: async (playerId: string): Promise<StreamingResult> => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.markStreamComplete();
|
|
},
|
|
|
|
ensureAudioContextReady: async (playerId: string): Promise<AudioResult> => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.ensureAudioContextReady();
|
|
},
|
|
|
|
play: async (playerId: string): Promise<AudioResult> => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.play();
|
|
},
|
|
|
|
pause: (playerId: string): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.pause();
|
|
},
|
|
|
|
stop: (playerId: string): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.stop();
|
|
},
|
|
|
|
unload: (playerId: string): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.unload();
|
|
},
|
|
|
|
seek: (playerId: string, position: number): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.seek(position);
|
|
},
|
|
|
|
// New methods for seek-beyond-buffer support
|
|
getBufferedDuration: (playerId: string): number => {
|
|
const player = audioPlayers.get(playerId);
|
|
return player?.getBufferedDuration() ?? 0;
|
|
},
|
|
|
|
calculateByteOffset: (playerId: string, positionSeconds: number): number => {
|
|
const player = audioPlayers.get(playerId);
|
|
return player?.calculateByteOffset(positionSeconds) ?? 0;
|
|
},
|
|
|
|
// "Load at timestamp" seam (Phase 18 wave 18.6 format switch). Resolve the file-absolute byte offset
|
|
// to begin a stream at `position` with no playback/buffer state — the C# load-from-position path calls
|
|
// this after initializeStreaming (Opus: sidecar resolves immediately; WAV: after a header probe) and
|
|
// then streams from the returned offset via the seek/refill loop. seekBeyondBuffer:true + byteOffset.
|
|
resolveStreamOffset: (playerId: string, position: number): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.resolveStreamOffset(position);
|
|
},
|
|
|
|
// Phase 21.2a back-pressure poll: the C# read loop calls this WHILE throttled to learn when
|
|
// the scheduler has drained below low-water and reading may resume. A missing player reads as
|
|
// "not paused" so a torn-down player never wedges a loop that is already exiting.
|
|
isProductionPaused: (playerId: string): boolean => {
|
|
const player = audioPlayers.get(playerId);
|
|
return player?.isProductionPaused() ?? false;
|
|
},
|
|
|
|
reinitializeFromOffset: (playerId: string, totalStreamLength: number, seekPosition: number): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.reinitializeFromOffset(totalStreamLength, seekPosition);
|
|
},
|
|
|
|
// Phase 21.3 / AC6: recover into a clean paused-but-loaded state after a window-miss refill
|
|
// (seek-back past the retained tail) failed its Range fetch or reinit. Prevents the starved
|
|
// scheduler from firing a silent false end; leaves the track loaded so a retry is possible.
|
|
recoverFromFailedRefill: (playerId: string, seekPosition: number): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.recoverFromFailedRefill(seekPosition);
|
|
},
|
|
|
|
setVolume: (playerId: string, volume: number): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.setVolume(volume);
|
|
},
|
|
|
|
getCurrentTime: (playerId: string): number => {
|
|
const player = audioPlayers.get(playerId);
|
|
return player?.getCurrentTime() ?? 0;
|
|
},
|
|
|
|
getState: (playerId: string): AudioState | null => {
|
|
const player = audioPlayers.get(playerId);
|
|
return player?.getState() ?? null;
|
|
},
|
|
|
|
setOnProgressCallback: (
|
|
playerId: string,
|
|
dotNetRef: DotNetObjectReference,
|
|
methodName: string
|
|
): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
|
|
player.setOnProgressCallback((currentTime: number) => {
|
|
dotNetRef.invokeMethodAsync(methodName, currentTime);
|
|
});
|
|
return { success: true };
|
|
},
|
|
|
|
setOnEndCallback: (
|
|
playerId: string,
|
|
dotNetRef: DotNetObjectReference,
|
|
methodName: string
|
|
): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
|
|
player.setOnEndCallback(() => {
|
|
dotNetRef.invokeMethodAsync(methodName);
|
|
});
|
|
return { success: true };
|
|
},
|
|
|
|
// Spectrum analyzer methods
|
|
getSpectrumData: (playerId: string): number[] | null => {
|
|
const player = audioPlayers.get(playerId);
|
|
return player?.getSpectrumData() ?? null;
|
|
},
|
|
|
|
setSpectrumHighPass: (playerId: string, freq: number): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.setSpectrumHighPass(freq);
|
|
},
|
|
|
|
setSpectrumLowPass: (playerId: string, freq: number): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.setSpectrumLowPass(freq);
|
|
},
|
|
|
|
setSpectrumSlope: (playerId: string, dbPerDecade: number): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
return player.setSpectrumSlope(dbPerDecade);
|
|
},
|
|
|
|
startSpectrumAnimation: (
|
|
playerId: string,
|
|
callbackId: string,
|
|
dotNetRef: DotNetObjectReference,
|
|
methodName: string
|
|
): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
|
|
player.startSpectrumAnimation(callbackId, (data: number[]) => {
|
|
dotNetRef.invokeMethodAsync(methodName, data);
|
|
});
|
|
return { success: true };
|
|
},
|
|
|
|
stopSpectrumAnimation: (playerId: string, callbackId: string): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
player.stopSpectrumAnimation(callbackId);
|
|
return { success: true };
|
|
},
|
|
|
|
getLevelDb: (playerId: string): number => {
|
|
const player = audioPlayers.get(playerId);
|
|
return player?.getLevelDb() ?? -Infinity;
|
|
},
|
|
|
|
startLevelAnimation: (
|
|
playerId: string,
|
|
callbackId: string,
|
|
dotNetRef: DotNetObjectReference,
|
|
methodName: string
|
|
): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
player.startLevelAnimation(callbackId, (db: number) => {
|
|
dotNetRef.invokeMethodAsync(methodName, db);
|
|
});
|
|
return { success: true };
|
|
},
|
|
|
|
stopLevelAnimation: (playerId: string, callbackId: string): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (!player) return { success: false, error: 'Player not found' };
|
|
player.stopLevelAnimation(callbackId);
|
|
return { success: true };
|
|
},
|
|
|
|
disposePlayer: (playerId: string): AudioResult => {
|
|
const player = audioPlayers.get(playerId);
|
|
if (player) {
|
|
player.dispose();
|
|
audioPlayers.delete(playerId);
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: 'Player not found' };
|
|
},
|
|
|
|
// Readiness probe — true once this module has finished executing and the API
|
|
// is attached to window. Blazor polls this before the first interop call so a
|
|
// slow WASM boot / cache miss does not surface as a generic init failure.
|
|
// Exposed as a method because Blazor JS interop invokes functions, not bare
|
|
// properties.
|
|
isReady: (): boolean => ready
|
|
};
|
|
|
|
// Expose to window
|
|
declare global {
|
|
interface Window {
|
|
DeepDrftAudio: typeof DeepDrftAudio;
|
|
}
|
|
}
|
|
|
|
window.DeepDrftAudio = DeepDrftAudio;
|
|
// Flip ready last so a poller that sees isReady() === true is guaranteed the
|
|
// whole surface is attached and callable.
|
|
ready = true;
|
|
|
|
export { DeepDrftAudio };
|