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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user