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
@@ -11,12 +11,20 @@ public class TrackMediaResponse : IDisposable
{ {
public Stream Stream { get; } public Stream Stream { get; }
public long ContentLength { get; } public long ContentLength { get; }
/// <summary>
/// The response media type (e.g. "audio/wav", "audio/mpeg"). Drives format-decoder
/// selection on the JS side. Falls back to "audio/wav" when the server omits the header.
/// </summary>
public string ContentType { get; }
private readonly HttpResponseMessage _response; private readonly HttpResponseMessage _response;
public TrackMediaResponse(Stream stream, long contentLength, HttpResponseMessage response) public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
{ {
Stream = stream; Stream = stream;
ContentLength = contentLength; ContentLength = contentLength;
ContentType = contentType;
_response = response; _response = response;
} }
@@ -61,11 +69,14 @@ public class TrackMediaClient
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength ?? 0; var contentLength = response.Content.Headers.ContentLength ?? 0;
// Default to WAV when the server omits the header — the only format shipping
// today — so the JS factory always receives a usable media type.
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
// TrackMediaResponse takes ownership of both stream and response; // TrackMediaResponse takes ownership of both stream and response;
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose(). // do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, response)); return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response));
} }
catch (Exception e) catch (Exception e)
{ {
@@ -65,9 +65,9 @@ public class AudioInteropService : IAsyncDisposable
} }
// Streaming methods // Streaming methods
public async Task<AudioOperationResult> InitializeStreaming(string playerId, long totalStreamLength) public async Task<AudioOperationResult> InitializeStreaming(string playerId, long totalStreamLength, string contentType)
{ {
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength); return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType);
} }
public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk) public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk)
@@ -143,8 +143,9 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
using var audio = mediaResult.Value; using var audio = mediaResult.Value;
// Initialize streaming mode with content length // Initialize streaming mode with content length and media type (drives
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength); // JS format-decoder selection).
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength, audio.ContentType);
if (!streamingResult.Success) if (!streamingResult.Success)
{ {
var technicalError = $"Failed to initialize streaming: {streamingResult.Error}"; var technicalError = $"Failed to initialize streaming: {streamingResult.Error}";
+23 -16
View File
@@ -10,6 +10,8 @@
import { AudioContextManager } from './AudioContextManager.js'; import { AudioContextManager } from './AudioContextManager.js';
import { StreamDecoder } from './StreamDecoder.js'; import { StreamDecoder } from './StreamDecoder.js';
import { PlaybackScheduler } from './PlaybackScheduler.js'; import { PlaybackScheduler } from './PlaybackScheduler.js';
import { IFormatDecoder } from './IFormatDecoder.js';
import { WavFormatDecoder } from './WavFormatDecoder.js';
export interface AudioResult { export interface AudioResult {
success: boolean; success: boolean;
@@ -89,7 +91,7 @@ export class AudioPlayer {
// ==================== Streaming ==================== // ==================== Streaming ====================
initializeStreaming(totalStreamLength: number): AudioResult { initializeStreaming(totalStreamLength: number, contentType: string): AudioResult {
try { try {
// Full cleanup before starting new stream // Full cleanup before starting new stream
this.stopProgressTracking(); this.stopProgressTracking();
@@ -97,15 +99,26 @@ export class AudioPlayer {
this.streamDecoder.reset(); this.streamDecoder.reset();
this.resetState(); this.resetState();
// Initialize new stream // Initialize new stream with the format decoder selected from Content-Type.
this.isStreamingMode = true; this.isStreamingMode = true;
this.streamDecoder.initialize(totalStreamLength); const formatDecoder = AudioPlayer.createFormatDecoder(contentType);
this.streamDecoder.initialize(totalStreamLength, formatDecoder);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
} }
} }
/**
* Select a format decoder from the response Content-Type. MP3 and FLAC decoders
* arrive in Wave 2; until then every format falls back to WAV. When Wave 2 lands,
* add the MP3/FLAC branches here, e.g.:
* if (contentType.includes('audio/mpeg')) return new Mp3FormatDecoder();
*/
private static createFormatDecoder(_contentType: string): IFormatDecoder {
return new WavFormatDecoder();
}
/** /**
* Signal to the decoder that the C# streaming loop has finished sending bytes. * Signal to the decoder that the C# streaming loop has finished sending bytes.
* This sets streamComplete=true and flushes any remaining decoded tail segments. * This sets streamComplete=true and flushes any remaining decoded tail segments.
@@ -321,22 +334,16 @@ export class AudioPlayer {
*/ */
private seekBeyondBuffer(position: number): AudioResult { private seekBeyondBuffer(position: number): AudioResult {
try { try {
const audioOffset = this.streamDecoder.calculateByteOffset(position); // The header must be parsed for byte-offset math; without it we cannot
// 0 is a valid offset (seek to start of audio data). Only a negative result // build a valid Range request.
// indicates calculation failure — typically a missing/unparsed WAV header. if (!this.streamDecoder.getFormatInfo()) {
if (audioOffset < 0) {
return { success: false, error: 'Cannot calculate byte offset' }; return { success: false, error: 'Cannot calculate byte offset' };
} }
// The Range request is file-absolute: byte position from the start of the // calculateByteOffset returns a file-absolute offset (byte position from the
// file on disk, header included. calculateByteOffset returns an audio-data- // start of the file on disk, header included) — exactly what the Range request
// relative offset, so add headerSize to land on the right byte. (The old // needs. The format decoder owns the header-offset addition and frame alignment.
// ?offset= contract was audio-relative; the server added the header itself.) const fileOffset = this.streamDecoder.calculateByteOffset(position);
const header = this.streamDecoder.getWavHeader();
if (!header) {
return { success: false, error: 'Cannot calculate byte offset' };
}
const fileOffset = header.headerSize + audioOffset;
// Signal that C# needs to request a new stream from this file-absolute offset // Signal that C# needs to request a new stream from this file-absolute offset
return { return {
@@ -0,0 +1,100 @@
/**
* FormatInfo: parsed header data needed to stream and seek an audio file.
* Populated by IFormatDecoder.tryParseHeader; used by StreamDecoder throughout playback.
*/
export interface FormatInfo {
/** Samples per second (e.g. 44100). */
sampleRate: number;
/** Number of audio channels. */
channels: number;
/**
* Nominal bit depth — 16 for MP3 (conventional), 16/24/32 for WAV/FLAC.
* Used for display; decoders handle the actual sample format internally.
*/
bitsPerSample: number;
/**
* Average bytes per second. Used for CBR byte-offset estimation.
* For WAV: exact (sampleRate * blockAlign).
* For MP3 CBR: bitrate_kbps * 125.
* For FLAC: approximate (fileSize / duration).
*/
byteRate: number;
/**
* For WAV: PCM frame size in bytes (channels * bitsPerSample / 8).
* For MP3: frame size in bytes (constant for CBR, 0 for VBR with TOC).
* For FLAC: 0 (frame sizes vary; use sync scan instead).
* Used by getAlignedSegmentSize to round to clean frame boundaries.
*/
blockAlign: number;
/** Total duration in seconds, from the header (null if unavailable). */
totalDuration: number | null;
/** Byte offset where audio frames begin in the original file. */
audioDataOffset: number;
/**
* Format-specific accelerator for seek-beyond-buffer byte calculation.
* WAV: null (uses byteRate/blockAlign directly).
* MP3 VBR: Xing/VBRI TOC (100-entry Uint8Array, values are file-percentage * 255).
* FLAC: SeekTable (array of {sampleNumber: number, streamOffset: number} — stream_offset
* is bytes from the start of audio frames, i.e. after all metadata blocks).
*/
seekData?: Mp3VbrSeekData | FlacSeekData | null;
}
export interface Mp3VbrSeekData {
kind: 'mp3-vbr';
toc: Uint8Array; // 100 entries; toc[i] = file-byte-fraction at i% of duration
totalBytes: number; // total audio bytes (from Xing header)
}
export interface FlacSeekData {
kind: 'flac-seektable';
points: Array<{ sampleNumber: number; streamOffset: number }>;
streamInfoBytes: Uint8Array; // raw STREAMINFO metadata block for segment wrapping
metadataBlocksSize: number; // total bytes of all metadata blocks (for stream_offset relative math)
}
/**
* IFormatDecoder: per-format strategy for header parsing, segment boundary detection,
* segment wrapping, and seek offset calculation.
*
* Implementations: WavFormatDecoder (Wave 1), Mp3FormatDecoder (Wave 2), FlacFormatDecoder (Wave 2).
*/
export interface IFormatDecoder {
/**
* Attempt to parse the header from accumulated bytes. Returns null if more bytes are needed.
* Called with growing chunks array until it succeeds or exceeds MAX_HEADER_SEARCH_BYTES.
*/
tryParseHeader(chunks: Uint8Array[], totalSize: number): FormatInfo | null;
/**
* Return the largest decodable byte count ≤ requestedSize that ends on a clean frame/block
* boundary in the audio data (post-header). Returns 0 if not enough data yet.
* @param info - the parsed FormatInfo
* @param availableBytes - bytes available starting at the current processedBytes position
* @param requestedSize - maximum desired segment size
* @param streamComplete - true when the stream has ended (allows draining the tail)
*/
getAlignedSegmentSize(
info: FormatInfo,
availableBytes: number,
requestedSize: number,
streamComplete: boolean
): number;
/**
* Wrap raw audio bytes in the minimal decodable container for decodeAudioData.
* WAV: prepend a 44-byte standard PCM header.
* MP3: pass through unchanged (raw frames are self-contained).
* FLAC: prepend fLaC marker + STREAMINFO metadata block.
*/
wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array;
/**
* Calculate the file-absolute byte offset for a seek-beyond-buffer Range request.
* The returned value includes the header offset (result ≥ info.audioDataOffset).
* WAV: exact PCM frame alignment.
* MP3 CBR: frame-aligned estimate; MP3 VBR: TOC interpolation.
* FLAC: SEEKTABLE lookup when available.
*/
calculateByteOffset(info: FormatInfo, positionSeconds: number): number;
}
+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 { AudioContextManager } from './AudioContextManager.js';
import { FormatInfo, IFormatDecoder } from './IFormatDecoder.js';
export interface DecodedChunkResult { export interface DecodedChunkResult {
buffer: AudioBuffer; buffer: AudioBuffer;
@@ -43,13 +47,14 @@ export class DecodeError extends Error {
} }
export class StreamDecoder { export class StreamDecoder {
// Upper bound on pre-header accumulation. 256 KB is far beyond any sane WAV // Upper bound on pre-header accumulation. 256 KB is far beyond any sane audio
// header (including extended LIST/INFO/JUNK chunks). If we have accumulated // header (WAV with extended LIST/INFO/JUNK chunks, FLAC metadata blocks, etc.).
// this many bytes without finding a valid header the stream is corrupt. // 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 static readonly MAX_HEADER_SEARCH_BYTES = 256 * 1024;
private contextManager: AudioContextManager; private contextManager: AudioContextManager;
private wavHeader: WavHeader | null = null; private formatDecoder: IFormatDecoder | null = null;
private formatInfo: FormatInfo | null = null;
private rawChunks: Uint8Array[] = []; private rawChunks: Uint8Array[] = [];
// totalRawBytes and processedBytes are JS number (IEEE 754 double), which can // 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 // 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; private headerError: string | null = null;
// Range-continuation state. After a seek-beyond-buffer the server responds 206 // 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 // 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 // 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 isContinuation: boolean = false;
private remainingByteLength: number = 0; private remainingByteLength: number = 0;
// Pre-header accumulator. WAV headers can span multiple network chunks // Pre-header accumulator. Audio headers can span multiple network chunks
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.), // (small first segment, extended WAV LIST/INFO/JUNK chunks before 'data',
// so we buffer raw bytes here until parseHeader succeeds rather than assuming // FLAC metadata blocks, etc.), so we buffer raw bytes here until the format
// the whole header lives in the first chunk. // decoder parses a header rather than assuming it lives in the first chunk.
private headerBytesReceived: number = 0; private headerBytesReceived: number = 0;
private headerSearchChunks: Uint8Array[] = []; 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 { initialize(totalStreamLength: number, formatDecoder: IFormatDecoder): void {
this.wavHeader = null; this.formatDecoder = formatDecoder;
this.formatInfo = null;
this.rawChunks = []; this.rawChunks = [];
this.totalRawBytes = 0; this.totalRawBytes = 0;
this.processedBytes = 0; this.processedBytes = 0;
@@ -105,12 +112,12 @@ export class StreamDecoder {
* audio data to decode immediately. * audio data to decode immediately.
*/ */
async processChunk(chunk: Uint8Array): Promise<DecodedChunkResult[]> { 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) { if (this.headerError) {
throw new Error(this.headerError); throw new Error(this.headerError);
} }
if (!this.wavHeader) { if (!this.formatInfo) {
await this.tryParseHeader(chunk); await this.tryParseHeader(chunk);
// Check again: tryParseHeader may have just set headerError. // Check again: tryParseHeader may have just set headerError.
if (this.headerError) { if (this.headerError) {
@@ -135,16 +142,17 @@ export class StreamDecoder {
} }
/** /**
* Accumulate bytes into the header-search buffer and retry parseHeader. * Accumulate bytes into the header-search buffer and retry the format decoder's
* Once a header is recognised, anything past headerSize becomes audio data. * header parse. Once a header is recognised, anything past audioDataOffset
* becomes audio data.
*/ */
private async tryParseHeader(chunk: Uint8Array): Promise<void> { private async tryParseHeader(chunk: Uint8Array): Promise<void> {
this.headerSearchChunks.push(chunk); this.headerSearchChunks.push(chunk);
this.headerBytesReceived += chunk.length; 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) { 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); console.error(this.headerError);
// Drop the search buffer so subsequent chunks are not accumulated either. // Drop the search buffer so subsequent chunks are not accumulated either.
this.headerSearchChunks = []; this.headerSearchChunks = [];
@@ -152,8 +160,8 @@ export class StreamDecoder {
return; return;
} }
const header = WavUtils.parseHeader(this.headerSearchChunks, this.headerBytesReceived); const info = this.formatDecoder!.tryParseHeader(this.headerSearchChunks, this.headerBytesReceived);
if (!header) { if (!info) {
// Not enough bytes yet — wait for the next chunk. If the stream ends // Not enough bytes yet — wait for the next chunk. If the stream ends
// without ever producing a valid header, the final processChunk will // without ever producing a valid header, the final processChunk will
// mark streamComplete and the player will report no audio decoded; // mark streamComplete and the player will report no audio decoded;
@@ -161,22 +169,22 @@ export class StreamDecoder {
return; return;
} }
this.wavHeader = header; this.formatInfo = info;
// Recreate AudioContext with correct sample rate if needed // Recreate AudioContext with correct sample rate if needed
if (this.contextManager.sampleRate !== header.sampleRate) { if (this.contextManager.sampleRate !== info.sampleRate) {
await this.contextManager.recreateWithSampleRate(header.sampleRate); await this.contextManager.recreateWithSampleRate(info.sampleRate);
} }
// Concatenate all header-search chunks and push the audio-data tail // 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); const concatenated = new Uint8Array(this.headerBytesReceived);
let offset = 0; let offset = 0;
for (const c of this.headerSearchChunks) { for (const c of this.headerSearchChunks) {
concatenated.set(c, offset); concatenated.set(c, offset);
offset += c.length; offset += c.length;
} }
const audioData = concatenated.subarray(header.headerSize); const audioData = concatenated.subarray(info.audioDataOffset);
if (audioData.length > 0) { if (audioData.length > 0) {
this.addRawData(audioData); this.addRawData(audioData);
} }
@@ -204,8 +212,8 @@ export class StreamDecoder {
} }
if (this.totalStreamLength <= 0) return; if (this.totalStreamLength <= 0) return;
const totalReceived = this.wavHeader const totalReceived = this.formatInfo
? this.totalRawBytes + this.wavHeader.headerSize ? this.totalRawBytes + this.formatInfo.audioDataOffset
: this.headerBytesReceived; : this.headerBytesReceived;
if (totalReceived >= this.totalStreamLength) { if (totalReceived >= this.totalStreamLength) {
this.streamComplete = true; this.streamComplete = true;
@@ -232,16 +240,16 @@ export class StreamDecoder {
* silently consume the failed segment. * silently consume the failed segment.
*/ */
private async tryDecodeNextSegment(): Promise<DecodedChunkResult | null> { private async tryDecodeNextSegment(): Promise<DecodedChunkResult | null> {
if (!this.wavHeader) return null; if (!this.formatInfo) return null;
const segmentSize = 64 * 1024; // 64KB segments const segmentSize = 64 * 1024; // 64KB segments
const availableBytes = this.totalRawBytes - this.processedBytes; const availableBytes = this.totalRawBytes - this.processedBytes;
// Passing streamComplete lets the aligner relax the min-frame guard // Passing streamComplete lets the aligner relax the min-frame guard
// for the final tail; otherwise residual <512-byte tails get dropped. // for the final tail; otherwise residual <512-byte tails get dropped.
const alignedSize = WavUtils.getSampleAlignedChunkSize( const alignedSize = this.formatDecoder!.getAlignedSegmentSize(
this.wavHeader, this.formatInfo,
segmentSize,
availableBytes, availableBytes,
segmentSize,
this.streamComplete this.streamComplete
); );
@@ -250,10 +258,10 @@ export class StreamDecoder {
const segmentOffset = this.processedBytes; const segmentOffset = this.processedBytes;
const rawSegment = this.extractAlignedData(alignedSize); const rawSegment = this.extractAlignedData(alignedSize);
const wavFile = this.createWavFile(rawSegment); const decodableSegment = this.formatDecoder!.wrapSegment(this.formatInfo, rawSegment);
try { 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 // Advance only after a successful decode so a thrown timeout/decode
// failure does not silently drop the segment. // failure does not silently drop the segment.
this.processedBytes += alignedSize; this.processedBytes += alignedSize;
@@ -346,30 +354,19 @@ export class StreamDecoder {
return extracted; 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 * Decode with timeout to prevent hanging. Throws DecodeTimeoutError if the
* deadline expires so callers can distinguish timeout from corrupt-data * deadline expires so callers can distinguish timeout from corrupt-data
* failures (decodeAudioData throws DOMException for the latter). * failures (decodeAudioData throws DOMException for the latter).
*/ */
private async decodeWithTimeout(wavData: Uint8Array, timeoutMs: number = 5000): Promise<AudioBuffer> { private async decodeWithTimeout(audioData: Uint8Array, timeoutMs: number = 5000): Promise<AudioBuffer> {
const buffer = new ArrayBuffer(wavData.length); const buffer = new ArrayBuffer(audioData.length);
new Uint8Array(buffer).set(wavData); new Uint8Array(buffer).set(audioData);
const decodePromise = this.contextManager.decodeAudioData(buffer); const decodePromise = this.contextManager.decodeAudioData(buffer);
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => { 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 { 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 { getEstimatedDuration(): number | null {
if (!this.wavHeader || this.wavHeader.byteRate <= 0) return null; if (!this.formatInfo) return null;
if (this.formatInfo.totalDuration && this.formatInfo.totalDuration > 0) {
const audioDataSize = this.wavHeader.dataSize > 0 return this.formatInfo.totalDuration;
? this.wavHeader.dataSize }
: (this.totalStreamLength - this.wavHeader.headerSize); if (this.formatInfo.byteRate <= 0) return null;
const audioDataSize = this.totalStreamLength - this.formatInfo.audioDataOffset;
return audioDataSize / this.wavHeader.byteRate; return audioDataSize / this.formatInfo.byteRate;
} }
/** /**
* Check if WAV header has been parsed * Check if the format header has been parsed
*/ */
get headerParsed(): boolean { 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 { getFormatInfo(): FormatInfo | null {
return this.wavHeader; return this.formatInfo;
} }
/** /**
* Calculate byte offset from a time position (in seconds) * Calculate the file-absolute byte offset for a seek to the given time position.
* Returns block-aligned byte offset for clean audio * 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 { calculateByteOffset(positionSeconds: number): number {
if (!this.wavHeader || this.wavHeader.byteRate <= 0) return 0; if (!this.formatInfo) return 0;
return this.formatDecoder!.calculateByteOffset(this.formatInfo, positionSeconds);
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;
} }
/** /**
@@ -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 { reset(): void {
this.wavHeader = null; this.formatInfo = null;
this.rawChunks = []; this.rawChunks = [];
this.totalRawBytes = 0; this.totalRawBytes = 0;
this.processedBytes = 0; this.processedBytes = 0;
@@ -473,10 +475,10 @@ export class StreamDecoder {
* Reinitialize for a Range-continuation stream after seek-beyond-buffer. * Reinitialize for a Range-continuation stream after seek-beyond-buffer.
* *
* The server responds to a Range request with 206 Partial Content carrying raw * 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 * audio from a file-absolute offset — there is NO header in this body. We retain
* the header parsed from the initial stream (its format describes every segment we * the FormatInfo parsed from the initial stream (its format describes every segment
* synthesise via createWavFile) and feed the entire 206 body straight into the * the decoder wraps via wrapSegment) and feed the entire 206 body straight into the
* decode pipeline. The `if (!this.wavHeader)` branch in processChunk therefore goes * decode pipeline. The `if (!this.formatInfo)` branch in processChunk therefore goes
* directly to addRawData and tryParseHeader is never re-entered. * directly to addRawData and tryParseHeader is never re-entered.
* *
* @param remainingByteLength the Content-Length of the 206 response — the number of * @param remainingByteLength the Content-Length of the 206 response — the number of
@@ -484,7 +486,7 @@ export class StreamDecoder {
* reached when totalRawBytes >= this value. * reached when totalRawBytes >= this value.
*/ */
reinitializeForRangeContinuation(remainingByteLength: number): void { 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.rawChunks = [];
this.totalRawBytes = 0; this.totalRawBytes = 0;
this.processedBytes = 0; this.processedBytes = 0;
@@ -0,0 +1,80 @@
/**
* WavFormatDecoder - WAV/PCM implementation of IFormatDecoder.
*
* All WAV-specific stream logic lives here: header parsing, frame-aligned segment
* sizing, segment wrapping with a synthesised 44-byte PCM header, and block-aligned
* seek byte-offset calculation. StreamDecoder delegates to this via IFormatDecoder
* and holds no WAV knowledge of its own.
*
* The low-level byte routines (parseHeader, createHeader, getSampleAlignedChunkSize)
* are reused from WavUtils rather than duplicated — this is the same proven logic the
* decoder used before the format-agnostic refactor, kept in one place.
*/
import { WavUtils } from '../wavutils.js';
import { FormatInfo, IFormatDecoder } from './IFormatDecoder.js';
export class WavFormatDecoder implements IFormatDecoder {
tryParseHeader(chunks: Uint8Array[], totalSize: number): FormatInfo | null {
const header = WavUtils.parseHeader(chunks, totalSize);
if (!header) return null;
return {
sampleRate: header.sampleRate,
channels: header.channels,
bitsPerSample: header.bitsPerSample,
byteRate: header.byteRate,
blockAlign: header.blockAlign,
// dataSize / byteRate is the exact PCM duration; guard against a zero
// byteRate (malformed fmt chunk) rather than producing Infinity/NaN.
totalDuration: header.byteRate > 0 ? header.dataSize / header.byteRate : null,
audioDataOffset: header.headerSize,
seekData: null
};
}
getAlignedSegmentSize(
info: FormatInfo,
availableBytes: number,
requestedSize: number,
streamComplete: boolean
): number {
// blockAlign is the PCM frame size; reuse the original frame-alignment routine
// verbatim so streaming/tail-drain behavior is unchanged.
return WavUtils.getSampleAlignedChunkSize(
{ blockAlign: info.blockAlign },
requestedSize,
availableBytes,
streamComplete
);
}
wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array {
const header = WavUtils.createHeader(
{
channels: info.channels,
sampleRate: info.sampleRate,
byteRate: info.byteRate,
blockAlign: info.blockAlign,
bitsPerSample: info.bitsPerSample
},
rawBytes.length
);
const wavFile = new Uint8Array(header.length + rawBytes.length);
wavFile.set(header, 0);
wavFile.set(rawBytes, header.length);
return wavFile;
}
calculateByteOffset(info: FormatInfo, positionSeconds: number): number {
// No header / malformed fmt chunk: cannot compute. Return the audio start so the
// caller still has a valid (file-absolute) Range target rather than a negative.
if (info.byteRate <= 0 || info.blockAlign <= 0) return info.audioDataOffset;
const rawOffset = Math.floor(positionSeconds * info.byteRate);
// Align to a PCM frame boundary for clean audio, then make it file-absolute by
// adding the header size — the Range request is a byte position in the file on disk.
const alignedAudioOffset = Math.floor(rawOffset / info.blockAlign) * info.blockAlign;
return info.audioDataOffset + alignedAudioOffset;
}
}
+2 -2
View File
@@ -31,10 +31,10 @@ const DeepDrftAudio = {
} }
}, },
initializeStreaming: (playerId: string, totalStreamLength: number): AudioResult => { initializeStreaming: (playerId: string, totalStreamLength: number, contentType: string): AudioResult => {
const player = audioPlayers.get(playerId); const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' }; if (!player) return { success: false, error: 'Player not found' };
return player.initializeStreaming(totalStreamLength); return player.initializeStreaming(totalStreamLength, contentType);
}, },
processStreamingChunk: async (playerId: string, chunk: Uint8Array): Promise<StreamingResult> => { processStreamingChunk: async (playerId: string, chunk: Uint8Array): Promise<StreamingResult> => {
+4 -2
View File
@@ -123,7 +123,9 @@ class WavUtils {
}; };
} }
static createHeader(wavHeader: WavHeader, dataSize: number): Uint8Array { static createHeader(
wavHeader: Pick<WavHeader, 'channels' | 'sampleRate' | 'byteRate' | 'blockAlign' | 'bitsPerSample'>,
dataSize: number): Uint8Array {
const header = new ArrayBuffer(44); const header = new ArrayBuffer(44);
const view = new DataView(header); const view = new DataView(header);
@@ -195,7 +197,7 @@ class WavUtils {
buffer[43] = (audioDataSize >> 24) & 0xFF; buffer[43] = (audioDataSize >> 24) & 0xFF;
} }
static getSampleAlignedChunkSize(header: WavHeader, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number { static getSampleAlignedChunkSize(header: Pick<WavHeader, 'blockAlign'>, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number {
const frameSize = header.blockAlign; const frameSize = header.blockAlign;
// Much smaller minimum for streaming - just enough for Web Audio API. // Much smaller minimum for streaming - just enough for Web Audio API.