@@ -58,9 +58,9 @@ class AudioPlayer {
private onEndCallback : EndCallback | null = null ;
private progressInterval : number | null = null ;
private bufferChunks : Uint8Array [ ] = [ ] ;
private expectedSize : number = 0 ;
private currentSize : number = 0 ;
private processedBytes : number = 0 ; // Track how many bytes we've already processed
// Streaming properties
private isStreamingMode : boolean = false ;
private wavHeader : WavHeader | null = null ;
@@ -68,12 +68,10 @@ class AudioPlayer {
private currentStreamSource : AudioBufferSourceNode | null = null ;
private nextStartTime : number = 0 ;
private streamingStarted : boolean = false ;
private minBuffersForStreaming : number = 3 ;
private streamingCompleted : boolean = false ; // Track if streaming is finished
private totalStreamLength : number = 0 ; // Total bytes expected in stream
private minBuffersForStreaming : number = 6 ; // Increased for better buffering
// Buffer optimization
private cachedWavHeader : Uint8Array | null = null ;
private reusableBuffer : Uint8Array | null = null ;
private maxReusableBufferSize : number = 128 * 1024 ; // 128KB max reusable buffer
async initialize ( ) : Promise < AudioResult > {
try {
@@ -81,9 +79,26 @@ class AudioPlayer {
if ( ! AudioContextClass ) {
throw new Error ( 'Web Audio API not supported' ) ;
}
this . audioContext = new AudioContextClass ( ) ;
// Initialize with 44.1kHz for music (most common rate) to avoid recreation
this . audioContext = new AudioContextClass ( { sampleRate : 44100 } ) ;
this . gainNode = this . audioContext . createGain ( ) ;
this . gainNode . connect ( this . audioContext . destination ) ;
console . log ( ` AudioContext initialized: sampleRate= ${ this . audioContext . sampleRate } Hz, state= ${ this . audioContext . state } ` ) ;
return { success : true } ;
} catch ( error ) {
return { success : false , error : ( error as Error ) . message } ;
}
}
async ensureAudioContextReady ( ) : Promise < AudioResult > {
try {
if ( this . audioContext ! . state === 'suspended' ) {
console . log ( '🔊 Resuming AudioContext on track selection (user interaction)' ) ;
await this . audioContext ! . resume ( ) ;
console . log ( ` ✅ AudioContext resumed: state= ${ this . audioContext ! . state } ` ) ;
}
return { success : true } ;
} catch ( error ) {
return { success : false , error : ( error as Error ) . message } ;
@@ -94,7 +109,6 @@ class AudioPlayer {
try {
this . bufferChunks = [ ] ;
this . currentSize = 0 ;
this . expectedSize = 0 ;
return { success : true } ;
} catch ( error ) {
return { success : false , error : ( error as Error ) . message } ;
@@ -320,39 +334,44 @@ class AudioPlayer {
this . onEndCallback = callback ;
}
initializeStreaming ( ) : AudioResult {
initializeStreaming ( totalStreamLength : number ) : AudioResult {
try {
this . isStreamingMode = true ;
this . bufferChunks = [ ] ;
this . bufferQueue = [ ] ;
this . currentSize = 0 ;
this . processedBytes = 0 ; // Reset stream position
this . totalStreamLength = totalStreamLength ; // Set total expected stream length
this . wavHeader = null ;
this . streamingStarted = false ;
this . streamingCompleted = false ; // Reset completion flag
this . nextStartTime = 0 ;
console . log ( ` Streaming initialized: expecting ${ this . totalStreamLength } total bytes ` ) ;
return { success : true } ;
} catch ( error ) {
return { success : false , error : ( error as Error ) . message } ;
}
}
processStreamingChunk ( audioChunk : Uint8Array ) : StreamingResult {
private chunkCounter = 0 ;
async processStreamingChunk ( audioChunk : Uint8Array ) : Promise < StreamingResult > {
try {
this . bufferChunks . push ( audioChunk ) ;
this . currentSize += audioChunk . length ;
this . chunkCounter ++ ;
console . log ( ` \ n=== CHUNK ${ this . chunkCounter } === ` ) ;
console . log ( ` Incoming chunk size: ${ audioChunk . length } ` ) ;
console . log ( ` Chunk preview: ` , Array . from ( audioChunk . slice ( 0 , 32 ) ) . map ( b = > b . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( ' ' ) ) ;
console . log ( ` Buffer queue length before processing: ${ this . bufferQueue . length } ` ) ;
// Parse WAV header from first chunk if not done yet
if ( ! this . wavHeader && this . currentSize >= 44 ) {
const header = WavUtils . parseHeader ( this . bufferChunks , this . currentSize ) ;
if ( header ) {
this . wavHeader = header ;
// Cache the WAV header for reuse
this . cachedWavHeader = WavUtils . createHeader ( header , 64 * 1024 ) ; // Cache with dummy size
}
}
await this . processChunk ( audioChunk ) ;
// Try to create audio buffers from accumulated chunks
if ( this . wavHeader ) {
this . processBufferedChunks ( ) ;
// Check if we've received all expected data
console . log ( ` Stream check: ${ this . currentSize } / ${ this . totalStreamLength } bytes, completed= ${ this . streamingCompleted } ` ) ;
if ( this . totalStreamLength > 0 && this . currentSize >= this . totalStreamLength ) {
console . log ( ` Stream complete: received ${ this . currentSize } / ${ this . totalStreamLength } bytes ` ) ;
this . streamingCompleted = true ;
}
const canStart = this . wavHeader !== null && this . bufferQueue . length >= this . minBuffersForStreaming ;
@@ -368,115 +387,166 @@ class AudioPlayer {
}
}
startStreamingPlayback ( ) : AudioResult {
if ( ! this . wavHeader || this . bufferQueue . length === 0 ) {
return { success : false , error : "Not ready for streaming playback" } ;
private isFirstChunk = true ;
private async processChunk ( audioChunk : Uint8Array ) : Promise < void > {
if ( this . isFirstChunk ) {
const audioData = await this . extractAudioFromFirstChunk ( audioChunk ) ;
this . addToAudioStream ( audioData ) ;
this . isFirstChunk = false ;
} else {
// Continuation chunks are pure audio data
this . addToAudioStream ( audioChunk ) ;
}
try {
if ( this . audioContext ! . state === 'suspended' ) {
this . audioContext ! . resume ( ) ;
}
this . streamingStarted = true ;
this . isPlaying = true ;
this . isPaused = false ;
this . nextStartTime = this . audioContext ! . currentTime ;
this . startTime = this . nextStartTime ;
this . scheduleNextBuffer ( ) ;
this . startProgressTracking ( ) ;
return { success : true } ;
} catch ( error ) {
return { success : false , error : ( error as Error ) . message } ;
}
}
private processBufferedChunks ( ) : void {
if ( ! this . wavHeader || this . bufferChunks . length === 0 ) return ;
try {
// Process chunks in groups to create audio buffers
const chunkSize = 64 * 1024 ; // 64KB chunks for streaming
while ( this . currentSize >= chunkSize + this . wavHeader . headerSize ) {
// Extract audio data using WavUtils
const audioData = WavUtils . extractAudioData ( this . bufferChunks , this . currentSize , this . wavHeader . headerSize , chunkSize ) ;
// Reuse buffer if possible to reduce allocations
const totalSize = this . cachedWavHeader ! . length + audioData . length - this . wavHeader . headerSize ;
if ( ! this . reusableBuffer || this . reusableBuffer . length < totalSize ) {
// Only allocate if we don't have a buffer or it's too small
this . reusableBuffer = new Uint8Array ( Math . min ( totalSize , this . maxReusableBufferSize ) ) ;
}
// Create complete WAV buffer using cached header and reusable buffer
const completeBuffer = this . reusableBuffer . slice ( 0 , totalSize ) ;
completeBuffer . set ( this . cachedWavHeader ! . slice ( 0 , this . wavHeader . headerSize ) , 0 ) ;
completeBuffer . set ( audioData . subarray ( this . wavHeader . headerSize ) , this . wavHeader . headerSize ) ;
// Create audio buffer from the chunk
this . createAudioBufferFromChunk ( completeBuffer ) ;
// Remove processed data
this . removeProcessedChunks ( chunkSize ) ;
break ; // Process one chunk at a time
}
} catch ( error ) {
console . error ( 'Error processing buffered chunks:' , error ) ;
}
}
private async createAudioBufferFromChunk ( chunkData : Uint8Array ) : Promise < void > {
try {
const arrayBuffer = chunkData . buffer . slice ( chunkData . byteOffset , chunkData . byteOffset + chunkData . byteLength ) ;
const audioBuffer = await this . audioContext ! . decodeAudioData ( arrayBuffer ) ;
this . bufferQueue . push ( audioBuffer ) ;
// Schedule buffer if streaming has started
if ( this . streamingStarted ) {
this . scheduleNextBuffer ( ) ;
}
} catch ( error ) {
console . error ( 'Error creating audio buffer from chunk:' , error ) ;
}
}
private scheduleNextBuffer ( ) : void {
if ( this . bufferQueue . length === 0 || ! this . streamingStarted ) return ;
const buffer = this . bufferQueue . shift ( ) ! ;
const source = this . audioContext ! . createBufferSource ( ) ;
source . buffer = buffer ;
source . connect ( this . gainNode ! ) ;
source . onended = ( ) = > {
if ( this . bufferQueue . length > 0 ) {
this . scheduleNextBuffer ( ) ;
} else if ( ! this . isPlaying ) {
this . onEndCallback ? . ( ) ;
}
} ;
source . start ( this . nextStartTime ) ;
this . nextStartTime += buffer . duration ;
this . currentStreamSource = source ;
await this . processAudioStream ( ) ;
}
private async extractAudioFromFirstChunk ( chunkData : Uint8Array ) : Promise < Uint8Array > {
console . log ( '\n--- EXTRACTING AUDIO FROM FIRST CHUNK ---' ) ;
// Parse header and setup AudioContext
const header = WavUtils . parseHeader ( [ chunkData ] , chunkData . 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 details: blockAlign= ${ header . blockAlign } , byteRate= ${ header . byteRate } , headerSize= ${ header . headerSize } ` ) ;
// Recreate AudioContext with correct sample rate if needed (only during initial setup)
if ( this . audioContext ! . sampleRate !== header . sampleRate ) {
console . log ( ` 🔄 AudioContext sample rate mismatch: ${ this . audioContext ! . sampleRate } Hz -> ${ header . sampleRate } Hz ` ) ;
private removeProcessedChunks ( processedSize : number ) : void {
// Only recreate if we haven't started playing yet AND AudioContext is already running
if ( ! this . streamingStarted && ! this . isPlaying && this . audioContext ! . state === 'running' ) {
console . log ( ` ⚠️ Recreating AudioContext for proper sample rate matching ` ) ;
await this . audioContext ! . close ( ) ;
const AudioContextClass = window . AudioContext || window . webkitAudioContext ;
this . audioContext = new AudioContextClass ( { sampleRate : header.sampleRate } ) ;
this . gainNode = this . audioContext . createGain ( ) ;
this . gainNode . connect ( this . audioContext . destination ) ;
console . log ( ` ✅ AudioContext recreated: ${ this . audioContext . sampleRate } Hz (should eliminate resampling artifacts) ` ) ;
} else {
console . log ( ` ℹ ️ Keeping existing AudioContext - using Web Audio API sample rate conversion` ) ;
}
}
// Extract pure audio data (skip WAV header)
const audioData = chunkData . subarray ( header . headerSize ) ;
console . log ( ` Extracted ${ audioData . length } bytes of audio data (skipped ${ header . headerSize } byte header) ` ) ;
return audioData ;
}
private async ensureCorrectSampleRate ( sampleRate : number ) : Promise < void > {
if ( this . audioContext ! . sampleRate !== sampleRate ) {
console . log ( ` 🔊 AUDIO CONTEXT CHANGE START: ${ this . audioContext ! . sampleRate } Hz -> ${ sampleRate } Hz ` ) ;
console . log ( ` ⚠️ This may cause an audible pop/click! ` ) ;
await this . audioContext ! . close ( ) ;
console . log ( ` ✅ Old AudioContext closed ` ) ;
const AudioContextClass = window . AudioContext || window . webkitAudioContext ;
this . audioContext = new AudioContextClass ( { sampleRate } ) ;
console . log ( ` ✅ New AudioContext created: actual= ${ this . audioContext . sampleRate } Hz (requested= ${ sampleRate } Hz) ` ) ;
this . gainNode = this . audioContext . createGain ( ) ;
this . gainNode . connect ( this . audioContext . destination ) ;
console . log ( ` 🔊 AUDIO CONTEXT CHANGE COMPLETE ` ) ;
}
}
private addToAudioStream ( audioData : Uint8Array ) : void {
this . bufferChunks . push ( audioData ) ;
this . currentSize += audioData . length ;
console . log ( ` Added ${ audioData . length } bytes to audio stream (total: ${ this . currentSize } bytes) ` ) ;
}
private async processAudioStream ( ) : Promise < void > {
if ( ! this . wavHeader ) return ;
// Process available data (but don't over-process during active playback)
if ( this . streamingStarted && this . bufferQueue . length >= 2 ) {
console . log ( ` Buffer queue has cushion ( ${ this . bufferQueue . length } ), minimal processing ` ) ;
// Still process but be less aggressive
}
// Create sample-aligned segments from continuous audio stream
const maxSegmentSize = 64 * 1024 ; // 64KB segments to match C# chunks better
const availableBytes = this . currentSize - this . processedBytes ; // Only count unprocessed bytes
const alignedSize = WavUtils . getSampleAlignedChunkSize ( this . wavHeader , maxSegmentSize , availableBytes ) ;
if ( alignedSize > 0 ) {
console . log ( ` \ n--- CREATING ALIGNED AUDIO SEGMENT --- ` ) ;
console . log ( ` Available: ${ availableBytes } bytes, requesting: ${ alignedSize } bytes (frame-aligned, frame size: ${ this . wavHeader . blockAlign } ) ` ) ;
console . log ( ` Buffer queue: ${ this . bufferQueue . length } , processing chunk ` ) ;
// Extract sample-aligned segment from continuous stream
const alignedSegment = this . extractAlignedData ( alignedSize ) ;
const wavFile = this . createWavFromRawData ( alignedSegment ) ;
await this . createAudioBufferFromChunk ( wavFile ) ;
// Note: No longer removing processed data - we track position instead
}
}
private extractAlignedData ( alignedSize : number ) : Uint8Array {
const extracted = new Uint8Array ( alignedSize ) ;
let extractedOffset = 0 ;
let remaining = alignedSize ;
let streamPosition = this . processedBytes ; // Start from where we left off
let currentPos = 0 ;
for ( const chunk of this . bufferChunks ) {
if ( remaining <= 0 ) break ;
// Skip chunks that are entirely before our current stream position
if ( currentPos + chunk . length <= streamPosition ) {
currentPos += chunk . length ;
continue ;
}
// Calculate the offset within this chunk to start extracting
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 ;
}
// Update processed bytes position
this . processedBytes += alignedSize ;
console . log ( ` Extracted ${ alignedSize } bytes from stream position ${ streamPosition } -> ${ this . processedBytes } ` ) ;
return extracted ;
}
private removeProcessedData ( processedSize : number ) : void {
let remaining = processedSize ;
while ( remaining > 0 && this . bufferChunks . length > 0 ) {
const chunk = this . bufferChunks [ 0 ] ;
if ( chunk . length <= remaining ) {
remaining - = chunk . length ;
this . currentSize -= chunk . length ;
const firstChunk = this . bufferChunks [ 0 ] ;
if ( firstChunk . length < = remaining ) {
// Remove entire chunk
remaining -= firstChunk . length ;
this . currentSize -= firstChunk . length ;
this . bufferChunks . shift ( ) ;
} else {
// Partial chunk removal
const newChunk = chunk . slice ( remaining ) ;
// Partially remove chunk
const newChunk = firstChunk . subarray ( remaining ) ;
this . bufferChunks [ 0 ] = newChunk ;
this . currentSize -= remaining ;
remaining = 0 ;
@@ -484,6 +554,203 @@ class AudioPlayer {
}
}
private concatenateChunks ( ) : Uint8Array {
const totalSize = this . currentSize ;
const concatenated = new Uint8Array ( totalSize ) ;
let offset = 0 ;
for ( const chunk of this . bufferChunks ) {
concatenated . set ( chunk , offset ) ;
offset += chunk . length ;
}
return concatenated ;
}
private createWavFromRawData ( 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 ) ;
console . log ( ` Created WAV: header= ${ header . length } bytes, data= ${ rawData . length } bytes, total= ${ wavFile . length } bytes ` ) ;
console . log ( ` Expected duration: ${ rawData . length / this . wavHeader ! . byteRate } seconds ` ) ;
return wavFile ;
}
startStreamingPlayback ( ) : AudioResult {
if ( ! this . wavHeader || this . bufferQueue . length === 0 ) {
return { success : false , error : "Not ready for streaming playback" } ;
}
try {
console . log ( ` \ n=== STARTING STREAMING PLAYBACK === ` ) ;
console . log ( ` AudioContext state: ${ this . audioContext ! . state } ` ) ;
console . log ( ` AudioContext sample rate: ${ this . audioContext ! . sampleRate } Hz ` ) ;
console . log ( ` Current time precision: ${ this . audioContext ! . currentTime . toFixed ( 6 ) } s ` ) ;
console . log ( ` Queue ready: ${ this . bufferQueue . length } buffers, ${ this . bufferQueue . reduce ( ( sum , b ) = > sum + b . duration , 0 ) . toFixed ( 3 ) } s total ` ) ;
// AudioContext should already be resumed during track selection
const startTimestamp = performance . now ( ) ;
const audioContextTime = this . audioContext ! . currentTime ;
this . streamingStarted = true ;
this . isPlaying = true ;
this . isPaused = false ;
this . nextStartTime = audioContextTime ;
this . startTime = this . nextStartTime ;
console . log ( ` ▶️ Playback timing: audioContext= ${ audioContextTime . toFixed ( 6 ) } s, performance= ${ startTimestamp . toFixed ( 3 ) } ms ` ) ;
console . log ( ` 🎵 Initial nextStartTime set to: ${ this . nextStartTime . toFixed ( 6 ) } s ` ) ;
this . scheduleNextBuffer ( ) ;
this . startProgressTracking ( ) ;
console . log ( ` ✅ Streaming playback started successfully ` ) ;
console . log ( ` ===================================== \ n ` ) ;
return { success : true } ;
} catch ( error ) {
console . error ( ` ❌ Failed to start streaming playback: ` , error ) ;
return { success : false , error : ( error as Error ) . message } ;
}
}
private async createAudioBufferFromChunk ( chunkData : Uint8Array ) : Promise < void > {
try {
console . log ( ` createAudioBufferFromChunk: chunkData.length= ${ chunkData . length } ` ) ;
// Create a clean ArrayBuffer with exact size (avoid reusable buffer issues)
const cleanBuffer = new ArrayBuffer ( chunkData . length ) ;
new Uint8Array ( cleanBuffer ) . set ( chunkData ) ;
console . log ( ` Decoding ${ cleanBuffer . byteLength } bytes with Web Audio API ` ) ;
console . log ( 'Starting decode...' ) ;
// Try with timeout to catch hanging decodes
const decodePromise = this . audioContext ! . decodeAudioData ( cleanBuffer ) ;
const timeoutPromise = new Promise < never > ( ( _ , reject ) = > {
setTimeout ( ( ) = > reject ( new Error ( 'Decode timeout after 5 seconds' ) ) , 5000 ) ;
} ) ;
const audioBuffer = await Promise . race ( [ decodePromise , timeoutPromise ] ) ;
console . log ( "AFTER Promise.race - this should always appear after 5 seconds max" ) ;
console . log ( ` \ n--- DECODE SUCCESS --- ` ) ;
console . log ( ` Buffer duration: ${ audioBuffer . duration } s ` ) ;
console . log ( ` Buffer channels: ${ audioBuffer . numberOfChannels } ` ) ;
console . log ( ` Buffer sample rate: ${ audioBuffer . sampleRate } ` ) ;
console . log ( ` Buffer length: ${ audioBuffer . length } samples ` ) ;
// Check if buffer contains actual audio data or silence/noise
const channel0 = audioBuffer . getChannelData ( 0 ) ;
const firstSamples = Array . from ( channel0 . slice ( 0 , 10 ) ) . map ( v = > v . toFixed ( 4 ) ) ;
const maxValue = Math . max ( . . . Array . from ( channel0 ) . map ( Math . abs ) ) ;
const avgValue = Array . from ( channel0 ) . reduce ( ( sum , val ) = > sum + Math . abs ( val ) , 0 ) / channel0 . length ;
console . log ( ` First 10 samples: ` , firstSamples ) ;
console . log ( ` Max amplitude: ${ maxValue . toFixed ( 4 ) } ` ) ;
console . log ( ` Average amplitude: ${ avgValue . toFixed ( 4 ) } ` ) ;
this . bufferQueue . push ( audioBuffer ) ;
console . log ( ` \ n=== BUFFER QUEUE UPDATE === ` ) ;
console . log ( ` ✓ Added buffer: duration= ${ audioBuffer . duration . toFixed ( 6 ) } s, samples= ${ audioBuffer . length } ` ) ;
console . log ( ` Queue state: ${ this . bufferQueue . length } buffers ( ${ this . bufferQueue . map ( b = > b . duration . toFixed ( 3 ) ) . join ( 's, ' ) } s) ` ) ;
console . log ( ` Total queued audio: ${ this . bufferQueue . reduce ( ( sum , b ) = > sum + b . duration , 0 ) . toFixed ( 3 ) } s ` ) ;
console . log ( ` Streaming: started= ${ this . streamingStarted } , completed= ${ this . streamingCompleted } ` ) ;
console . log ( ` Current playback time: ${ this . audioContext ! . currentTime . toFixed ( 6 ) } s ` ) ;
// Schedule immediately when streaming has started (for gapless playback)
if ( this . streamingStarted ) {
console . log ( ` ⏩ Triggering proactive schedule (streaming active) ` ) ;
this . scheduleNextBuffer ( ) ;
} else {
console . log ( ` ⏸️ Not scheduling yet (streaming not started) ` ) ;
}
console . log ( ` =========================== \ n ` ) ;
} catch ( error ) {
console . error ( 'Error creating audio buffer from chunk:' , error ) ;
console . error ( 'Failed chunk size:' , chunkData . length ) ;
// Log first few bytes of the chunk for debugging
const preview = Array . from ( chunkData . slice ( 0 , 16 ) ) . map ( b = > b . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( ' ' ) ;
console . error ( 'Chunk preview (first 16 bytes):' , preview ) ;
}
}
private scheduleNextBuffer ( ) : void {
// Schedule all available buffers proactively instead of waiting for onended
while ( this . bufferQueue . length > 0 && this . streamingStarted ) {
const scheduleStartTime = performance . now ( ) ;
const buffer = this . bufferQueue . shift ( ) ! ;
const source = this . audioContext ! . createBufferSource ( ) ;
source . buffer = buffer ;
source . connect ( this . gainNode ! ) ;
// Critical: Use precise timing for gapless playback
const currentTime = this . audioContext ! . currentTime ;
// For the very first buffer, add small lookahead to avoid startup glitches
const startTime = this . nextStartTime > 0 ? this . nextStartTime : currentTime + 0.01 ;
const schedulingDelay = currentTime - startTime ;
console . log ( ` 🎵 Scheduling buffer: start= ${ startTime . toFixed ( 3 ) } s, duration= ${ buffer . duration . toFixed ( 3 ) } s, delay= ${ ( schedulingDelay * 1000 ) . toFixed ( 1 ) } ms ${ schedulingDelay > 0.005 ? '⚠️' : '✓' } , queue= ${ this . bufferQueue . length } ` ) ;
// Only log timing issues for debugging
const gap = Math . abs ( startTime - this . nextStartTime ) ;
if ( gap > 0.001 ) {
console . warn ( ` ⚠️ TIMING GAP: ${ ( gap * 1000 ) . toFixed ( 3 ) } ms between expected and actual start time ` ) ;
}
source . onended = ( ) = > {
const endTime = this . audioContext ! . currentTime ;
const expectedEndTime = startTime + buffer . duration ;
const timingError = Math . abs ( endTime - expectedEndTime ) ;
console . log ( ` 🏁 Buffer ended: timing error= ${ ( timingError * 1000 ) . toFixed ( 1 ) } ms ` ) ;
this . currentStreamSource = null ;
// Check for end-of-stream
if ( this . bufferQueue . length === 0 ) {
if ( this . streamingCompleted ) {
console . log ( ` ✓ End-of-stream: All buffers played at ${ endTime . toFixed ( 3 ) } s (expected) ` ) ;
} else {
console . warn ( ` ❌ Buffer underrun! Queue empty at ${ endTime . toFixed ( 3 ) } s (unexpected during streaming) ` ) ;
}
if ( ! this . isPlaying ) {
this . onEndCallback ? . ( ) ;
}
}
} ;
source . start ( startTime ) ;
// Calculate next start time with sample-perfect precision
this . nextStartTime = startTime + buffer . duration ;
this . currentStreamSource = source ;
const scheduleEndTime = performance . now ( ) ;
const scheduleProcessingTime = scheduleEndTime - scheduleStartTime ;
// Stop scheduling when we have enough buffered ahead
const lookaheadTime = this . nextStartTime - currentTime ;
if ( lookaheadTime > 0.5 ) { // Stop when we have 500ms of audio scheduled ahead
console . log ( ` 📋 Sufficient lookahead: ${ ( lookaheadTime * 1000 ) . toFixed ( 0 ) } ms scheduled ahead ` ) ;
break ;
}
}
}
unload ( ) : AudioResult {
try {
this . stop ( ) ;
@@ -491,22 +758,21 @@ class AudioPlayer {
this . duration = 0 ;
this . bufferChunks = [ ] ;
this . currentSize = 0 ;
this . expectedSize = 0 ;
this . processedBytes = 0 ; // Reset stream position
// Clean up streaming state
this . isStreamingMode = false ;
this . wavHeader = null ;
this . bufferQueue = [ ] ;
this . streamingStarted = false ;
this . streamingCompleted = false ;
this . totalStreamLength = 0 ;
this . nextStartTime = 0 ;
if ( this . currentStreamSource ) {
this . currentStreamSource . stop ( ) ;
this . currentStreamSource = null ;
}
// Clean up cached buffers
this . cachedWavHeader = null ;
this . reusableBuffer = null ;
return { success : true } ;
} catch ( error ) {
@@ -530,8 +796,6 @@ class AudioPlayer {
this . bufferQueue = [ ] ;
this . wavHeader = null ;
this . currentStreamSource = null ;
this . cachedWavHeader = null ;
this . reusableBuffer = null ;
}
}
@@ -583,20 +847,20 @@ const DeepDrftAudio = {
} ,
// Streaming methods
initializeStreaming : ( playerId : string ) : AudioResult = > {
initializeStreaming : ( playerId : string , totalStreamLength : number ) : AudioResult = > {
const player = audioPlayers . get ( playerId ) ;
if ( ! player ) {
return { success : false , error : "Player not found" } ;
}
return player . initializeStreaming ( ) ;
return player . initializeStreaming ( totalStreamLength ) ;
} ,
processStreamingChunk : ( playerId : string , audioChunk : Uint8Array ) : StreamingResult = > {
processStreamingChunk : async ( playerId : string , audioChunk : Uint8Array ) : Promise < StreamingResult > = > {
const player = audioPlayers . get ( playerId ) ;
if ( ! player ) {
return { success : false , error : "Player not found" } ;
}
return player . processStreamingChunk ( audioChunk ) ;
return await player . processStreamingChunk ( audioChunk ) ;
} ,
startStreamingPlayback : ( playerId : string ) : AudioResult = > {
@@ -607,6 +871,14 @@ const DeepDrftAudio = {
return player . startStreamingPlayback ( ) ;
} ,
ensureAudioContextReady : async ( playerId : string ) : Promise < AudioResult > = > {
const player = audioPlayers . get ( playerId ) ;
if ( ! player ) {
return { success : false , error : "Player not found" } ;
}
return await player . ensureAudioContextReady ( ) ;
} ,
play : ( playerId : string ) : AudioResult = > {
const player = audioPlayers . get ( playerId ) ;
if ( ! player ) {