Streaming Bug Fixes
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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_;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "9.0.0",
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestMajor",
|
||||
"allowPrerelease": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user