Fix streaming majors: PCM-only validation, stream-from-disk, ConcatStream offset, AsyncDisposable, HTTP cancellation, await ensureReady, seekBeyondBuffer offset-0 guard, negative WAV chunk guard
This commit is contained in:
@@ -175,13 +175,21 @@ export class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
startStreamingPlayback(): AudioResult {
|
||||
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;
|
||||
@@ -199,7 +207,7 @@ export class AudioPlayer {
|
||||
|
||||
// ==================== Playback Control ====================
|
||||
|
||||
play(): AudioResult {
|
||||
async play(): Promise<AudioResult> {
|
||||
if (!this.isStreamingMode) {
|
||||
return { success: false, error: 'Not in streaming mode' };
|
||||
}
|
||||
@@ -215,7 +223,11 @@ export class AudioPlayer {
|
||||
}
|
||||
|
||||
try {
|
||||
this.contextManager.ensureReady();
|
||||
// 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;
|
||||
@@ -313,7 +325,9 @@ export class AudioPlayer {
|
||||
private seekBeyondBuffer(position: number): AudioResult {
|
||||
try {
|
||||
const byteOffset = this.streamDecoder.calculateByteOffset(position);
|
||||
if (byteOffset <= 0) {
|
||||
// 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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,15 @@ export class PlaybackScheduler {
|
||||
}
|
||||
|
||||
if (startBufferIndex >= this.buffers.length) {
|
||||
console.log('Position beyond available buffers');
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,36 @@ export interface DecodedChunkResult {
|
||||
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
|
||||
@@ -173,7 +203,15 @@ export class StreamDecoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to decode the next segment of audio
|
||||
* 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;
|
||||
@@ -199,15 +237,63 @@ export class StreamDecoder {
|
||||
const wavFile = this.createWavFile(rawSegment);
|
||||
|
||||
try {
|
||||
const buffer = await this.decodeWithTimeout(wavFile);
|
||||
// Advance only after a successful decode so that a timeout or decode
|
||||
// failure does not permanently skip the segment.
|
||||
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) {
|
||||
console.error(`Failed to decode segment at offset ${segmentOffset}:`, error);
|
||||
return null;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,18 +343,25 @@ export class StreamDecoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode with timeout to prevent hanging
|
||||
* 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) => {
|
||||
setTimeout(() => reject(new Error('Decode timeout')), timeoutMs);
|
||||
timer = setTimeout(() => reject(new DecodeTimeoutError(-1, wavData.length)), timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([decodePromise, timeoutPromise]);
|
||||
try {
|
||||
return await Promise.race([decodePromise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,7 @@ const DeepDrftAudio = {
|
||||
return player.processStreamingChunk(chunk);
|
||||
},
|
||||
|
||||
startStreamingPlayback: (playerId: string): AudioResult => {
|
||||
startStreamingPlayback: async (playerId: string): Promise<AudioResult> => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.startStreamingPlayback();
|
||||
@@ -57,7 +57,7 @@ const DeepDrftAudio = {
|
||||
return player.ensureAudioContextReady();
|
||||
},
|
||||
|
||||
play: (playerId: string): AudioResult => {
|
||||
play: async (playerId: string): Promise<AudioResult> => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.play();
|
||||
|
||||
Reference in New Issue
Block a user