diff --git a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs index d3bef1b..f38d1d7 100644 --- a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs @@ -11,12 +11,20 @@ public class TrackMediaResponse : IDisposable { public Stream Stream { get; } public long ContentLength { get; } + + /// + /// 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. + /// + public string ContentType { get; } + private readonly HttpResponseMessage _response; - public TrackMediaResponse(Stream stream, long contentLength, HttpResponseMessage response) + public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response) { Stream = stream; ContentLength = contentLength; + ContentType = contentType; _response = response; } @@ -61,11 +69,14 @@ public class TrackMediaClient response.EnsureSuccessStatusCode(); 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); // TrackMediaResponse takes ownership of both stream and response; // do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose(). - return ApiResult.CreatePassResult(new TrackMediaResponse(stream, contentLength, response)); + return ApiResult.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response)); } catch (Exception e) { diff --git a/DeepDrftPublic.Client/Services/AudioInteropService.cs b/DeepDrftPublic.Client/Services/AudioInteropService.cs index f27fe3f..f7e1f1f 100644 --- a/DeepDrftPublic.Client/Services/AudioInteropService.cs +++ b/DeepDrftPublic.Client/Services/AudioInteropService.cs @@ -65,9 +65,9 @@ public class AudioInteropService : IAsyncDisposable } // Streaming methods - public async Task InitializeStreaming(string playerId, long totalStreamLength) + public async Task InitializeStreaming(string playerId, long totalStreamLength, string contentType) { - return await InvokeJsAsync("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength); + return await InvokeJsAsync("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType); } public async Task ProcessStreamingChunk(string playerId, byte[] audioChunk) diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index 7568df8..774d005 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -143,8 +143,9 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS using var audio = mediaResult.Value; - // Initialize streaming mode with content length - var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength); + // Initialize streaming mode with content length and media type (drives + // JS format-decoder selection). + var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength, audio.ContentType); if (!streamingResult.Success) { var technicalError = $"Failed to initialize streaming: {streamingResult.Error}"; diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts index 17f2617..92bdbf7 100644 --- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts +++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts @@ -10,6 +10,8 @@ import { AudioContextManager } from './AudioContextManager.js'; import { StreamDecoder } from './StreamDecoder.js'; import { PlaybackScheduler } from './PlaybackScheduler.js'; +import { IFormatDecoder } from './IFormatDecoder.js'; +import { WavFormatDecoder } from './WavFormatDecoder.js'; export interface AudioResult { success: boolean; @@ -89,7 +91,7 @@ export class AudioPlayer { // ==================== Streaming ==================== - initializeStreaming(totalStreamLength: number): AudioResult { + initializeStreaming(totalStreamLength: number, contentType: string): AudioResult { try { // Full cleanup before starting new stream this.stopProgressTracking(); @@ -97,15 +99,26 @@ export class AudioPlayer { this.streamDecoder.reset(); this.resetState(); - // Initialize new stream + // Initialize new stream with the format decoder selected from Content-Type. this.isStreamingMode = true; - this.streamDecoder.initialize(totalStreamLength); + const formatDecoder = AudioPlayer.createFormatDecoder(contentType); + this.streamDecoder.initialize(totalStreamLength, formatDecoder); return { success: true }; } catch (error) { 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. * This sets streamComplete=true and flushes any remaining decoded tail segments. @@ -321,22 +334,16 @@ export class AudioPlayer { */ private seekBeyondBuffer(position: number): AudioResult { try { - const audioOffset = 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 (audioOffset < 0) { + // The header must be parsed for byte-offset math; without it we cannot + // build a valid Range request. + if (!this.streamDecoder.getFormatInfo()) { return { success: false, error: 'Cannot calculate byte offset' }; } - // The Range request is file-absolute: byte position from the start of the - // file on disk, header included. calculateByteOffset returns an audio-data- - // relative offset, so add headerSize to land on the right byte. (The old - // ?offset= contract was audio-relative; the server added the header itself.) - const header = this.streamDecoder.getWavHeader(); - if (!header) { - return { success: false, error: 'Cannot calculate byte offset' }; - } - const fileOffset = header.headerSize + audioOffset; + // calculateByteOffset returns a file-absolute offset (byte position from the + // start of the file on disk, header included) — exactly what the Range request + // needs. The format decoder owns the header-offset addition and frame alignment. + const fileOffset = this.streamDecoder.calculateByteOffset(position); // Signal that C# needs to request a new stream from this file-absolute offset return { diff --git a/DeepDrftPublic/Interop/audio/IFormatDecoder.ts b/DeepDrftPublic/Interop/audio/IFormatDecoder.ts new file mode 100644 index 0000000..23c1135 --- /dev/null +++ b/DeepDrftPublic/Interop/audio/IFormatDecoder.ts @@ -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; +} diff --git a/DeepDrftPublic/Interop/audio/StreamDecoder.ts b/DeepDrftPublic/Interop/audio/StreamDecoder.ts index 73a6c3a..5288487 100644 --- a/DeepDrftPublic/Interop/audio/StreamDecoder.ts +++ b/DeepDrftPublic/Interop/audio/StreamDecoder.ts @@ -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 { - // 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 { 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 { - 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 { - const buffer = new ArrayBuffer(wavData.length); - new Uint8Array(buffer).set(wavData); + private async decodeWithTimeout(audioData: Uint8Array, timeoutMs: number = 5000): Promise { + const buffer = new ArrayBuffer(audioData.length); + new Uint8Array(buffer).set(audioData); const decodePromise = this.contextManager.decodeAudioData(buffer); let timer: ReturnType | null = null; const timeoutPromise = new Promise((_, 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; diff --git a/DeepDrftPublic/Interop/audio/WavFormatDecoder.ts b/DeepDrftPublic/Interop/audio/WavFormatDecoder.ts new file mode 100644 index 0000000..57a89ed --- /dev/null +++ b/DeepDrftPublic/Interop/audio/WavFormatDecoder.ts @@ -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; + } +} diff --git a/DeepDrftPublic/Interop/audio/index.ts b/DeepDrftPublic/Interop/audio/index.ts index b6dbb7b..3239f24 100644 --- a/DeepDrftPublic/Interop/audio/index.ts +++ b/DeepDrftPublic/Interop/audio/index.ts @@ -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); 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 => { diff --git a/DeepDrftPublic/Interop/wavutils.ts b/DeepDrftPublic/Interop/wavutils.ts index f27efcc..c8b9001 100644 --- a/DeepDrftPublic/Interop/wavutils.ts +++ b/DeepDrftPublic/Interop/wavutils.ts @@ -123,7 +123,9 @@ class WavUtils { }; } - static createHeader(wavHeader: WavHeader, dataSize: number): Uint8Array { + static createHeader( + wavHeader: Pick, + dataSize: number): Uint8Array { const header = new ArrayBuffer(44); const view = new DataView(header); @@ -195,7 +197,7 @@ class WavUtils { buffer[43] = (audioDataSize >> 24) & 0xFF; } - static getSampleAlignedChunkSize(header: WavHeader, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number { + static getSampleAlignedChunkSize(header: Pick, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number { const frameSize = header.blockAlign; // Much smaller minimum for streaming - just enough for Web Audio API.