refactor(split): rename DeepDrftWeb -> DeepDrftPublic and DeepDrftWeb.Client -> DeepDrftPublic.Client (Phase 4)

This commit is contained in:
Daniel Harvey
2026-05-19 23:06:16 -04:00
parent a981a99978
commit e5b4a79727
83 changed files with 116 additions and 116 deletions
@@ -0,0 +1,118 @@
/**
* AudioContextManager - Manages the Web Audio API AudioContext and GainNode.
*
* Single Responsibility: AudioContext lifecycle and audio routing.
*
* Audio chain: Source → GainNode → AnalyserNode → destination
*/
import { SpectrumAnalyzer } from './SpectrumAnalyzer.js';
export class AudioContextManager {
private audioContext: AudioContext | null = null;
private gainNode: GainNode | null = null;
private spectrumAnalyzer: SpectrumAnalyzer;
constructor() {
this.spectrumAnalyzer = new SpectrumAnalyzer();
}
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();
// Initialize spectrum analyzer and insert into chain
// Chain: Source → GainNode → AnalyserNode → destination
const analyserNode = this.spectrumAnalyzer.initialize(this.audioContext);
this.gainNode.connect(analyserNode);
analyserNode.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);
}
getSpectrumAnalyzer(): SpectrumAnalyzer {
return this.spectrumAnalyzer;
}
dispose(): void {
this.spectrumAnalyzer.dispose();
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close();
}
this.audioContext = null;
this.gainNode = null;
}
}
+518
View File
@@ -0,0 +1,518 @@
/**
* 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;
seekBeyondBuffer?: boolean;
byteOffset?: number;
}
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 {
// Full cleanup before starting new stream
this.stopProgressTracking();
this.scheduler.clear();
this.streamDecoder.reset();
this.resetState();
// Initialize new stream
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 };
}
}
/**
* Signal to the decoder that the C# streaming loop has finished sending bytes.
* This sets streamComplete=true and flushes any remaining decoded tail segments.
* Must be called after the ReadAsync loop exits, regardless of whether
* Content-Length was known — without it the tail-decode path is dead when
* Content-Length is absent.
*/
async markStreamComplete(): Promise<StreamingResult> {
try {
const results = await this.streamDecoder.markStreamComplete();
if (results.length > 0) {
for (const result of results) {
this.scheduler.addBuffer(result.buffer);
}
if (this.streamingStarted && this.isPlaying) {
this.scheduler.scheduleNewBuffers();
}
}
this.streamingCompleted = true;
console.log('Stream marked complete by C# signal');
return { success: true, bufferCount: this.scheduler.getBufferCount() };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async processStreamingChunk(chunk: Uint8Array): Promise<StreamingResult> {
try {
const results = await this.streamDecoder.processChunk(chunk);
if (results.length > 0) {
for (const result of results) {
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 };
}
}
async startStreamingPlayback(): Promise<AudioResult> {
if (!this.scheduler.hasBuffers()) {
return { success: false, error: 'No buffers available' };
}
try {
console.log('\n=== Starting streaming playback ===');
// A backgrounded tab leaves AudioContext suspended. createBufferSource/start
// against a suspended context produces no audio without throwing — the same
// failure mode that was fixed for play() (resume path). Awaiting ensureReady()
// here guarantees the context is running before playFromPosition schedules
// any AudioBufferSourceNodes.
await this.contextManager.ensureReady();
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 ====================
async play(): Promise<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 {
// Must await: a backgrounded tab leaves AudioContext suspended, and
// createBufferSource/source.start against a suspended context produces
// no audio without throwing. Firing ensureReady() without await meant
// play() returned success but the user heard nothing.
await 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' };
}
// Get buffered duration (accounting for playback offset)
const bufferedDuration = this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset();
// Check if seeking within buffered content
if (position <= bufferedDuration) {
return this.seekWithinBuffer(position);
} else {
// Seeking beyond buffer - signal C# to fetch new stream
return this.seekBeyondBuffer(position);
}
}
/**
* Seek within currently buffered content
*/
private seekWithinBuffer(position: number): AudioResult {
try {
const wasPlaying = this.isPlaying;
this.scheduler.stopAllSources();
// Adjust position relative to buffer start (subtract playback offset)
const bufferRelativePosition = position - this.scheduler.getPlaybackOffset();
this.pausePosition = position;
if (wasPlaying) {
this.scheduler.playFromPosition(Math.max(0, bufferRelativePosition));
}
console.log(`🔍 Seeked within buffer to ${position.toFixed(3)}s (buffer-relative: ${bufferRelativePosition.toFixed(3)}s)`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Seek beyond buffered content - calculate byte offset for server request
*/
private seekBeyondBuffer(position: number): AudioResult {
try {
const byteOffset = this.streamDecoder.calculateByteOffset(position);
// 0 is a valid offset (seek to start of audio data). Only a negative result
// indicates calculation failure — typically a missing/unparsed WAV header.
if (byteOffset < 0) {
return { success: false, error: 'Cannot calculate byte offset' };
}
console.log(`🔍 Seek beyond buffer to ${position.toFixed(3)}s requires byte offset ${byteOffset}`);
// Signal that C# needs to request new stream from offset
return {
success: true,
seekBeyondBuffer: true,
byteOffset: byteOffset
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Get the total buffered duration (for C# to check if seek is within buffer)
*/
getBufferedDuration(): number {
return this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset();
}
/**
* Calculate byte offset for a time position (for C# layer)
*/
calculateByteOffset(positionSeconds: number): number {
return this.streamDecoder.calculateByteOffset(positionSeconds);
}
/**
* Reinitialize for offset streaming after seek-beyond-buffer
* Called by C# after receiving new stream from server
*/
reinitializeFromOffset(totalStreamLength: number, seekPosition: number): AudioResult {
try {
console.log(`\n=== Reinitializing for offset stream ===`);
console.log(`Seek position: ${seekPosition.toFixed(3)}s, Stream length: ${totalStreamLength}`);
// Stop current playback
this.stopProgressTracking();
const wasPlaying = this.isPlaying;
this.isPlaying = false;
// Clear buffers and set new offset
this.scheduler.clearForSeek();
this.scheduler.setPlaybackOffset(seekPosition);
// Reinitialize decoder for new stream
this.streamDecoder.reinitializeForOffset(totalStreamLength);
// Update state
this.pausePosition = seekPosition;
this.streamingStarted = false; // Will restart when new buffers arrive
this.streamingCompleted = false;
console.log(`✅ Reinitialized for offset, was playing: ${wasPlaying}`);
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;
}
// ==================== Spectrum Analysis ====================
getSpectrumData(): number[] {
return this.contextManager.getSpectrumAnalyzer().getFrequencyData();
}
setSpectrumHighPass(freq: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setHighPass(freq);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
setSpectrumLowPass(freq: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setLowPass(freq);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
setSpectrumSlope(dbPerDecade: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setSlopeCorrection(dbPerDecade);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
startSpectrumAnimation(callbackId: string, callback: (data: number[]) => void): void {
this.contextManager.getSpectrumAnalyzer().addCallback(callbackId, callback);
}
stopSpectrumAnimation(callbackId: string): void {
this.contextManager.getSpectrumAnalyzer().removeCallback(callbackId);
}
// ==================== 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,331 @@
/**
* 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
// Offset for seek-beyond-buffer scenarios
// When seeking to position T beyond buffers, we clear buffers and set playbackOffset = T
// The new stream starts at T, so buffer positions are relative to T
private playbackOffset: number = 0;
// 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 (includes playbackOffset for seek-beyond-buffer)
*/
getCurrentPosition(): number {
// Use isActive_ as the sentinel for "playback is running", not playbackAnchorTime == 0.
// AudioContext.currentTime can legitimately be 0 at context creation, so comparing
// against 0 would incorrectly treat an active stream started at t=0 as paused.
if (!this.isActive_) {
return this.playbackAnchorPosition + this.playbackOffset;
}
const elapsed = this.contextManager.currentTime - this.playbackAnchorTime;
return Math.min(this.playbackAnchorPosition + this.playbackOffset + elapsed, this.getTotalDuration() + this.playbackOffset);
}
/**
* Set the playback offset for seek-beyond-buffer scenarios
* This represents the absolute time position where the current buffers start
*/
setPlaybackOffset(offset: number): void {
this.playbackOffset = offset;
console.log(`📍 Playback offset set to ${offset.toFixed(3)}s`);
}
/**
* Get the current playback offset
*/
getPlaybackOffset(): number {
return this.playbackOffset;
}
/**
* 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) {
// Position landed at or past the end of all buffers. Previously this
// returned silently, leaving the player stuck "playing" with no source
// scheduled — a pause near the end followed by play never recovered.
// Treat this as end-of-track so listeners (UI / end callback) fire.
console.log('Position at/beyond available buffers — ending playback');
this.isActive_ = false;
this.playbackAnchorTime = 0;
this.playbackAnchorPosition = 0;
this.onPlaybackEnded?.();
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
}
// Use isActive_ as the sentinel for "playback is running", not nextScheduleTime === 0.
// AudioContext.currentTime can legitimately be 0 at context creation, which would cause
// nextScheduleTime === 0 to incorrectly reset a value already set by playFromPosition.
if (!this.isActive_) {
return;
}
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 and resets offset
*/
clear(): void {
this.isActive_ = false;
this.stopAllSources();
this.buffers = [];
this.playbackAnchorPosition = 0;
this.playbackAnchorTime = 0;
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
this.playbackOffset = 0;
console.log('🗑️ Scheduler cleared');
}
/**
* Clear buffers but keep offset - for seek-beyond-buffer scenarios
*/
clearForSeek(): void {
this.isActive_ = false;
this.stopAllSources();
this.buffers = [];
this.playbackAnchorPosition = 0;
this.playbackAnchorTime = 0;
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
// Note: playbackOffset is NOT reset - it will be set by the caller
console.log('🗑️ Scheduler cleared for seek (offset preserved)');
}
/**
* 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,213 @@
/**
* SpectrumAnalyzer - Manages FFT analysis with filtering and slope correction.
*
* Single Responsibility: FFT analysis, frequency bucketing, and visual processing filters.
*/
export interface SpectrumConfig {
bucketCount: number;
highPassFreq: number; // Hz, 0 = disabled
lowPassFreq: number; // Hz
slopeDb: number; // dB/decade correction
}
export class SpectrumAnalyzer {
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
private fftSize: number = 2048;
private dataArray: Float32Array<ArrayBuffer> | null = null;
// Configuration
private bucketCount: number = 32;
private highPassFreq: number = 0;
private lowPassFreq: number = 20000;
private slopeDb: number = 0;
// Animation state - supports multiple callbacks per player
private animationId: number | null = null;
private callbacks = new Map<string, (data: number[]) => void>();
private lastFrameTime: number = 0;
private targetFrameInterval: number = 1000 / 30; // ~30fps for smooth visuals without excessive interop
initialize(context: AudioContext): AnalyserNode {
this.audioContext = context;
this.analyser = context.createAnalyser();
this.analyser.fftSize = this.fftSize;
this.analyser.smoothingTimeConstant = 0.8;
this.dataArray = new Float32Array(this.analyser.frequencyBinCount);
console.log(`SpectrumAnalyzer initialized: fftSize=${this.fftSize}, bins=${this.analyser.frequencyBinCount}`);
return this.analyser;
}
getAnalyserNode(): AnalyserNode | null {
return this.analyser;
}
setConfig(config: Partial<SpectrumConfig>): void {
if (config.bucketCount !== undefined) this.bucketCount = config.bucketCount;
if (config.highPassFreq !== undefined) this.highPassFreq = config.highPassFreq;
if (config.lowPassFreq !== undefined) this.lowPassFreq = config.lowPassFreq;
if (config.slopeDb !== undefined) this.slopeDb = config.slopeDb;
}
setHighPass(freq: number): void {
this.highPassFreq = Math.max(0, freq);
}
setLowPass(freq: number): void {
this.lowPassFreq = Math.max(20, freq);
}
setSlopeCorrection(dbPerDecade: number): void {
this.slopeDb = dbPerDecade;
}
/**
* Get frequency data as normalized values (0-1) for each bucket
*/
getFrequencyData(): number[] {
if (!this.analyser || !this.dataArray || !this.audioContext) {
return new Array(this.bucketCount).fill(0);
}
// Get raw FFT data (in dB, typically -100 to 0)
this.analyser.getFloatFrequencyData(this.dataArray);
const nyquist = this.audioContext.sampleRate / 2;
const binCount = this.dataArray.length;
const buckets: number[] = new Array(this.bucketCount).fill(0);
// Logarithmic frequency mapping for perceptual balance
// Map 20Hz - 20kHz to buckets using log scale
const minFreq = 20;
const maxFreq = Math.min(20000, nyquist);
const logMin = Math.log10(minFreq);
const logMax = Math.log10(maxFreq);
const logRange = logMax - logMin;
for (let bucket = 0; bucket < this.bucketCount; bucket++) {
// Calculate frequency range for this bucket
const logFreqLow = logMin + (bucket / this.bucketCount) * logRange;
const logFreqHigh = logMin + ((bucket + 1) / this.bucketCount) * logRange;
const freqLow = Math.pow(10, logFreqLow);
const freqHigh = Math.pow(10, logFreqHigh);
// Map frequencies to FFT bins
const binLow = Math.floor((freqLow / nyquist) * binCount);
const binHigh = Math.ceil((freqHigh / nyquist) * binCount);
// Average the bins in this range
let sum = 0;
let count = 0;
for (let bin = binLow; bin < binHigh && bin < binCount; bin++) {
const freq = (bin / binCount) * nyquist;
let value = this.dataArray[bin];
// Apply filters
value = this.applyFilters(value, freq);
sum += value;
count++;
}
const avgDb = count > 0 ? sum / count : -100;
// Normalize from dB (-100 to 0) to 0-1 range
// Clamp to reasonable range and scale
const normalizedDb = Math.max(-80, Math.min(0, avgDb));
buckets[bucket] = (normalizedDb + 80) / 80;
}
return buckets;
}
/**
* Apply high-pass, low-pass, and slope correction filters
*/
private applyFilters(valueDb: number, freq: number): number {
// Convert dB to linear for filter math
let linear = Math.pow(10, valueDb / 20);
// High-pass filter (6dB/octave)
if (this.highPassFreq > 0 && freq < this.highPassFreq && freq > 0) {
const octaves = Math.log2(this.highPassFreq / freq);
const attenuation = Math.pow(10, (-6 * octaves) / 20);
linear *= attenuation;
}
// Low-pass filter (6dB/octave)
if (freq > this.lowPassFreq && this.lowPassFreq > 0) {
const octaves = Math.log2(freq / this.lowPassFreq);
const attenuation = Math.pow(10, (-6 * octaves) / 20);
linear *= attenuation;
}
// Slope correction (dB/decade, referenced to 1kHz)
if (this.slopeDb !== 0 && freq > 0) {
const decades = Math.log10(freq / 1000);
const correction = Math.pow(10, (this.slopeDb * decades) / 20);
linear *= correction;
}
// Convert back to dB
return linear > 0 ? 20 * Math.log10(linear) : -100;
}
/**
* Add a callback for spectrum data. Starts animation loop on first subscriber.
*/
addCallback(id: string, callback: (data: number[]) => void): void {
const wasEmpty = this.callbacks.size === 0;
this.callbacks.set(id, callback);
if (wasEmpty) {
this.lastFrameTime = 0;
this.animationId = requestAnimationFrame(this.animate);
}
}
/**
* Remove a callback by ID. Stops animation loop when no subscribers remain.
*/
removeCallback(id: string): void {
this.callbacks.delete(id);
if (this.callbacks.size === 0) {
this.stopAnimation();
}
}
/**
* Stop animation loop
*/
stopAnimation(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
private animate = (timestamp: number): void => {
if (this.callbacks.size === 0) return;
// Throttle to target frame rate
const elapsed = timestamp - this.lastFrameTime;
if (elapsed >= this.targetFrameInterval) {
this.lastFrameTime = timestamp - (elapsed % this.targetFrameInterval);
const data = this.getFrequencyData();
// Broadcast to all callbacks
for (const cb of this.callbacks.values()) {
cb(data);
}
}
this.animationId = requestAnimationFrame(this.animate);
};
dispose(): void {
this.stopAnimation();
this.callbacks.clear();
this.analyser = null;
this.audioContext = null;
this.dataArray = null;
}
}
@@ -0,0 +1,476 @@
/**
* 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;
}
/**
* Thrown when decodeAudioData exceeds the per-segment deadline. Distinct from
* DecodeError so callers (and operators reading logs) can tell a slow/throttled
* decoder from corrupt audio data — the previous "Decode timeout" string error
* was indistinguishable from any other Error and was silently swallowed.
*/
export class DecodeTimeoutError extends Error {
constructor(public readonly segmentOffset: number, public readonly byteCount: number) {
super(`Decode timeout at offset ${segmentOffset} (${byteCount} bytes)`);
this.name = 'DecodeTimeoutError';
}
}
/**
* Thrown when decodeAudioData rejects for non-timeout reasons (corrupt header,
* unsupported format, etc.). Carries the segment offset so callers can log
* which part of the stream failed.
*/
export class DecodeError extends Error {
constructor(
message: string,
public readonly segmentOffset: number,
public readonly byteCount: number,
public readonly cause?: Error
) {
super(message);
this.name = 'DecodeError';
}
}
export class StreamDecoder {
// Upper bound on pre-header accumulation. 256 KB is far beyond any sane WAV
// header (including extended LIST/INFO/JUNK chunks). If we have accumulated
// this many bytes without finding a valid header the stream is corrupt.
private static readonly MAX_HEADER_SEARCH_BYTES = 256 * 1024;
private contextManager: AudioContextManager;
private wavHeader: WavHeader | null = null;
private rawChunks: Uint8Array[] = [];
// totalRawBytes and processedBytes are JS number (IEEE 754 double), which can
// represent integers exactly up to 2^53 bytes (~8 PB). WAV files are bounded
// at 4 GB by the 32-bit RIFF size field, so overflow is not a practical concern.
private totalRawBytes: number = 0;
private processedBytes: number = 0;
private totalStreamLength: number = 0;
private streamComplete: boolean = false;
private headerError: string | null = null;
// Pre-header accumulator. WAV headers can span multiple network chunks
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
// the whole header lives in the first chunk.
private headerBytesReceived: number = 0;
private headerSearchChunks: Uint8Array[] = [];
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.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
console.log(`StreamDecoder initialized: expecting ${totalStreamLength} bytes`);
}
/**
* Process incoming chunk and return all decoded AudioBuffers ready so far.
*
* Returns an array (possibly empty) rather than a single result because the
* final chunk may unlock the residual tail in addition to a full segment,
* and a single chunk that completes header parsing may also carry enough
* audio data to decode immediately.
*/
async processChunk(chunk: Uint8Array): Promise<DecodedChunkResult[]> {
// If the header search already failed (corrupt/non-WAV stream), stop processing.
if (this.headerError) {
throw new Error(this.headerError);
}
if (!this.wavHeader) {
await this.tryParseHeader(chunk);
// Check again: tryParseHeader may have just set headerError.
if (this.headerError) {
throw new Error(this.headerError);
}
} else {
this.addRawData(chunk);
}
this.updateStreamCompleteFlag();
const results: DecodedChunkResult[] = [];
// Drain all currently-decodable segments. Without this loop, a single
// processChunk call returns at most one segment; the trailing tail
// unlocked once streamComplete flips true would never be flushed.
while (true) {
const segment = await this.tryDecodeNextSegment();
if (!segment) break;
results.push(segment);
}
return results;
}
/**
* Accumulate bytes into the header-search buffer and retry parseHeader.
* Once a header is recognised, anything past headerSize becomes audio data.
*/
private async tryParseHeader(chunk: Uint8Array): Promise<void> {
this.headerSearchChunks.push(chunk);
this.headerBytesReceived += chunk.length;
// Guard against unbounded accumulation from a corrupt or non-WAV stream.
if (this.headerBytesReceived > StreamDecoder.MAX_HEADER_SEARCH_BYTES) {
this.headerError = `WAV header not found after ${this.headerBytesReceived} bytes — stream may be corrupt or not a WAV file`;
console.error(this.headerError);
// Drop the search buffer so subsequent chunks are not accumulated either.
this.headerSearchChunks = [];
this.headerBytesReceived = 0;
return;
}
const header = WavUtils.parseHeader(this.headerSearchChunks, this.headerBytesReceived);
if (!header) {
// Not enough bytes yet — wait for the next chunk. If the stream ends
// without ever producing a valid header, the final processChunk will
// mark streamComplete and the player will report no audio decoded;
// that is the correct failure mode, since there is no audio to play.
console.log(`Header not yet parsable: ${this.headerBytesReceived} bytes accumulated`);
return;
}
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);
}
// Concatenate all header-search chunks and push the audio-data tail
// (everything past headerSize) into the raw audio buffer.
const concatenated = new Uint8Array(this.headerBytesReceived);
let offset = 0;
for (const c of this.headerSearchChunks) {
concatenated.set(c, offset);
offset += c.length;
}
const audioData = concatenated.subarray(header.headerSize);
if (audioData.length > 0) {
this.addRawData(audioData);
}
console.log(`Extracted ${audioData.length} bytes of audio data from header buffer`);
// Header-search buffer no longer needed.
this.headerSearchChunks = [];
this.headerBytesReceived = 0;
}
/**
* Mark the stream complete once we've received all expected bytes. The
* computation must account for whichever stage of header parsing we're in:
* if a header has been parsed, raw audio bytes are tracked separately;
* otherwise pre-header bytes count toward the total.
*/
private updateStreamCompleteFlag(): void {
if (this.totalStreamLength <= 0) return;
const totalReceived = this.wavHeader
? this.totalRawBytes + this.wavHeader.headerSize
: this.headerBytesReceived;
if (totalReceived >= this.totalStreamLength) {
this.streamComplete = true;
}
}
/**
* 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.
*
* Failure modes:
* - Decode timeout: retry once, then surface as DecodeTimeoutError (typed).
* - Other decode error (corrupt data, format mismatch): surface as DecodeError.
* Both are thrown rather than silently swallowed — callers (processChunk /
* markStreamComplete) decide whether to abort the stream or skip the segment.
* processedBytes is only advanced on success so a thrown failure does not
* silently consume the failed segment.
*/
private async tryDecodeNextSegment(): Promise<DecodedChunkResult | null> {
if (!this.wavHeader) return null;
const segmentSize = 64 * 1024; // 64KB segments
const availableBytes = this.totalRawBytes - this.processedBytes;
// Passing streamComplete lets the aligner relax the min-frame guard
// for the final tail; otherwise residual <512-byte tails get dropped.
const alignedSize = WavUtils.getSampleAlignedChunkSize(
this.wavHeader,
segmentSize,
availableBytes,
this.streamComplete
);
if (alignedSize <= 0) return null;
const segmentOffset = this.processedBytes;
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.decodeWithRetry(wavFile, segmentOffset, alignedSize);
// Advance only after a successful decode so a thrown timeout/decode
// failure does not silently drop the segment.
this.processedBytes += alignedSize;
console.log(`✓ Decoded: ${buffer.duration.toFixed(3)}s, ${buffer.numberOfChannels}ch`);
return { buffer, duration: buffer.duration };
} catch (error) {
// Re-throw typed errors so the outer drain loop in processChunk /
// markStreamComplete sees the real failure instead of an empty array.
// The previous silent return hid timeouts entirely.
if (error instanceof DecodeTimeoutError || error instanceof DecodeError) {
throw error;
}
// Unknown synchronous failure during decode — wrap and surface.
throw new DecodeError(
`Decode failed at offset ${segmentOffset} (${alignedSize} bytes): ${(error as Error).message}`,
segmentOffset,
alignedSize,
error as Error);
}
}
/**
* Decode with a single retry on timeout. Web Audio's decodeAudioData is
* occasionally flaky under tab throttling; a retry costs little and recovers
* the common transient case without dropping the segment.
*/
private async decodeWithRetry(
wavData: Uint8Array,
segmentOffset: number,
alignedSize: number): Promise<AudioBuffer> {
try {
return await this.decodeWithTimeout(wavData);
} catch (error) {
if (!(error instanceof DecodeTimeoutError)) {
throw new DecodeError(
`Decode failed at offset ${segmentOffset} (${alignedSize} bytes): ${(error as Error).message}`,
segmentOffset,
alignedSize,
error as Error);
}
console.warn(
`Decode timeout at offset ${segmentOffset} (${alignedSize} bytes) — retrying once`);
try {
return await this.decodeWithTimeout(wavData);
} catch (retryError) {
if (retryError instanceof DecodeTimeoutError) {
console.error(
`Decode timeout after retry at offset ${segmentOffset} (${alignedSize} bytes)`);
throw new DecodeTimeoutError(segmentOffset, alignedSize);
}
throw new DecodeError(
`Decode failed on retry at offset ${segmentOffset} (${alignedSize} bytes): ${(retryError as Error).message}`,
segmentOffset,
alignedSize,
retryError as Error);
}
}
}
/**
* 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;
}
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. Throws DecodeTimeoutError if the
* deadline expires so callers can distinguish timeout from corrupt-data
* failures (decodeAudioData throws DOMException for the latter).
*/
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);
let timer: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new DecodeTimeoutError(-1, wavData.length)), timeoutMs);
});
try {
return await Promise.race([decodePromise, timeoutPromise]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}
/**
* 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.streamComplete;
}
/**
* Get the WAV header info for byte offset calculation
*/
getWavHeader(): WavHeader | null {
return this.wavHeader;
}
/**
* Calculate byte offset from a time position (in seconds)
* Returns block-aligned byte offset for clean audio
*/
calculateByteOffset(positionSeconds: number): number {
if (!this.wavHeader || this.wavHeader.byteRate <= 0) return 0;
const rawOffset = Math.floor(positionSeconds * this.wavHeader.byteRate);
// Align to block boundary for clean audio
return Math.floor(rawOffset / this.wavHeader.blockAlign) * this.wavHeader.blockAlign;
}
/**
* Explicitly mark the stream as complete.
*
* Called by the C# streaming loop after ReadAsync returns 0 (no more data).
* This ensures streamComplete is set even when the server omits Content-Length,
* which prevents updateStreamCompleteFlag from ever firing via byte counting.
* Returns all remaining decoded segments (the tail drain pass).
*
* If streamComplete was already true (set by updateStreamCompleteFlag during the
* final processChunk call), the tail was already drained inside that call's
* while(true) loop — return immediately to avoid a second drain pass that would
* set streamingCompleted = true even if the first drain had a partial failure.
*/
async markStreamComplete(): Promise<DecodedChunkResult[]> {
if (this.streamComplete) {
return [];
}
this.streamComplete = true;
const results: DecodedChunkResult[] = [];
while (true) {
const segment = await this.tryDecodeNextSegment();
if (!segment) break;
results.push(segment);
}
return results;
}
/**
* Reset decoder state
*/
reset(): void {
this.wavHeader = null;
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.totalStreamLength = 0;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
}
/**
* Reinitialize for offset streaming - preserves header format knowledge
* Called when seeking beyond buffer to prepare for new stream from server
*/
reinitializeForOffset(totalStreamLength: number): void {
// Reset data state but we'll get a fresh header from the offset stream
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
// wavHeader will be reparsed from the new stream (server sends fresh header)
this.wavHeader = null;
console.log(`StreamDecoder reinitialized for offset: expecting ${totalStreamLength} bytes`);
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* 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: async (playerId: string): Promise<AudioResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.startStreamingPlayback();
},
markStreamComplete: async (playerId: string): Promise<StreamingResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.markStreamComplete();
},
ensureAudioContextReady: async (playerId: string): Promise<AudioResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.ensureAudioContextReady();
},
play: async (playerId: string): Promise<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);
},
// New methods for seek-beyond-buffer support
getBufferedDuration: (playerId: string): number => {
const player = audioPlayers.get(playerId);
return player?.getBufferedDuration() ?? 0;
},
calculateByteOffset: (playerId: string, positionSeconds: number): number => {
const player = audioPlayers.get(playerId);
return player?.calculateByteOffset(positionSeconds) ?? 0;
},
reinitializeFromOffset: (playerId: string, totalStreamLength: number, seekPosition: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.reinitializeFromOffset(totalStreamLength, seekPosition);
},
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 };
},
// Spectrum analyzer methods
getSpectrumData: (playerId: string): number[] | null => {
const player = audioPlayers.get(playerId);
return player?.getSpectrumData() ?? null;
},
setSpectrumHighPass: (playerId: string, freq: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.setSpectrumHighPass(freq);
},
setSpectrumLowPass: (playerId: string, freq: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.setSpectrumLowPass(freq);
},
setSpectrumSlope: (playerId: string, dbPerDecade: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.setSpectrumSlope(dbPerDecade);
},
startSpectrumAnimation: (
playerId: string,
callbackId: string,
dotNetRef: DotNetObjectReference,
methodName: string
): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
player.startSpectrumAnimation(callbackId, (data: number[]) => {
dotNetRef.invokeMethodAsync(methodName, data);
});
return { success: true };
},
stopSpectrumAnimation: (playerId: string, callbackId: string): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
player.stopSpectrumAnimation(callbackId);
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;
}
}
+210
View File
@@ -0,0 +1,210 @@
interface WavHeader {
sampleRate: number;
channels: number;
bitsPerSample: number;
byteRate: number;
blockAlign: number;
dataSize: number;
headerSize: number;
}
class WavUtils {
static parseHeader(chunks: Uint8Array[], totalSize: number): WavHeader | null {
if (totalSize < 44) return null;
const concatenated = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
concatenated.set(chunk, offset);
offset += chunk.length;
}
// Need a DataView that spans the entire buffer for chunk searching
const view = new DataView(concatenated.buffer);
// Allocate TextDecoder once for the entire parse pass. Constructing it
// inside the chunk-walk loop would create a new instance per iteration,
// which is non-trivial and unnecessary — a single instance is reusable.
const decoder = new TextDecoder();
// Check RIFF header
const riff = decoder.decode(concatenated.slice(0, 4));
if (riff !== 'RIFF') return null;
const wave = decoder.decode(concatenated.slice(8, 12));
if (wave !== 'WAVE') return null;
// 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 = decoder.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(chunkOffset + 8, true);
// PCM only. The server's WavOffsetService synthesises PCM-shaped headers,
// and AudioProcessor rejects non-PCM at upload — accepting Float here would
// hand the decoder a header/payload mismatch that surfaces as garbled audio.
if (audioFormat !== 1) {
console.warn(`Unsupported audio format: ${audioFormat} (only PCM=1 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;
foundFmt = true;
console.log(`Found fmt chunk: ${bitsPerSample}-bit, ${channels}ch, ${sampleRate}Hz, format=${audioFormat}`);
}
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;
}
// 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 {
const header = new ArrayBuffer(44);
const view = new DataView(header);
// RIFF header
view.setUint8(0, 0x52); view.setUint8(1, 0x49); view.setUint8(2, 0x46); view.setUint8(3, 0x46); // "RIFF"
view.setUint32(4, 36 + dataSize, true); // File size
view.setUint8(8, 0x57); view.setUint8(9, 0x41); view.setUint8(10, 0x56); view.setUint8(11, 0x45); // "WAVE"
// fmt chunk
view.setUint8(12, 0x66); view.setUint8(13, 0x6d); view.setUint8(14, 0x74); view.setUint8(15, 0x20); // "fmt "
view.setUint32(16, 16, true); // fmt chunk size
view.setUint16(20, 1, true); // Audio format (PCM)
view.setUint16(22, wavHeader.channels, true);
view.setUint32(24, wavHeader.sampleRate, true);
view.setUint32(28, wavHeader.byteRate, true);
view.setUint16(32, wavHeader.blockAlign, true);
view.setUint16(34, wavHeader.bitsPerSample, true);
// data chunk header
view.setUint8(36, 0x64); view.setUint8(37, 0x61); view.setUint8(38, 0x74); view.setUint8(39, 0x61); // "data"
view.setUint32(40, dataSize, true);
return new Uint8Array(header);
}
static copyAudioDataDirect(chunks: Uint8Array[], targetBuffer: Uint8Array, targetOffset: number, headerSize: number, audioDataSize: number): number {
// Clear audio data area completely to prevent contamination - KEY FIX
for (let i = targetOffset; i < targetOffset + audioDataSize; i++) {
targetBuffer[i] = 0;
}
// Direct copy of audio data to target buffer, skipping WAV header in first chunk only
let targetPos = targetOffset;
let remainingSize = audioDataSize;
let chunkIndex = 0;
let chunkOffset = headerSize; // Skip WAV header in first chunk
while (remainingSize > 0 && chunkIndex < chunks.length) {
const chunk = chunks[chunkIndex];
const availableInChunk = chunk.length - chunkOffset;
const toCopy = Math.min(availableInChunk, remainingSize);
if (toCopy > 0) {
targetBuffer.set(chunk.subarray(chunkOffset, chunkOffset + toCopy), targetPos);
targetPos += toCopy;
remainingSize -= toCopy;
chunkOffset += toCopy;
}
if (chunkOffset >= chunk.length) {
chunkIndex++;
chunkOffset = 0; // No header to skip in subsequent chunks
}
}
return targetPos - targetOffset; // Return actual bytes copied
}
static patchHeaderSizes(buffer: Uint8Array, audioDataSize: number): void {
// Patch file size (offset 4) and data chunk size (offset 40) - little endian, 4 bytes each
const fileSize = 36 + audioDataSize;
buffer[4] = fileSize & 0xFF;
buffer[5] = (fileSize >> 8) & 0xFF;
buffer[6] = (fileSize >> 16) & 0xFF;
buffer[7] = (fileSize >> 24) & 0xFF;
buffer[40] = audioDataSize & 0xFF;
buffer[41] = (audioDataSize >> 8) & 0xFF;
buffer[42] = (audioDataSize >> 16) & 0xFF;
buffer[43] = (audioDataSize >> 24) & 0xFF;
}
static getSampleAlignedChunkSize(header: WavHeader, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number {
const frameSize = header.blockAlign;
// Much smaller minimum for streaming - just enough for Web Audio API.
// The minimum exists to avoid decoding partial-frame artifacts on
// mid-stream chunks while the rest is still in flight. Once the stream
// is fully received, we must drain whatever remains regardless of size,
// otherwise the trailing tail (often <512 bytes) is silently lost.
const minAudioBytes = Math.max(512, frameSize * 10); // At least 512 bytes or 10 frames
// Mid-stream guard: wait for more data if below minimum.
if (!streamComplete && availableDataSize < minAudioBytes) {
return 0;
}
// Even when complete we still need at least one full frame to decode.
if (availableDataSize < frameSize) {
return 0;
}
// Calculate frames for the available data
const requestedSize = Math.min(maxChunkSize, availableDataSize);
const frames = Math.floor(requestedSize / frameSize);
return frames * frameSize;
}
}
export { WavHeader, WavUtils };
+14
View File
@@ -0,0 +1,14 @@
/**
* 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';