diff --git a/DeepDrftPublic/Interop/audio/FlacFormatDecoder.ts b/DeepDrftPublic/Interop/audio/FlacFormatDecoder.ts index 3872848..3b9904a 100644 --- a/DeepDrftPublic/Interop/audio/FlacFormatDecoder.ts +++ b/DeepDrftPublic/Interop/audio/FlacFormatDecoder.ts @@ -27,8 +27,7 @@ export class FlacFormatDecoder implements IFormatDecoder { if (buf.length < 4) return null; if (buf[0] !== FLAC_MAGIC[0] || buf[1] !== FLAC_MAGIC[1] || buf[2] !== FLAC_MAGIC[2] || buf[3] !== FLAC_MAGIC[3]) { - console.warn('FlacFormatDecoder: invalid fLaC magic — corrupt stream'); - return null; + return null; // silently; StreamDecoder will error when MAX_HEADER_SEARCH_BYTES is exhausted } let sampleRate = 0; @@ -123,16 +122,42 @@ export class FlacFormatDecoder implements IFormatDecoder { info: FormatInfo, availableBytes: number, requestedSize: number, - streamComplete: boolean + streamComplete: boolean, + rawData?: Uint8Array ): number { - // FLAC frames vary in size and can't be aligned without scanning. Use a minimum - // threshold so the wrapped segment carries at least one complete block; the browser - // FLAC decoder tolerates a partial leading frame once STREAMINFO is prepended. - const minSize = 16384; // 16 KB — covers one block at typical 44100/24-bit/4096-sample settings. - if (availableBytes === 0) return 0; - if (!streamComplete && availableBytes < minSize) return 0; - return Math.min(requestedSize, availableBytes); + const candidate = Math.min(requestedSize, availableBytes); + + if (!rawData || rawData.length === 0) { + // No scan data — conservative threshold to avoid tiny unusable segments + if (!streamComplete && availableBytes < 16384) return 0; + return candidate; + } + + // Scan backward from the candidate boundary to find the last FLAC frame sync code. + const boundary = FlacFormatDecoder.findLastFlacFrame(rawData, candidate); + if (boundary <= 0) { + if (streamComplete) return candidate; // flush remaining bytes (stream done) + return 0; // wait for more data + } + return boundary; + } + + /** + * Scan backward from `maxBytes` in `rawData` to find the start of the last valid FLAC + * audio frame. FLAC frame sync: 0xFF followed by a byte where top 7 bits are 0xF8 + * (i.e. (byte & 0xFE) === 0xF8 — covers both blocking-strategy variants 0xF8 and 0xF9). + * Returns the byte offset of that sync, or 0 if none is found (causes caller to wait). + */ + private static findLastFlacFrame(rawData: Uint8Array, maxBytes: number): number { + const limit = Math.min(maxBytes, rawData.length); + // Need at least 2 bytes to verify sync pair; skip the very last byte. + for (let i = limit - 2; i > 0; i--) { + if (rawData[i] === 0xFF && (rawData[i + 1] & 0xFE) === 0xF8) { + return i; + } + } + return 0; } wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array { diff --git a/DeepDrftPublic/Interop/audio/IFormatDecoder.ts b/DeepDrftPublic/Interop/audio/IFormatDecoder.ts index 23c1135..bd9fe1e 100644 --- a/DeepDrftPublic/Interop/audio/IFormatDecoder.ts +++ b/DeepDrftPublic/Interop/audio/IFormatDecoder.ts @@ -73,12 +73,18 @@ export interface IFormatDecoder { * @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) + * @param rawData - optional; the first `Math.min(requestedSize, availableBytes)` raw audio + * bytes (starting at the current processedBytes position in the stream), made available + * for format-specific frame-sync scanning. WAV and MP3 decoders ignore this parameter; + * FLAC and similar variable-frame formats use it to find the last clean frame boundary + * within the candidate window. */ getAlignedSegmentSize( info: FormatInfo, availableBytes: number, requestedSize: number, - streamComplete: boolean + streamComplete: boolean, + rawData?: Uint8Array ): number; /** diff --git a/DeepDrftPublic/Interop/audio/StreamDecoder.ts b/DeepDrftPublic/Interop/audio/StreamDecoder.ts index 5288487..e4c7943 100644 --- a/DeepDrftPublic/Interop/audio/StreamDecoder.ts +++ b/DeepDrftPublic/Interop/audio/StreamDecoder.ts @@ -244,20 +244,31 @@ export class StreamDecoder { const segmentSize = 64 * 1024; // 64KB segments const availableBytes = this.totalRawBytes - this.processedBytes; + + // Peek the candidate window first so the aligner can scan for a format-specific + // frame boundary (FLAC). extractAlignedData is non-destructive — it reads from + // rawChunks without advancing processedBytes — so reading before alignment is safe. + const peekSize = Math.min(segmentSize, availableBytes); + if (peekSize === 0) return null; + const peekBytes = this.extractAlignedData(peekSize); + // Passing streamComplete lets the aligner relax the min-frame guard // for the final tail; otherwise residual <512-byte tails get dropped. const alignedSize = this.formatDecoder!.getAlignedSegmentSize( this.formatInfo, availableBytes, segmentSize, - this.streamComplete + this.streamComplete, + peekBytes ); if (alignedSize <= 0) return null; const segmentOffset = this.processedBytes; - const rawSegment = this.extractAlignedData(alignedSize); + // alignedSize is always ≤ peekSize ≤ peekBytes.length, so subarray is in-bounds + // and zero-copy — no second extraction needed. + const rawSegment = peekBytes.subarray(0, alignedSize); const decodableSegment = this.formatDecoder!.wrapSegment(this.formatInfo, rawSegment); try {