refactor(audio): extract IFormatDecoder/WavFormatDecoder and wire Content-Type to JS format selection

StreamDecoder is now format-agnostic; WavFormatDecoder delegates to WavUtils; contentType flows C# to JS.
This commit is contained in:
daniel-c-harvey
2026-06-11 06:08:09 -04:00
parent f8186fb7c7
commit 0b0bcb3dee
9 changed files with 308 additions and 105 deletions
+81 -79
View File
@@ -1,11 +1,15 @@
/**
* StreamDecoder - Handles WAV stream parsing and AudioBuffer decoding.
* StreamDecoder - Handles audio stream parsing and AudioBuffer decoding.
*
* Single Responsibility: Convert raw WAV stream data into decoded AudioBuffers.
* Single Responsibility: Convert a raw audio stream into decoded AudioBuffers.
* Format-specific work (header parsing, segment alignment, segment wrapping, seek
* byte math) is delegated to an IFormatDecoder supplied at initialize time; this
* class owns only the format-agnostic concerns: chunk accumulation, header search
* bounding, stream-complete detection, decode timeout/retry, and range continuation.
*/
import { WavHeader, WavUtils } from '../wavutils.js';
import { AudioContextManager } from './AudioContextManager.js';
import { FormatInfo, IFormatDecoder } from './IFormatDecoder.js';
export interface DecodedChunkResult {
buffer: AudioBuffer;
@@ -43,13 +47,14 @@ export class DecodeError extends Error {
}
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.
// Upper bound on pre-header accumulation. 256 KB is far beyond any sane audio
// header (WAV with extended LIST/INFO/JUNK chunks, FLAC metadata blocks, etc.).
// If we have accumulated this many bytes without 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 formatDecoder: IFormatDecoder | null = null;
private formatInfo: FormatInfo | 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
@@ -61,17 +66,17 @@ export class StreamDecoder {
private headerError: string | null = null;
// Range-continuation state. After a seek-beyond-buffer the server responds 206
// with raw PCM from a file-absolute offset (no WAV header). We retain the header
// with raw audio from a file-absolute offset (no header). We retain the FormatInfo
// parsed from the initial stream and treat the whole body as audio data. The
// stream-complete check then counts raw bytes against the 206 Content-Length
// (remainingByteLength) rather than the full-file totalStreamLength + headerSize.
// (remainingByteLength) rather than the full-file totalStreamLength + audioDataOffset.
private isContinuation: boolean = false;
private remainingByteLength: number = 0;
// 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.
// Pre-header accumulator. Audio headers can span multiple network chunks
// (small first segment, extended WAV LIST/INFO/JUNK chunks before 'data',
// FLAC metadata blocks, etc.), so we buffer raw bytes here until the format
// decoder parses a header rather than assuming it lives in the first chunk.
private headerBytesReceived: number = 0;
private headerSearchChunks: Uint8Array[] = [];
@@ -80,10 +85,12 @@ export class StreamDecoder {
}
/**
* Initialize for a new stream
* Initialize for a new stream. The format decoder owns all format-specific
* parsing/wrapping/seek math for this stream's lifetime.
*/
initialize(totalStreamLength: number): void {
this.wavHeader = null;
initialize(totalStreamLength: number, formatDecoder: IFormatDecoder): void {
this.formatDecoder = formatDecoder;
this.formatInfo = null;
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
@@ -105,12 +112,12 @@ export class StreamDecoder {
* audio data to decode immediately.
*/
async processChunk(chunk: Uint8Array): Promise<DecodedChunkResult[]> {
// If the header search already failed (corrupt/non-WAV stream), stop processing.
// If the header search already failed (corrupt/unrecognised stream), stop processing.
if (this.headerError) {
throw new Error(this.headerError);
}
if (!this.wavHeader) {
if (!this.formatInfo) {
await this.tryParseHeader(chunk);
// Check again: tryParseHeader may have just set headerError.
if (this.headerError) {
@@ -135,16 +142,17 @@ export class StreamDecoder {
}
/**
* Accumulate bytes into the header-search buffer and retry parseHeader.
* Once a header is recognised, anything past headerSize becomes audio data.
* Accumulate bytes into the header-search buffer and retry the format decoder's
* header parse. Once a header is recognised, anything past audioDataOffset
* 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.
// Guard against unbounded accumulation from a corrupt or unrecognised 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`;
this.headerError = `Audio header not found after ${this.headerBytesReceived} bytes — stream may be corrupt or an unsupported format`;
console.error(this.headerError);
// Drop the search buffer so subsequent chunks are not accumulated either.
this.headerSearchChunks = [];
@@ -152,8 +160,8 @@ export class StreamDecoder {
return;
}
const header = WavUtils.parseHeader(this.headerSearchChunks, this.headerBytesReceived);
if (!header) {
const info = this.formatDecoder!.tryParseHeader(this.headerSearchChunks, this.headerBytesReceived);
if (!info) {
// 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;
@@ -161,22 +169,22 @@ export class StreamDecoder {
return;
}
this.wavHeader = header;
this.formatInfo = info;
// Recreate AudioContext with correct sample rate if needed
if (this.contextManager.sampleRate !== header.sampleRate) {
await this.contextManager.recreateWithSampleRate(header.sampleRate);
if (this.contextManager.sampleRate !== info.sampleRate) {
await this.contextManager.recreateWithSampleRate(info.sampleRate);
}
// Concatenate all header-search chunks and push the audio-data tail
// (everything past headerSize) into the raw audio buffer.
// (everything past audioDataOffset) 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);
const audioData = concatenated.subarray(info.audioDataOffset);
if (audioData.length > 0) {
this.addRawData(audioData);
}
@@ -204,8 +212,8 @@ export class StreamDecoder {
}
if (this.totalStreamLength <= 0) return;
const totalReceived = this.wavHeader
? this.totalRawBytes + this.wavHeader.headerSize
const totalReceived = this.formatInfo
? this.totalRawBytes + this.formatInfo.audioDataOffset
: this.headerBytesReceived;
if (totalReceived >= this.totalStreamLength) {
this.streamComplete = true;
@@ -232,16 +240,16 @@ export class StreamDecoder {
* silently consume the failed segment.
*/
private async tryDecodeNextSegment(): Promise<DecodedChunkResult | null> {
if (!this.wavHeader) return null;
if (!this.formatInfo) 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,
const alignedSize = this.formatDecoder!.getAlignedSegmentSize(
this.formatInfo,
availableBytes,
segmentSize,
this.streamComplete
);
@@ -250,10 +258,10 @@ export class StreamDecoder {
const segmentOffset = this.processedBytes;
const rawSegment = this.extractAlignedData(alignedSize);
const wavFile = this.createWavFile(rawSegment);
const decodableSegment = this.formatDecoder!.wrapSegment(this.formatInfo, rawSegment);
try {
const buffer = await this.decodeWithRetry(wavFile, segmentOffset, alignedSize);
const buffer = await this.decodeWithRetry(decodableSegment, segmentOffset, alignedSize);
// Advance only after a successful decode so a thrown timeout/decode
// failure does not silently drop the segment.
this.processedBytes += alignedSize;
@@ -346,30 +354,19 @@ export class StreamDecoder {
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);
private async decodeWithTimeout(audioData: Uint8Array, timeoutMs: number = 5000): Promise<AudioBuffer> {
const buffer = new ArrayBuffer(audioData.length);
new Uint8Array(buffer).set(audioData);
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);
timer = setTimeout(() => reject(new DecodeTimeoutError(-1, audioData.length)), timeoutMs);
});
try {
@@ -380,23 +377,28 @@ export class StreamDecoder {
}
/**
* Get calculated duration from WAV header
* Get calculated duration from the parsed format header.
*
* Prefer the decoder's header-derived totalDuration (for WAV this is the exact
* dataSize/byteRate). When the header omits a usable size (e.g. a WAV data chunk
* size of 0 in a streamed/unknown-length file), fall back to deriving it from the
* total stream length minus the header — identical to the original WAV behavior.
*/
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;
if (!this.formatInfo) return null;
if (this.formatInfo.totalDuration && this.formatInfo.totalDuration > 0) {
return this.formatInfo.totalDuration;
}
if (this.formatInfo.byteRate <= 0) return null;
const audioDataSize = this.totalStreamLength - this.formatInfo.audioDataOffset;
return audioDataSize / this.formatInfo.byteRate;
}
/**
* Check if WAV header has been parsed
* Check if the format header has been parsed
*/
get headerParsed(): boolean {
return this.wavHeader !== null;
return this.formatInfo !== null;
}
/**
@@ -407,22 +409,21 @@ export class StreamDecoder {
}
/**
* Get the WAV header info for byte offset calculation
* Get the parsed format info (sample rate, channels, audio-data offset, …).
* Used by the player for seek byte-offset math and header-dependent decisions.
*/
getWavHeader(): WavHeader | null {
return this.wavHeader;
getFormatInfo(): FormatInfo | null {
return this.formatInfo;
}
/**
* Calculate byte offset from a time position (in seconds)
* Returns block-aligned byte offset for clean audio
* Calculate the file-absolute byte offset for a seek to the given time position.
* Delegates to the format decoder; the returned value is a byte position in the
* file on disk (header included), ready for a Range request.
*/
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;
if (!this.formatInfo) return 0;
return this.formatDecoder!.calculateByteOffset(this.formatInfo, positionSeconds);
}
/**
@@ -453,10 +454,11 @@ export class StreamDecoder {
}
/**
* Reset decoder state
* Reset decoder state. The format decoder is retained — a stream's format does
* not change across reset; a new stream supplies a fresh decoder via initialize.
*/
reset(): void {
this.wavHeader = null;
this.formatInfo = null;
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
@@ -473,10 +475,10 @@ export class StreamDecoder {
* Reinitialize for a Range-continuation stream after seek-beyond-buffer.
*
* The server responds to a Range request with 206 Partial Content carrying raw
* PCM from a file-absolute offset — there is NO WAV header in this body. We retain
* the header parsed from the initial stream (its format describes every segment we
* synthesise via createWavFile) and feed the entire 206 body straight into the
* decode pipeline. The `if (!this.wavHeader)` branch in processChunk therefore goes
* audio from a file-absolute offset — there is NO header in this body. We retain
* the FormatInfo parsed from the initial stream (its format describes every segment
* the decoder wraps via wrapSegment) and feed the entire 206 body straight into the
* decode pipeline. The `if (!this.formatInfo)` branch in processChunk therefore goes
* directly to addRawData and tryParseHeader is never re-entered.
*
* @param remainingByteLength the Content-Length of the 206 response — the number of
@@ -484,7 +486,7 @@ export class StreamDecoder {
* reached when totalRawBytes >= this value.
*/
reinitializeForRangeContinuation(remainingByteLength: number): void {
// Retain this.wavHeader — the 206 body carries no header to reparse.
// Retain this.formatInfo and this.formatDecoder — the 206 body carries no header to reparse.
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;