Streaming Bug Fixes
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user