From 2baf0575bc53803b5279356636b2e989135a9743 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 6 Dec 2025 06:41:32 -0500 Subject: [PATCH] Streaming Bug Fixes --- .../FileDatabase/Services/FileDatabase.cs | 5 +- .../AudioPlayerBar/AudioPlayerBar.razor | 4 +- .../AudioPlayerBar/AudioPlayerBar.razor.cs | 16 + .../Controls/AudioPlayerProvider.razor.cs | 5 +- .../Services/AudioInteropService.cs | 1 + .../Services/AudioPlayerService.cs | 2 +- .../Services/StreamingAudioPlayerService.cs | 90 +- DeepDrftWeb/Components/App.razor | 2 +- .../Interop/audio/AudioContextManager.ts | 98 ++ DeepDrftWeb/Interop/audio/AudioPlayer.ts | 338 ++++++ .../Interop/audio/PlaybackScheduler.ts | 280 +++++ DeepDrftWeb/Interop/audio/StreamDecoder.ts | 216 ++++ DeepDrftWeb/Interop/audio/index.ts | 161 +++ DeepDrftWeb/Interop/audiobuffermanager.ts | 250 +++++ DeepDrftWeb/Interop/wavutils.ts | 96 +- DeepDrftWeb/Interop/webaudio.ts | 998 +----------------- global.json | 2 +- 17 files changed, 1510 insertions(+), 1054 deletions(-) create mode 100644 DeepDrftWeb/Interop/audio/AudioContextManager.ts create mode 100644 DeepDrftWeb/Interop/audio/AudioPlayer.ts create mode 100644 DeepDrftWeb/Interop/audio/PlaybackScheduler.ts create mode 100644 DeepDrftWeb/Interop/audio/StreamDecoder.ts create mode 100644 DeepDrftWeb/Interop/audio/index.ts create mode 100644 DeepDrftWeb/Interop/audiobuffermanager.ts diff --git a/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs b/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs index a429cac..7441b4d 100644 --- a/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs +++ b/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs @@ -1,6 +1,5 @@ using DeepDrftContent.Services.FileDatabase.Models; using DeepDrftContent.Services.FileDatabase.Utils; -using IndexType = DeepDrftContent.Services.FileDatabase.Services.IndexType; namespace DeepDrftContent.Services.FileDatabase.Services; @@ -21,7 +20,7 @@ public class FileDatabase : DirectoryIndexDirectory if (rootIndex != null) { - var db = new FileDatabase(rootPath, (DirectoryIndex)rootIndex); + var db = new FileDatabase(rootPath, rootIndex); await db.InitVaultsAsync(); return db; } @@ -29,7 +28,7 @@ public class FileDatabase : DirectoryIndexDirectory return null; } - private FileDatabase(string rootPath, DirectoryIndex index) : base(rootPath, index) + private FileDatabase(string rootPath, IDirectoryIndex index) : base(rootPath, index) { _vaults = new StructuralMap(); } diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index 5e65363..f01c528 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -42,7 +42,7 @@ else Step="0.1" Value="@CurrentTime" ValueChanged="@OnSeek" - Disabled="!IsLoaded"/> + Disabled="@(!IsLoaded || IsStreamingMode)"/>
@@ -77,7 +77,7 @@ else Step="0.1" Value="@CurrentTime" ValueChanged="@OnSeek" - Disabled="!IsLoaded"/> + Disabled="@(!IsLoaded || IsStreamingMode)"/>
@* Control Buttons - positioned absolutely like original *@ diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs index ea4f554..3ac2f66 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs +++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs @@ -13,6 +13,7 @@ public partial class AudioPlayerBar : ComponentBase private bool IsLoaded => PlayerService.IsLoaded; private bool IsLoading => PlayerService.IsLoading; private bool IsStreaming => PlayerService.CanStartStreaming; + private bool IsStreamingMode => PlayerService.IsStreamingMode; private bool IsPlaying => PlayerService.IsPlaying; private bool IsPaused => PlayerService.IsPaused; private double CurrentTime => PlayerService.CurrentTime; @@ -26,6 +27,21 @@ public partial class AudioPlayerBar : ComponentBase await base.OnInitializedAsync(); // Set up EventCallback for track selection PlayerService.OnTrackSelected = new EventCallback(this, Expand); + + // Store the original OnStateChanged callback set by the provider + var originalOnStateChanged = PlayerService.OnStateChanged; + + // Set up a wrapper that calls both the original callback and our StateHasChanged + PlayerService.OnStateChanged = new EventCallback(this, async () => + { + // Invoke the original callback (AudioPlayerProvider's StateHasChanged) + if (originalOnStateChanged.HasValue) + { + await originalOnStateChanged.Value.InvokeAsync(); + } + // Also trigger our own re-render + await InvokeAsync(StateHasChanged); + }); } private async Task Expand() diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerProvider.razor.cs b/DeepDrftWeb.Client/Controls/AudioPlayerProvider.razor.cs index 38e5d5e..3735268 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerProvider.razor.cs +++ b/DeepDrftWeb.Client/Controls/AudioPlayerProvider.razor.cs @@ -19,9 +19,10 @@ public partial class AudioPlayerProvider : ComponentBase { // Create the service immediately (but don't initialize yet) _audioPlayerService = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger); - + // Set up EventCallback to properly marshal UI updates back to UI thread - _audioPlayerService.OnStateChanged = new EventCallback(this, StateHasChanged); + // Use InvokeAsync to ensure proper Blazor render cycle triggering + _audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged)); // OnTrackSelected will be set by individual child components that need it } diff --git a/DeepDrftWeb.Client/Services/AudioInteropService.cs b/DeepDrftWeb.Client/Services/AudioInteropService.cs index e1218b3..d81e7fe 100644 --- a/DeepDrftWeb.Client/Services/AudioInteropService.cs +++ b/DeepDrftWeb.Client/Services/AudioInteropService.cs @@ -230,6 +230,7 @@ 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 } public class AudioPlayerState diff --git a/DeepDrftWeb.Client/Services/AudioPlayerService.cs b/DeepDrftWeb.Client/Services/AudioPlayerService.cs index 7e8b859..6a63d40 100644 --- a/DeepDrftWeb.Client/Services/AudioPlayerService.cs +++ b/DeepDrftWeb.Client/Services/AudioPlayerService.cs @@ -238,7 +238,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable } } - public async Task Stop() + public virtual async Task Stop() { if (!IsLoaded) return; diff --git a/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs index 0b54902..88ff132 100644 --- a/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs @@ -60,40 +60,24 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS private async Task LoadTrackStreaming(TrackEntity track) { - // Cancel and replace any previous streaming operation atomically - var oldCancellation = _streamingCancellation; + // Always reset to clean state before loading new track + await ResetToIdle(); + + // Create new cancellation token for this streaming operation _streamingCancellation = new CancellationTokenSource(); - - // Cancel the old operation after we've replaced it - oldCancellation?.Cancel(); - oldCancellation?.Dispose(); - + try { - // No need to check IsLoading - we cancel previous operations - - if (IsPlaying || IsPaused) - { - await Unload(); - } - - // Reset state to indicate streaming has started + // Set state to indicate loading has started ErrorMessage = null; LoadProgress = 0; - IsLoaded = false; IsLoading = true; IsStreamingMode = true; - CanStartStreaming = false; - HeaderParsed = false; - BufferedChunks = 0; - _streamingPlaybackStarted = false; - Duration = null; - CurrentTime = 0; - + // Reset adaptive buffer sizing _currentBufferSize = DefaultBufferSize; _consecutiveSlowReads = 0; - + await NotifyStateChanged(); var mediaResult = await _trackMediaClient.GetTrackMedia(track.EntryKey); @@ -190,6 +174,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS CanStartStreaming = chunkResult.CanStartStreaming; HeaderParsed = chunkResult.HeaderParsed; BufferedChunks = chunkResult.BufferCount; + + // Set duration from WAV header when available (only set once) + if (chunkResult.Duration.HasValue && Duration == null) + { + Duration = chunkResult.Duration.Value; + _logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration); + } // Start playback as soon as we can if (!_streamingPlaybackStarted && CanStartStreaming) @@ -245,20 +236,63 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS } } + /// + /// In streaming mode, Stop fully resets to Idle state since audio data is consumed. + /// This is equivalent to Unload for streaming playback. + /// + public override async Task Stop() + { + // In streaming mode, Stop = Unload (data is consumed, can't replay) + await ResetToIdle(); + } + + /// + /// Fully resets the player to Idle state, ready for a new track. + /// public override async Task Unload() { - // Cancel any ongoing streaming operation + await ResetToIdle(); + } + + /// + /// Single method to reset all state - called by both Stop and Unload. + /// + private async Task ResetToIdle() + { + // 1. Cancel any ongoing streaming operation _streamingCancellation?.Cancel(); _streamingCancellation?.Dispose(); _streamingCancellation = null; - + + // 2. Tell JS to stop and unload + try + { + await _audioInterop.StopAsync(PlayerId); + await _audioInterop.UnloadAsync(PlayerId); + } + catch + { + // Ignore JS errors during cleanup + } + + // 3. Reset ALL state to Idle + IsPlaying = false; + IsPaused = false; + IsLoaded = false; + IsLoading = false; + CurrentTime = 0; + Duration = null; + LoadProgress = 0; + ErrorMessage = null; + + // 4. Reset streaming-specific state IsStreamingMode = false; CanStartStreaming = false; HeaderParsed = false; BufferedChunks = 0; _streamingPlaybackStarted = false; - - await base.Unload(); + + await NotifyStateChanged(); } private async Task ThrottledNotifyStateChanged() diff --git a/DeepDrftWeb/Components/App.razor b/DeepDrftWeb/Components/App.razor index b5dce81..f970b6c 100644 --- a/DeepDrftWeb/Components/App.razor +++ b/DeepDrftWeb/Components/App.razor @@ -24,7 +24,7 @@ diff --git a/DeepDrftWeb/Interop/audio/AudioContextManager.ts b/DeepDrftWeb/Interop/audio/AudioContextManager.ts new file mode 100644 index 0000000..e45b6a4 --- /dev/null +++ b/DeepDrftWeb/Interop/audio/AudioContextManager.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/DeepDrftWeb/Interop/audio/AudioPlayer.ts b/DeepDrftWeb/Interop/audio/AudioPlayer.ts new file mode 100644 index 0000000..15f8733 --- /dev/null +++ b/DeepDrftWeb/Interop/audio/AudioPlayer.ts @@ -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 { + try { + await this.contextManager.initialize(); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + async ensureAudioContextReady(): Promise { + 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 { + 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(); + } +} diff --git a/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts b/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts new file mode 100644 index 0000000..37e769f --- /dev/null +++ b/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts @@ -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_; + } +} diff --git a/DeepDrftWeb/Interop/audio/StreamDecoder.ts b/DeepDrftWeb/Interop/audio/StreamDecoder.ts new file mode 100644 index 0000000..7215aa0 --- /dev/null +++ b/DeepDrftWeb/Interop/audio/StreamDecoder.ts @@ -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 { + 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 { + 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 { + 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 { + const buffer = new ArrayBuffer(wavData.length); + new Uint8Array(buffer).set(wavData); + + const decodePromise = this.contextManager.decodeAudioData(buffer); + const timeoutPromise = new Promise((_, 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; + } +} diff --git a/DeepDrftWeb/Interop/audio/index.ts b/DeepDrftWeb/Interop/audio/index.ts new file mode 100644 index 0000000..548aa2d --- /dev/null +++ b/DeepDrftWeb/Interop/audio/index.ts @@ -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(); + +// .NET interop type +interface DotNetObjectReference { + invokeMethodAsync(methodName: string, ...args: unknown[]): Promise; +} + +// Global API exposed to Blazor +const DeepDrftAudio = { + createPlayer: async (playerId: string): Promise => { + 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 => { + 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 => { + 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 => { + return { success: true }; // No-op for streaming mode + } +}; + +// Expose to window +declare global { + interface Window { + DeepDrftAudio: typeof DeepDrftAudio; + } +} + +window.DeepDrftAudio = DeepDrftAudio; + +export { DeepDrftAudio }; diff --git a/DeepDrftWeb/Interop/audiobuffermanager.ts b/DeepDrftWeb/Interop/audiobuffermanager.ts new file mode 100644 index 0000000..4bbb4d0 --- /dev/null +++ b/DeepDrftWeb/Interop/audiobuffermanager.ts @@ -0,0 +1,250 @@ +/** + * AudioBufferManager - Encapsulates all audio buffer storage and scheduling logic. + * + * Responsibilities: + * - Store decoded AudioBuffers (retained for pause/resume/seek) + * - Track playback position + * - Schedule buffers for playback from any position + * - Handle pause/resume without losing audio data + */ + +export interface ScheduledBuffer { + source: AudioBufferSourceNode; + startTime: number; // AudioContext time when this buffer starts + duration: number; // Duration of this buffer + bufferIndex: number; // Index in decodedBuffers array +} + +export class AudioBufferManager { + private decodedBuffers: AudioBuffer[] = []; + private scheduledSources: ScheduledBuffer[] = []; + private audioContext: AudioContext; + private gainNode: GainNode; + + // Playback state + private playbackStartTime: number = 0; // AudioContext.currentTime when playback started + private playbackStartPosition: number = 0; // Position in audio (seconds) where playback started + private nextScheduleIndex: number = 0; // Next buffer index to schedule during streaming + private nextScheduleTime: number = 0; // AudioContext time for next buffer + + // Callbacks + public onBufferEnded: (() => void) | null = null; + public onAllBuffersPlayed: (() => void) | null = null; + + constructor(audioContext: AudioContext, gainNode: GainNode) { + this.audioContext = audioContext; + this.gainNode = gainNode; + } + + /** + * Add a newly decoded buffer to storage + */ + addBuffer(buffer: AudioBuffer): void { + this.decodedBuffers.push(buffer); + console.log(`đŸ“Ļ Buffer added: index=${this.decodedBuffers.length - 1}, duration=${buffer.duration.toFixed(3)}s, total=${this.getTotalDuration().toFixed(3)}s`); + } + + /** + * Get total duration of all stored buffers + */ + getTotalDuration(): number { + return this.decodedBuffers.reduce((sum, b) => sum + b.duration, 0); + } + + /** + * Get number of stored buffers + */ + getBufferCount(): number { + return this.decodedBuffers.length; + } + + /** + * Get current playback position in seconds + */ + getCurrentPosition(): number { + if (this.playbackStartTime === 0) { + return this.playbackStartPosition; + } + const elapsed = this.audioContext.currentTime - this.playbackStartTime; + return this.playbackStartPosition + elapsed; + } + + /** + * Schedule playback from a specific position (used for play, resume, seek) + */ + scheduleFromPosition(position: number): void { + // Stop any currently scheduled sources + this.stopAllScheduled(); + + // Find which buffer contains this position + let accumulatedTime = 0; + let startBufferIndex = 0; + let offsetInBuffer = 0; + + for (let i = 0; i < this.decodedBuffers.length; i++) { + const bufferDuration = this.decodedBuffers[i].duration; + if (accumulatedTime + bufferDuration > position) { + startBufferIndex = i; + offsetInBuffer = position - accumulatedTime; + break; + } + accumulatedTime += bufferDuration; + startBufferIndex = i + 1; + } + + console.log(`đŸŽ¯ Scheduling from position ${position.toFixed(3)}s: buffer[${startBufferIndex}] offset=${offsetInBuffer.toFixed(3)}s`); + + // Record playback start reference + this.playbackStartPosition = position; + this.playbackStartTime = this.audioContext.currentTime; + this.nextScheduleTime = this.audioContext.currentTime + 0.01; // Small lookahead + + // Schedule buffers starting from the found position + this.scheduleBuffersFrom(startBufferIndex, offsetInBuffer); + } + + /** + * Schedule pending buffers during live streaming (called when new buffers arrive) + */ + schedulePendingBuffers(): void { + if (this.nextScheduleIndex >= this.decodedBuffers.length) { + return; // No new buffers to schedule + } + + // If this is the first scheduling, initialize timing + if (this.nextScheduleTime === 0) { + this.nextScheduleTime = this.audioContext.currentTime + 0.01; + } + + this.scheduleBuffersFrom(this.nextScheduleIndex, 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 + + for (let i = startIndex; i < this.decodedBuffers.length; i++) { + const buffer = this.decodedBuffers[i]; + const isFirstBuffer = (i === startIndex && offsetInFirstBuffer > 0); + const offset = isFirstBuffer ? offsetInFirstBuffer : 0; + const duration = buffer.duration - offset; + + // Create and configure source + const source = this.audioContext.createBufferSource(); + source.buffer = buffer; + source.connect(this.gainNode); + + // Set up ended callback + const bufferIndex = i; + source.onended = () => this.handleBufferEnded(bufferIndex); + + // Schedule the source + source.start(this.nextScheduleTime, offset); + + // Track the scheduled source + this.scheduledSources.push({ + source, + startTime: this.nextScheduleTime, + duration, + bufferIndex: i + }); + + console.log(`đŸŽĩ Scheduled buffer[${i}]: start=${this.nextScheduleTime.toFixed(3)}s, offset=${offset.toFixed(3)}s, duration=${duration.toFixed(3)}s`); + + // Update timing for next buffer + this.nextScheduleTime += duration; + this.nextScheduleIndex = i + 1; + + // Check if we have enough lookahead + const lookahead = this.nextScheduleTime - this.audioContext.currentTime; + if (lookahead > lookaheadTarget) { + console.log(`📋 Sufficient lookahead: ${(lookahead * 1000).toFixed(0)}ms`); + break; + } + } + } + + /** + * Handle a buffer finishing playback + */ + private handleBufferEnded(bufferIndex: number): void { + // Remove from scheduled list + this.scheduledSources = this.scheduledSources.filter(s => s.bufferIndex !== bufferIndex); + + this.onBufferEnded?.(); + + // Check if all buffers have finished + if (this.scheduledSources.length === 0 && this.nextScheduleIndex >= this.decodedBuffers.length) { + console.log(`✓ All buffers played`); + this.onAllBuffersPlayed?.(); + } + } + + /** + * Stop all scheduled sources (for pause/stop) + */ + stopAllScheduled(): void { + for (const scheduled of this.scheduledSources) { + try { + scheduled.source.stop(); + } catch (e) { + // Source may already be stopped + } + } + this.scheduledSources = []; + console.log(`âšī¸ Stopped all scheduled sources`); + } + + /** + * Pause playback - saves position and stops sources + */ + pause(): number { + const position = this.getCurrentPosition(); + this.stopAllScheduled(); + this.playbackStartPosition = position; + this.playbackStartTime = 0; + console.log(`â¸ī¸ Paused at ${position.toFixed(3)}s`); + return position; + } + + /** + * Reset to beginning (for stop) + */ + resetToStart(): void { + this.stopAllScheduled(); + this.playbackStartPosition = 0; + this.playbackStartTime = 0; + this.nextScheduleIndex = 0; + this.nextScheduleTime = 0; + console.log(`âŽī¸ Reset to start`); + } + + /** + * Full reset - clears all buffers (for unload/new track) + */ + clear(): void { + this.stopAllScheduled(); + this.decodedBuffers = []; + this.playbackStartPosition = 0; + this.playbackStartTime = 0; + this.nextScheduleIndex = 0; + this.nextScheduleTime = 0; + console.log(`đŸ—‘ī¸ Buffer manager cleared`); + } + + /** + * Check if we have any buffers + */ + hasBuffers(): boolean { + return this.decodedBuffers.length > 0; + } + + /** + * Check if we have enough buffers to start playback + */ + hasMinimumBuffers(minCount: number): boolean { + return this.decodedBuffers.length >= minCount; + } +} diff --git a/DeepDrftWeb/Interop/wavutils.ts b/DeepDrftWeb/Interop/wavutils.ts index a83913e..7ff70fa 100644 --- a/DeepDrftWeb/Interop/wavutils.ts +++ b/DeepDrftWeb/Interop/wavutils.ts @@ -19,8 +19,9 @@ class WavUtils { offset += chunk.length; } - const view = new DataView(concatenated.buffer, 0, 44); - + // Need a DataView that spans the entire buffer for chunk searching + const view = new DataView(concatenated.buffer); + // Check RIFF header const riff = new TextDecoder().decode(concatenated.slice(0, 4)); if (riff !== 'RIFF') return null; @@ -28,45 +29,76 @@ class WavUtils { const wave = new TextDecoder().decode(concatenated.slice(8, 12)); if (wave !== 'WAVE') return null; - // Find fmt chunk with better alignment handling - let fmtOffset = 12; - while (fmtOffset < totalSize - 8) { - const chunkId = new TextDecoder().decode(concatenated.slice(fmtOffset, fmtOffset + 4)); - const chunkSize = view.getUint32(fmtOffset + 4, true); - + // Variables to store parsed header info + let sampleRate = 0; + let channels = 0; + let bitsPerSample = 0; + let byteRate = 0; + let blockAlign = 0; + let dataSize = 0; + let headerSize = 0; + let foundFmt = false; + let foundData = false; + + // Find fmt and data chunks + let chunkOffset = 12; + while (chunkOffset < totalSize - 8) { + const chunkId = new TextDecoder().decode(concatenated.slice(chunkOffset, chunkOffset + 4)); + const chunkSize = view.getUint32(chunkOffset + 4, true); + if (chunkId === 'fmt ') { // Validate minimum fmt chunk size if (chunkSize < 16) return null; - - const audioFormat = view.getUint16(fmtOffset + 8, true); - if (audioFormat !== 1) return null; // Only PCM supported - - const channels = view.getUint16(fmtOffset + 10, true); - const sampleRate = view.getUint32(fmtOffset + 12, true); - const byteRate = view.getUint32(fmtOffset + 16, true); - const blockAlign = view.getUint16(fmtOffset + 20, true); - const bitsPerSample = view.getUint16(fmtOffset + 22, true); - + + const audioFormat = view.getUint16(chunkOffset + 8, true); + // Support PCM (1) and IEEE Float (3) formats + if (audioFormat !== 1 && audioFormat !== 3) { + console.warn(`Unsupported audio format: ${audioFormat} (only PCM=1 and IEEE Float=3 supported)`); + return null; + } + + channels = view.getUint16(chunkOffset + 10, true); + sampleRate = view.getUint32(chunkOffset + 12, true); + byteRate = view.getUint32(chunkOffset + 16, true); + blockAlign = view.getUint16(chunkOffset + 20, true); + bitsPerSample = view.getUint16(chunkOffset + 22, true); + // Basic validation if (channels < 1 || channels > 8) return null; if (blockAlign !== channels * (bitsPerSample / 8)) return null; - - return { - sampleRate, - channels, - bitsPerSample, - byteRate, - blockAlign, - dataSize: 0, // Will be updated when we find data chunk - headerSize: 44 - }; + + foundFmt = true; + console.log(`Found fmt chunk: ${bitsPerSample}-bit, ${channels}ch, ${sampleRate}Hz, format=${audioFormat}`); } - - // Move to next chunk with proper alignment - fmtOffset += 8 + ((chunkSize + 1) & ~1); // Ensure even alignment + else if (chunkId === 'data') { + dataSize = chunkSize; + headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes) + foundData = true; + console.log(`Found data chunk at offset ${chunkOffset}, headerSize=${headerSize}, dataSize=${dataSize}`); + } + + // Move to next chunk with proper alignment (chunks are word-aligned) + chunkOffset += 8 + ((chunkSize + 1) & ~1); + + // If we found both chunks, we're done + if (foundFmt && foundData) break; } - return null; + // Must have found both fmt and data chunks + if (!foundFmt || !foundData) { + console.warn(`WAV parsing incomplete: foundFmt=${foundFmt}, foundData=${foundData}`); + return null; + } + + return { + sampleRate, + channels, + bitsPerSample, + byteRate, + blockAlign, + dataSize, + headerSize + }; } static createHeader(wavHeader: WavHeader, dataSize: number): Uint8Array { diff --git a/DeepDrftWeb/Interop/webaudio.ts b/DeepDrftWeb/Interop/webaudio.ts index af0908d..71fed02 100644 --- a/DeepDrftWeb/Interop/webaudio.ts +++ b/DeepDrftWeb/Interop/webaudio.ts @@ -1,984 +1,14 @@ -interface AudioResult { - success: boolean; - error?: string; -} - -interface LoadAudioResult extends AudioResult { - duration?: number; - sampleRate?: number; - numberOfChannels?: number; - loadProgress?: number; -} - -import { WavHeader, WavUtils } from './wavutils.js'; - -interface StreamingResult extends AudioResult { - canStartStreaming?: boolean; - headerParsed?: boolean; - bufferCount?: number; -} - -interface AudioState { - isPlaying: boolean; - isPaused: boolean; - currentTime: number; - duration: number; - volume: number; - loadProgress: number; -} - -type ProgressCallback = (currentTime: number) => void; -type EndCallback = () => void; -type DecodeSuccessCallback = (audioBuffer: AudioBuffer) => void; -type DecodeErrorCallback = (error: DOMException) => void; - -declare global { - interface Window { - webkitAudioContext?: new() => AudioContext; - DeepDrftAudio: typeof DeepDrftAudio; - } - - interface AudioContext { - decodeAudioData(audioData: ArrayBuffer | SharedArrayBuffer): Promise; - decodeAudioData(audioData: ArrayBuffer | SharedArrayBuffer, successCallback?: DecodeSuccessCallback, errorCallback?: DecodeErrorCallback): Promise; - } -} - -class AudioPlayer { - private audioContext: AudioContext | null = null; - private audioBuffer: AudioBuffer | null = null; - private source: AudioBufferSourceNode | null = null; - private gainNode: GainNode | null = null; - private isPlaying: boolean = false; - private isPaused: boolean = false; - private startTime: number = 0; - private pauseOffset: number = 0; - private duration: number = 0; - private onProgressCallback: ProgressCallback | null = null; - private onEndCallback: EndCallback | null = null; - private progressInterval: number | null = null; - private bufferChunks: Uint8Array[] = []; - 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; - private bufferQueue: AudioBuffer[] = []; - private currentStreamSource: AudioBufferSourceNode | null = null; - private nextStartTime: number = 0; - private streamingStarted: boolean = false; - 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 - - - async initialize(): Promise { - try { - const AudioContextClass = window.AudioContext || window.webkitAudioContext; - if (!AudioContextClass) { - throw new Error('Web Audio API not supported'); - } - - // 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 { - 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 }; - } - } - - initializeBuffered(): AudioResult { - try { - this.bufferChunks = []; - this.currentSize = 0; - return { success: true }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - appendAudioBlock(audioBlock: Uint8Array): AudioResult { - try { - this.bufferChunks.push(audioBlock); - this.currentSize += audioBlock.length; - return { success: true }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - async finalizeAudioBuffer(): Promise { - try { - const arrayBuffer = new ArrayBuffer(this.currentSize); - const view = new Uint8Array(arrayBuffer); - let offset = 0; - - for (const chunk of this.bufferChunks) { - view.set(chunk, offset); - offset += chunk.length; - } - - this.audioBuffer = await this.audioContext!.decodeAudioData(arrayBuffer); - this.duration = this.audioBuffer.duration; - - this.bufferChunks = []; - this.currentSize = 0; - - return { - success: true, - duration: this.duration, - sampleRate: this.audioBuffer.sampleRate, - numberOfChannels: this.audioBuffer.numberOfChannels, - loadProgress: 100 - }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - - play(): AudioResult { - if (!this.audioBuffer) { - return { success: false, error: "No audio loaded" }; - } - - try { - if (this.audioContext!.state === 'suspended') { - this.audioContext!.resume(); - } - - this.source = this.audioContext!.createBufferSource(); - this.source.buffer = this.audioBuffer; - this.source.connect(this.gainNode!); - - this.source.onended = () => { - this.isPlaying = false; - this.isPaused = false; - this.startTime = 0; - this.pauseOffset = 0; - if (this.onEndCallback) { - this.onEndCallback(); - } - }; - - if (this.isPaused) { - this.source.start(0, this.pauseOffset); - this.startTime = this.audioContext!.currentTime - this.pauseOffset; - } else { - this.source.start(0); - this.startTime = this.audioContext!.currentTime; - } - - this.isPlaying = true; - this.isPaused = false; - this.startProgressTracking(); - - return { success: true }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - pause(): AudioResult { - if (!this.isPlaying) { - return { success: false, error: "Audio is not playing" }; - } - - try { - this.source!.stop(); - this.pauseOffset += this.audioContext!.currentTime - this.startTime; - this.isPlaying = false; - this.isPaused = true; - this.stopProgressTracking(); - - return { success: true }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - stop(): AudioResult { - try { - if (this.source) { - this.source.stop(); - } - this.isPlaying = false; - this.isPaused = false; - this.startTime = 0; - this.pauseOffset = 0; - this.stopProgressTracking(); - - return { success: true }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - seek(position: number): AudioResult { - if (!this.audioBuffer || position < 0 || position > this.duration) { - return { success: false, error: "Invalid seek position" }; - } - - try { - const wasPlaying = this.isPlaying; - - if (this.isPlaying) { - this.source!.stop(); - } - - this.pauseOffset = position; - - if (wasPlaying) { - this.source = this.audioContext!.createBufferSource(); - this.source.buffer = this.audioBuffer; - this.source.connect(this.gainNode!); - - this.source.onended = () => { - this.isPlaying = false; - this.isPaused = false; - this.startTime = 0; - this.pauseOffset = 0; - if (this.onEndCallback) { - this.onEndCallback(); - } - }; - - this.source.start(0, position); - this.startTime = this.audioContext!.currentTime - position; - } else { - this.isPaused = true; - } - - return { success: true }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - setVolume(volume: number): AudioResult { - if (!this.gainNode) { - return { success: false, error: "Audio not initialized" }; - } - - try { - const clampedVolume = Math.max(0, Math.min(1, volume)); - this.gainNode.gain.setValueAtTime(clampedVolume, this.audioContext!.currentTime); - return { success: true }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - getCurrentTime(): number { - if (!this.isPlaying && !this.isPaused) { - return 0; - } - - if (this.isPlaying) { - return Math.min(this.pauseOffset + (this.audioContext!.currentTime - this.startTime), this.duration); - } else { - return this.pauseOffset; - } - } - - getState(): AudioState { - return { - isPlaying: this.isPlaying, - isPaused: this.isPaused, - currentTime: this.getCurrentTime(), - duration: this.duration, - volume: this.gainNode ? this.gainNode.gain.value : 0, - loadProgress: 100 - }; - } - - private startProgressTracking(): void { - this.stopProgressTracking(); - this.progressInterval = setInterval(() => { - if (this.onProgressCallback) { - this.onProgressCallback(this.getCurrentTime()); - } - }, 100); - } - - private stopProgressTracking(): void { - if (this.progressInterval) { - clearInterval(this.progressInterval); - this.progressInterval = null; - } - } - - setOnProgressCallback(callback: ProgressCallback): void { - this.onProgressCallback = callback; - } - - setOnEndCallback(callback: EndCallback): void { - this.onEndCallback = callback; - } - - 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 }; - } - } - - private chunkCounter = 0; - - async processStreamingChunk(audioChunk: Uint8Array): Promise { - try { - 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}`); - - await this.processChunk(audioChunk); - - // 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; - - return { - success: true, - canStartStreaming: canStart, - headerParsed: this.wavHeader !== null, - bufferCount: this.bufferQueue.length - }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - - private isFirstChunk = true; - - private async processChunk(audioChunk: Uint8Array): Promise { - 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); - } - - await this.processAudioStream(); - } - - private async extractAudioFromFirstChunk(chunkData: Uint8Array): Promise { - 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`); - - // 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 { - 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 { - 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 firstChunk = this.bufferChunks[0]; - - if (firstChunk.length <= remaining) { - // Remove entire chunk - remaining -= firstChunk.length; - this.currentSize -= firstChunk.length; - this.bufferChunks.shift(); - } else { - // Partially remove chunk - const newChunk = firstChunk.subarray(remaining); - this.bufferChunks[0] = newChunk; - this.currentSize -= remaining; - remaining = 0; - } - } - } - - 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 { - 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((_, 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(); - this.audioBuffer = null; - this.duration = 0; - this.bufferChunks = []; - this.currentSize = 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; - } - - - return { success: true }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - dispose(): void { - this.stop(); - this.stopProgressTracking(); - if (this.audioContext && this.audioContext.state !== 'closed') { - this.audioContext.close(); - } - this.audioContext = null; - this.audioBuffer = null; - this.gainNode = null; - this.bufferChunks = []; - this.currentSize = 0; - - // Clean up streaming state - this.bufferQueue = []; - this.wavHeader = null; - this.currentStreamSource = null; - } -} - -// Global player instances -const audioPlayers = new Map(); - -// Define .NET interop types -interface DotNetObjectReference { - invokeMethodAsync(methodName: string, ...args: any[]): Promise; -} - -// JavaScript interop functions for Blazor -const DeepDrftAudio = { - createPlayer: async (playerId: string): Promise => { - 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 }; - } - }, - - initializeBufferedPlayer: (playerId: string): AudioResult => { - const player = audioPlayers.get(playerId); - if (!player) { - return { success: false, error: "Player not found" }; - } - return player.initializeBuffered(); - }, - - appendAudioBlock: (playerId: string, audioBlock: Uint8Array): AudioResult => { - const player = audioPlayers.get(playerId); - if (!player) { - return { success: false, error: "Player not found" }; - } - return player.appendAudioBlock(audioBlock); - }, - - finalizeAudioBuffer: async (playerId: string): Promise => { - const player = audioPlayers.get(playerId); - if (!player) { - return { success: false, error: "Player not found" }; - } - return await player.finalizeAudioBuffer(); - }, - - // Streaming methods - 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, audioChunk: Uint8Array): Promise => { - const player = audioPlayers.get(playerId); - if (!player) { - return { success: false, error: "Player not found" }; - } - return await player.processStreamingChunk(audioChunk); - }, - - 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 => { - 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) { - 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); - if (!player) { - return 0; - } - return player.getCurrentTime(); - }, - - getState: (playerId: string): AudioState | null => { - const player = audioPlayers.get(playerId); - if (!player) { - return null; - } - return player.getState(); - }, - - setOnProgressCallback: (playerId: string, dotNetObjectReference: DotNetObjectReference, methodName: string): AudioResult => { - const player = audioPlayers.get(playerId); - if (!player) { - return { success: false, error: "Player not found" }; - } - - player.setOnProgressCallback((currentTime: number) => { - dotNetObjectReference.invokeMethodAsync(methodName, currentTime); - }); - - return { success: true }; - }, - - setOnEndCallback: (playerId: string, dotNetObjectReference: DotNetObjectReference, methodName: string): AudioResult => { - const player = audioPlayers.get(playerId); - if (!player) { - return { success: false, error: "Player not found" }; - } - - player.setOnEndCallback(() => { - dotNetObjectReference.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" }; - } -}; - -// Assign to window for global access -window.DeepDrftAudio = DeepDrftAudio; \ No newline at end of file +/** + * webaudio.ts - Legacy entry point for Blazor Audio Interop + * + * This file now delegates to the SOLID audio architecture in ./audio/ + * All functionality is provided by the new modular classes: + * - AudioContextManager: Web Audio API context and routing + * - StreamDecoder: WAV parsing and decoding + * - PlaybackScheduler: Buffer storage and playback scheduling + * - AudioPlayer: Main orchestrator + */ + +// Re-export from the new SOLID architecture +export { DeepDrftAudio } from './audio/index.js'; +export { AudioPlayer, AudioResult, StreamingResult, AudioState } from './audio/AudioPlayer.js'; diff --git a/global.json b/global.json index f4fd385..e69d70f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.100", "rollForward": "latestMajor", "allowPrerelease": true }