Streaming Bug Fixes

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