Streaming Bug Fixes

This commit is contained in:
daniel-c-harvey
2025-12-06 06:41:32 -05:00
parent 605fc94fbb
commit 2baf0575bc
17 changed files with 1510 additions and 1054 deletions
@@ -0,0 +1,98 @@
/**
* AudioContextManager - Manages the Web Audio API AudioContext and GainNode.
*
* Single Responsibility: AudioContext lifecycle and audio routing.
*/
export class AudioContextManager {
private audioContext: AudioContext | null = null;
private gainNode: GainNode | null = null;
async initialize(sampleRate: number = 44100): Promise<void> {
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContextClass) {
throw new Error('Web Audio API not supported');
}
this.audioContext = new AudioContextClass({ sampleRate });
this.gainNode = this.audioContext.createGain();
this.gainNode.connect(this.audioContext.destination);
console.log(`AudioContext initialized: sampleRate=${this.audioContext.sampleRate}Hz, state=${this.audioContext.state}`);
}
async ensureReady(): Promise<void> {
if (!this.audioContext) {
throw new Error('AudioContext not initialized');
}
if (this.audioContext.state === 'suspended') {
console.log('🔊 Resuming AudioContext');
await this.audioContext.resume();
console.log(`✅ AudioContext resumed: state=${this.audioContext.state}`);
}
}
async recreateWithSampleRate(sampleRate: number): Promise<void> {
if (!this.audioContext) {
throw new Error('AudioContext not initialized');
}
if (this.audioContext.sampleRate === sampleRate) {
return; // Already correct sample rate
}
console.log(`🔄 Recreating AudioContext: ${this.audioContext.sampleRate}Hz -> ${sampleRate}Hz`);
await this.audioContext.close();
await this.initialize(sampleRate);
}
getContext(): AudioContext {
if (!this.audioContext) {
throw new Error('AudioContext not initialized');
}
return this.audioContext;
}
getGainNode(): GainNode {
if (!this.gainNode) {
throw new Error('GainNode not initialized');
}
return this.gainNode;
}
get currentTime(): number {
return this.audioContext?.currentTime ?? 0;
}
get sampleRate(): number {
return this.audioContext?.sampleRate ?? 0;
}
get state(): AudioContextState | 'uninitialized' {
return this.audioContext?.state ?? 'uninitialized';
}
setVolume(volume: number): void {
if (!this.gainNode || !this.audioContext) return;
const clampedVolume = Math.max(0, Math.min(1, volume));
this.gainNode.gain.setValueAtTime(clampedVolume, this.audioContext.currentTime);
}
getVolume(): number {
return this.gainNode?.gain.value ?? 0;
}
async decodeAudioData(buffer: ArrayBuffer): Promise<AudioBuffer> {
if (!this.audioContext) {
throw new Error('AudioContext not initialized');
}
return this.audioContext.decodeAudioData(buffer);
}
dispose(): void {
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close();
}
this.audioContext = null;
this.gainNode = null;
}
}
+338
View File
@@ -0,0 +1,338 @@
/**
* AudioPlayer - Main orchestrator for audio playback.
*
* Composes specialized managers following Single Responsibility Principle:
* - AudioContextManager: Web Audio API context and routing
* - StreamDecoder: WAV parsing and decoding
* - PlaybackScheduler: Buffer storage and playback scheduling
*/
import { AudioContextManager } from './AudioContextManager.js';
import { StreamDecoder } from './StreamDecoder.js';
import { PlaybackScheduler } from './PlaybackScheduler.js';
export interface AudioResult {
success: boolean;
error?: string;
}
export interface StreamingResult extends AudioResult {
canStartStreaming?: boolean;
headerParsed?: boolean;
bufferCount?: number;
duration?: number;
}
export interface AudioState {
isPlaying: boolean;
isPaused: boolean;
currentTime: number;
duration: number;
volume: number;
}
type ProgressCallback = (currentTime: number) => void;
type EndCallback = () => void;
export class AudioPlayer {
private contextManager: AudioContextManager;
private streamDecoder: StreamDecoder;
private scheduler: PlaybackScheduler;
// Playback state
private isPlaying: boolean = false;
private isPaused: boolean = false;
private pausePosition: number = 0;
private duration: number = 0;
// Streaming state
private isStreamingMode: boolean = false;
private streamingStarted: boolean = false;
private streamingCompleted: boolean = false;
private minBuffersForPlayback: number = 6;
// Callbacks
private onProgressCallback: ProgressCallback | null = null;
private onEndCallback: EndCallback | null = null;
private progressInterval: number | null = null;
constructor() {
this.contextManager = new AudioContextManager();
this.streamDecoder = new StreamDecoder(this.contextManager);
this.scheduler = new PlaybackScheduler(this.contextManager);
// Wire up scheduler callbacks
this.scheduler.onPlaybackEnded = () => this.handlePlaybackEnded();
}
// ==================== Initialization ====================
async initialize(): Promise<AudioResult> {
try {
await this.contextManager.initialize();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async ensureAudioContextReady(): Promise<AudioResult> {
try {
await this.contextManager.ensureReady();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Streaming ====================
initializeStreaming(totalStreamLength: number): AudioResult {
try {
this.resetState();
this.isStreamingMode = true;
this.streamDecoder.initialize(totalStreamLength);
console.log(`Streaming initialized: ${totalStreamLength} bytes expected`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async processStreamingChunk(chunk: Uint8Array): Promise<StreamingResult> {
try {
const result = await this.streamDecoder.processChunk(chunk);
if (result) {
this.scheduler.addBuffer(result.buffer);
// Update duration estimate
const estimatedDuration = this.streamDecoder.getEstimatedDuration();
if (estimatedDuration) {
this.duration = estimatedDuration;
}
// Schedule new buffers if already playing
if (this.streamingStarted && this.isPlaying) {
this.scheduler.scheduleNewBuffers();
}
}
// Check if streaming is complete
if (this.streamDecoder.isComplete) {
this.streamingCompleted = true;
console.log('Stream complete');
}
const canStart = this.streamDecoder.headerParsed &&
this.scheduler.hasMinimumBuffers(this.minBuffersForPlayback);
return {
success: true,
canStartStreaming: canStart,
headerParsed: this.streamDecoder.headerParsed,
bufferCount: this.scheduler.getBufferCount(),
duration: this.duration
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
startStreamingPlayback(): AudioResult {
if (!this.scheduler.hasBuffers()) {
return { success: false, error: 'No buffers available' };
}
try {
console.log('\n=== Starting streaming playback ===');
this.streamingStarted = true;
this.isPlaying = true;
this.isPaused = false;
this.pausePosition = 0;
this.scheduler.playFromPosition(0);
this.startProgressTracking();
console.log('✅ Streaming playback started');
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Playback Control ====================
play(): AudioResult {
if (!this.isStreamingMode) {
return { success: false, error: 'Not in streaming mode' };
}
if (!this.streamingStarted || !this.scheduler.hasBuffers()) {
return { success: false, error: 'Streaming not ready' };
}
// Don't restart if already playing
if (this.isPlaying) {
console.log('Already playing, ignoring play()');
return { success: true };
}
try {
this.contextManager.ensureReady();
this.isPlaying = true;
this.isPaused = false;
// Resume from pause position
this.scheduler.playFromPosition(this.pausePosition);
this.startProgressTracking();
console.log(`▶️ Resumed from ${this.pausePosition.toFixed(3)}s`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
pause(): AudioResult {
if (!this.isPlaying) {
return { success: false, error: 'Not playing' };
}
try {
this.pausePosition = this.scheduler.pause();
this.isPlaying = false;
this.isPaused = true;
this.stopProgressTracking();
console.log(`⏸️ Paused at ${this.pausePosition.toFixed(3)}s`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
stop(): AudioResult {
try {
this.scheduler.clear();
this.streamDecoder.reset();
this.resetState();
this.stopProgressTracking();
console.log('⏹️ Stopped');
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
unload(): AudioResult {
return this.stop();
}
seek(position: number): AudioResult {
if (!this.isStreamingMode || position < 0 || position > this.duration) {
return { success: false, error: 'Invalid seek position' };
}
try {
const wasPlaying = this.isPlaying;
this.scheduler.stopAllSources();
this.pausePosition = position;
if (wasPlaying) {
this.scheduler.playFromPosition(position);
}
console.log(`🔍 Seeked to ${position.toFixed(3)}s`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Volume ====================
setVolume(volume: number): AudioResult {
try {
this.contextManager.setVolume(volume);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== State ====================
getCurrentTime(): number {
if (this.isPlaying) {
return this.scheduler.getCurrentPosition();
}
return this.pausePosition;
}
getState(): AudioState {
return {
isPlaying: this.isPlaying,
isPaused: this.isPaused,
currentTime: this.getCurrentTime(),
duration: this.duration,
volume: this.contextManager.getVolume()
};
}
// ==================== Callbacks ====================
setOnProgressCallback(callback: ProgressCallback): void {
this.onProgressCallback = callback;
}
setOnEndCallback(callback: EndCallback): void {
this.onEndCallback = callback;
}
// ==================== Private Methods ====================
private resetState(): void {
this.isPlaying = false;
this.isPaused = false;
this.pausePosition = 0;
this.duration = 0;
this.isStreamingMode = false;
this.streamingStarted = false;
this.streamingCompleted = false;
}
private handlePlaybackEnded(): void {
this.isPlaying = false;
this.isPaused = false;
this.pausePosition = 0;
this.stopProgressTracking();
this.onEndCallback?.();
}
private startProgressTracking(): void {
this.stopProgressTracking();
this.progressInterval = window.setInterval(() => {
if (this.onProgressCallback && this.isPlaying) {
this.onProgressCallback(this.getCurrentTime());
}
}, 100);
}
private stopProgressTracking(): void {
if (this.progressInterval) {
clearInterval(this.progressInterval);
this.progressInterval = null;
}
}
// ==================== Cleanup ====================
dispose(): void {
this.stop();
this.stopProgressTracking();
this.contextManager.dispose();
}
}
@@ -0,0 +1,280 @@
/**
* PlaybackScheduler - Manages AudioBuffer storage and playback scheduling.
*
* Single Responsibility: Store decoded buffers and schedule them for playback.
* Supports pause/resume/seek by retaining all buffers.
*/
import { AudioContextManager } from './AudioContextManager.js';
interface ScheduledSource {
source: AudioBufferSourceNode;
bufferIndex: number;
startTime: number;
endTime: number;
}
export class PlaybackScheduler {
private contextManager: AudioContextManager;
private buffers: AudioBuffer[] = [];
private scheduledSources: ScheduledSource[] = [];
// Playback timing
private playbackAnchorTime: number = 0; // AudioContext time when playback started/resumed
private playbackAnchorPosition: number = 0; // Position in audio when playback started/resumed
private nextBufferIndex: number = 0; // Next buffer to schedule during live streaming
private nextScheduleTime: number = 0; // AudioContext time for next buffer
private isActive_: boolean = false; // Prevents scheduling during pause/stop
// Callbacks
public onPlaybackEnded: (() => void) | null = null;
constructor(contextManager: AudioContextManager) {
this.contextManager = contextManager;
}
/**
* Add a decoded buffer to storage
*/
addBuffer(buffer: AudioBuffer): void {
this.buffers.push(buffer);
console.log(`📦 Buffer[${this.buffers.length - 1}] added: ${buffer.duration.toFixed(3)}s (total: ${this.getTotalDuration().toFixed(3)}s)`);
}
/**
* Get total duration of all stored buffers
*/
getTotalDuration(): number {
return this.buffers.reduce((sum, b) => sum + b.duration, 0);
}
/**
* Get number of stored buffers
*/
getBufferCount(): number {
return this.buffers.length;
}
/**
* Get current playback position in seconds
*/
getCurrentPosition(): number {
if (this.playbackAnchorTime === 0) {
return this.playbackAnchorPosition;
}
const elapsed = this.contextManager.currentTime - this.playbackAnchorTime;
return Math.min(this.playbackAnchorPosition + elapsed, this.getTotalDuration());
}
/**
* Start or resume playback from a specific position
*/
playFromPosition(position: number): void {
this.stopAllSources();
// Find which buffer contains this position
let accumulatedTime = 0;
let startBufferIndex = 0;
let offsetInBuffer = 0;
for (let i = 0; i < this.buffers.length; i++) {
const bufferDuration = this.buffers[i].duration;
if (accumulatedTime + bufferDuration > position) {
startBufferIndex = i;
offsetInBuffer = position - accumulatedTime;
break;
}
accumulatedTime += bufferDuration;
startBufferIndex = i + 1;
}
if (startBufferIndex >= this.buffers.length) {
console.log('Position beyond available buffers');
return;
}
console.log(`▶️ Playing from ${position.toFixed(3)}s: buffer[${startBufferIndex}] offset=${offsetInBuffer.toFixed(3)}s`);
// Set timing anchors
this.playbackAnchorPosition = position;
this.playbackAnchorTime = this.contextManager.currentTime;
this.nextScheduleTime = this.contextManager.currentTime + 0.01; // Small lookahead
this.nextBufferIndex = startBufferIndex;
this.isActive_ = true; // Enable scheduling
// Schedule buffers
this.scheduleBuffersFrom(startBufferIndex, offsetInBuffer);
}
/**
* Schedule newly decoded buffers during live streaming
*/
scheduleNewBuffers(): void {
if (this.nextBufferIndex >= this.buffers.length) {
return; // No new buffers
}
if (this.nextScheduleTime === 0) {
this.nextScheduleTime = this.contextManager.currentTime + 0.01;
}
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
}
/**
* Internal: Schedule buffers starting from a specific index
*/
private scheduleBuffersFrom(startIndex: number, offsetInFirstBuffer: number): void {
const lookaheadTarget = 0.5; // Schedule up to 500ms ahead
const gainNode = this.contextManager.getGainNode();
for (let i = startIndex; i < this.buffers.length; i++) {
const buffer = this.buffers[i];
const isFirstBuffer = (i === startIndex && offsetInFirstBuffer > 0);
const offset = isFirstBuffer ? offsetInFirstBuffer : 0;
const duration = buffer.duration - offset;
// Create and configure source
const source = this.contextManager.getContext().createBufferSource();
source.buffer = buffer;
source.connect(gainNode);
const scheduleTime = this.nextScheduleTime;
const endTime = scheduleTime + duration;
// Track scheduled source
const scheduled: ScheduledSource = {
source,
bufferIndex: i,
startTime: scheduleTime,
endTime
};
this.scheduledSources.push(scheduled);
// Set up ended callback
source.onended = () => this.handleSourceEnded(scheduled);
// Schedule the source
source.start(scheduleTime, offset);
console.log(`🎵 Scheduled buffer[${i}]: ${scheduleTime.toFixed(3)}s -> ${endTime.toFixed(3)}s`);
// Update for next buffer
this.nextScheduleTime = endTime;
this.nextBufferIndex = i + 1;
// Check if we have enough lookahead
const lookahead = this.nextScheduleTime - this.contextManager.currentTime;
if (lookahead > lookaheadTarget) {
console.log(`📋 Lookahead: ${(lookahead * 1000).toFixed(0)}ms buffered`);
break;
}
}
}
/**
* Handle a source finishing playback
*/
private handleSourceEnded(scheduled: ScheduledSource): void {
// Ignore if we're paused/stopped (sources fire onended when stopped)
if (!this.isActive_) {
return;
}
// Remove from scheduled list
const index = this.scheduledSources.indexOf(scheduled);
if (index > -1) {
this.scheduledSources.splice(index, 1);
}
// Schedule more buffers if available
if (this.nextBufferIndex < this.buffers.length) {
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
}
// Check if all playback has finished
if (this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) {
console.log('✓ Playback complete');
this.isActive_ = false;
this.playbackAnchorTime = 0;
this.playbackAnchorPosition = 0;
this.onPlaybackEnded?.();
}
}
/**
* Pause playback - saves position and stops sources
*/
pause(): number {
const position = this.getCurrentPosition();
this.isActive_ = false; // Prevent handleSourceEnded from scheduling more
this.stopAllSources();
this.playbackAnchorPosition = position;
this.playbackAnchorTime = 0;
this.nextScheduleTime = 0;
console.log(`⏸️ Paused at ${position.toFixed(3)}s`);
return position;
}
/**
* Stop all scheduled sources
*/
stopAllSources(): void {
for (const scheduled of this.scheduledSources) {
try {
scheduled.source.stop();
} catch {
// Source may already be stopped
}
}
this.scheduledSources = [];
}
/**
* Reset to beginning (for stop)
*/
resetToStart(): void {
this.isActive_ = false;
this.stopAllSources();
this.playbackAnchorPosition = 0;
this.playbackAnchorTime = 0;
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
console.log('⏮️ Reset to start');
}
/**
* Full reset - clears all buffers
*/
clear(): void {
this.isActive_ = false;
this.stopAllSources();
this.buffers = [];
this.playbackAnchorPosition = 0;
this.playbackAnchorTime = 0;
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
console.log('🗑️ Scheduler cleared');
}
/**
* Check if we have buffers
*/
hasBuffers(): boolean {
return this.buffers.length > 0;
}
/**
* Check if we have minimum buffers for playback
*/
hasMinimumBuffers(minCount: number): boolean {
return this.buffers.length >= minCount;
}
/**
* Check if playback is active
*/
isActive(): boolean {
return this.isActive_;
}
}
+216
View File
@@ -0,0 +1,216 @@
/**
* StreamDecoder - Handles WAV stream parsing and AudioBuffer decoding.
*
* Single Responsibility: Convert raw WAV stream data into decoded AudioBuffers.
*/
import { WavHeader, WavUtils } from '../wavutils.js';
import { AudioContextManager } from './AudioContextManager.js';
export interface DecodedChunkResult {
buffer: AudioBuffer;
duration: number;
}
export class StreamDecoder {
private contextManager: AudioContextManager;
private wavHeader: WavHeader | null = null;
private rawChunks: Uint8Array[] = [];
private totalRawBytes: number = 0;
private processedBytes: number = 0;
private isFirstChunk: boolean = true;
private totalStreamLength: number = 0;
constructor(contextManager: AudioContextManager) {
this.contextManager = contextManager;
}
/**
* Initialize for a new stream
*/
initialize(totalStreamLength: number): void {
this.wavHeader = null;
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.isFirstChunk = true;
this.totalStreamLength = totalStreamLength;
console.log(`StreamDecoder initialized: expecting ${totalStreamLength} bytes`);
}
/**
* Process incoming chunk and return decoded AudioBuffer if ready
*/
async processChunk(chunk: Uint8Array): Promise<DecodedChunkResult | null> {
if (this.isFirstChunk) {
await this.handleFirstChunk(chunk);
this.isFirstChunk = false;
} else {
this.addRawData(chunk);
}
return this.tryDecodeNextSegment();
}
/**
* Handle first chunk - extract WAV header and setup AudioContext
*/
private async handleFirstChunk(chunk: Uint8Array): Promise<void> {
console.log('\n--- Processing first chunk ---');
const header = WavUtils.parseHeader([chunk], chunk.length);
if (!header) {
throw new Error('Invalid WAV header in first chunk');
}
this.wavHeader = header;
console.log(`WAV format: ${header.bitsPerSample}-bit, ${header.channels}ch, ${header.sampleRate}Hz`);
console.log(`Header size: ${header.headerSize}, byteRate: ${header.byteRate}`);
// Recreate AudioContext with correct sample rate if needed
if (this.contextManager.sampleRate !== header.sampleRate) {
await this.contextManager.recreateWithSampleRate(header.sampleRate);
}
// Extract audio data (skip WAV header)
const audioData = chunk.subarray(header.headerSize);
this.addRawData(audioData);
console.log(`Extracted ${audioData.length} bytes of audio data`);
}
/**
* Add raw audio data to buffer
*/
private addRawData(data: Uint8Array): void {
this.rawChunks.push(data);
this.totalRawBytes += data.length;
}
/**
* Try to decode the next segment of audio
*/
private async tryDecodeNextSegment(): Promise<DecodedChunkResult | null> {
if (!this.wavHeader) return null;
const segmentSize = 64 * 1024; // 64KB segments
const availableBytes = this.totalRawBytes - this.processedBytes;
const alignedSize = WavUtils.getSampleAlignedChunkSize(this.wavHeader, segmentSize, availableBytes);
if (alignedSize <= 0) return null;
console.log(`\n--- Decoding segment ---`);
console.log(`Available: ${availableBytes} bytes, aligned size: ${alignedSize} bytes`);
const rawSegment = this.extractAlignedData(alignedSize);
const wavFile = this.createWavFile(rawSegment);
try {
const buffer = await this.decodeWithTimeout(wavFile);
console.log(`✓ Decoded: ${buffer.duration.toFixed(3)}s, ${buffer.numberOfChannels}ch`);
return { buffer, duration: buffer.duration };
} catch (error) {
console.error('Failed to decode segment:', error);
return null;
}
}
/**
* Extract aligned data from raw chunks
*/
private extractAlignedData(size: number): Uint8Array {
const extracted = new Uint8Array(size);
let extractedOffset = 0;
let remaining = size;
let streamPosition = this.processedBytes;
let currentPos = 0;
for (const chunk of this.rawChunks) {
if (remaining <= 0) break;
if (currentPos + chunk.length <= streamPosition) {
currentPos += chunk.length;
continue;
}
const chunkStartOffset = Math.max(0, streamPosition - currentPos);
const availableInChunk = chunk.length - chunkStartOffset;
const toCopy = Math.min(availableInChunk, remaining);
if (toCopy > 0) {
extracted.set(chunk.subarray(chunkStartOffset, chunkStartOffset + toCopy), extractedOffset);
extractedOffset += toCopy;
remaining -= toCopy;
}
currentPos += chunk.length;
}
this.processedBytes += size;
return extracted;
}
/**
* Create a complete WAV file from raw audio data
*/
private createWavFile(rawData: Uint8Array): Uint8Array {
const header = WavUtils.createHeader(this.wavHeader!, rawData.length);
const wavFile = new Uint8Array(header.length + rawData.length);
wavFile.set(header, 0);
wavFile.set(rawData, header.length);
return wavFile;
}
/**
* Decode with timeout to prevent hanging
*/
private async decodeWithTimeout(wavData: Uint8Array, timeoutMs: number = 5000): Promise<AudioBuffer> {
const buffer = new ArrayBuffer(wavData.length);
new Uint8Array(buffer).set(wavData);
const decodePromise = this.contextManager.decodeAudioData(buffer);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Decode timeout')), timeoutMs);
});
return Promise.race([decodePromise, timeoutPromise]);
}
/**
* Get calculated duration from WAV header
*/
getEstimatedDuration(): number | null {
if (!this.wavHeader || this.wavHeader.byteRate <= 0) return null;
const audioDataSize = this.wavHeader.dataSize > 0
? this.wavHeader.dataSize
: (this.totalStreamLength - this.wavHeader.headerSize);
return audioDataSize / this.wavHeader.byteRate;
}
/**
* Check if WAV header has been parsed
*/
get headerParsed(): boolean {
return this.wavHeader !== null;
}
/**
* Check if all stream data has been received
*/
get isComplete(): boolean {
return this.totalStreamLength > 0 && this.totalRawBytes >= (this.totalStreamLength - (this.wavHeader?.headerSize ?? 0));
}
/**
* Reset decoder state
*/
reset(): void {
this.wavHeader = null;
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.isFirstChunk = true;
this.totalStreamLength = 0;
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Audio Interop - Exposes AudioPlayer to Blazor via window.DeepDrftAudio
*/
import { AudioPlayer, AudioResult, StreamingResult, AudioState } from './AudioPlayer.js';
// Player instances by ID
const audioPlayers = new Map<string, AudioPlayer>();
// .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): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.initializeStreaming(totalStreamLength);
},
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: (playerId: string): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.startStreamingPlayback();
},
ensureAudioContextReady: async (playerId: string): Promise<AudioResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.ensureAudioContextReady();
},
play: (playerId: string): 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);
},
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 };
},
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' };
},
// Legacy compatibility - these may not be needed but kept for safety
initializeBufferedPlayer: (_playerId: string): AudioResult => {
return { success: true }; // No-op for streaming mode
},
appendAudioBlock: (_playerId: string, _audioBlock: Uint8Array): AudioResult => {
return { success: true }; // No-op - use processStreamingChunk instead
},
finalizeAudioBuffer: async (_playerId: string): Promise<AudioResult & { duration?: number }> => {
return { success: true }; // No-op for streaming mode
}
};
// Expose to window
declare global {
interface Window {
DeepDrftAudio: typeof DeepDrftAudio;
}
}
window.DeepDrftAudio = DeepDrftAudio;
export { DeepDrftAudio };