Files
deepdrft/DeepDrftPublic/Interop/audio/index.ts
T
daniel-c-harvey 518479e7ae Phase 21.2: back-pressure to bound the unplayed decoded region
Shared scheduler fill signal (forward water-marks + hard byte cap) pauses
the C# read loop above high-water and, for Opus, stops the demux/decode
feed so WebCodecs queues stay near-empty. Routes through the existing
cancellation discipline; releases the latch on clear/seek.
2026-06-23 23:16:08 -04:00

281 lines
11 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;
},
// 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);
},
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 };