fix(flac): add FLAC frame-sync scan to getAlignedSegmentSize; extend IFormatDecoder rawData param
StreamDecoder peeks candidate bytes; FlacFormatDecoder scans backward for 0xFF/0xF8 sync. Fixes mid-stream decode failure where segments started mid-frame.
This commit is contained in:
@@ -27,8 +27,7 @@ export class FlacFormatDecoder implements IFormatDecoder {
|
|||||||
if (buf.length < 4) return null;
|
if (buf.length < 4) return null;
|
||||||
if (buf[0] !== FLAC_MAGIC[0] || buf[1] !== FLAC_MAGIC[1] ||
|
if (buf[0] !== FLAC_MAGIC[0] || buf[1] !== FLAC_MAGIC[1] ||
|
||||||
buf[2] !== FLAC_MAGIC[2] || buf[3] !== FLAC_MAGIC[3]) {
|
buf[2] !== FLAC_MAGIC[2] || buf[3] !== FLAC_MAGIC[3]) {
|
||||||
console.warn('FlacFormatDecoder: invalid fLaC magic — corrupt stream');
|
return null; // silently; StreamDecoder will error when MAX_HEADER_SEARCH_BYTES is exhausted
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sampleRate = 0;
|
let sampleRate = 0;
|
||||||
@@ -123,16 +122,42 @@ export class FlacFormatDecoder implements IFormatDecoder {
|
|||||||
info: FormatInfo,
|
info: FormatInfo,
|
||||||
availableBytes: number,
|
availableBytes: number,
|
||||||
requestedSize: number,
|
requestedSize: number,
|
||||||
streamComplete: boolean
|
streamComplete: boolean,
|
||||||
|
rawData?: Uint8Array
|
||||||
): number {
|
): 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 (availableBytes === 0) return 0;
|
||||||
if (!streamComplete && availableBytes < minSize) return 0;
|
const candidate = Math.min(requestedSize, availableBytes);
|
||||||
return 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 {
|
wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array {
|
||||||
|
|||||||
@@ -73,12 +73,18 @@ export interface IFormatDecoder {
|
|||||||
* @param availableBytes - bytes available starting at the current processedBytes position
|
* @param availableBytes - bytes available starting at the current processedBytes position
|
||||||
* @param requestedSize - maximum desired segment size
|
* @param requestedSize - maximum desired segment size
|
||||||
* @param streamComplete - true when the stream has ended (allows draining the tail)
|
* @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(
|
getAlignedSegmentSize(
|
||||||
info: FormatInfo,
|
info: FormatInfo,
|
||||||
availableBytes: number,
|
availableBytes: number,
|
||||||
requestedSize: number,
|
requestedSize: number,
|
||||||
streamComplete: boolean
|
streamComplete: boolean,
|
||||||
|
rawData?: Uint8Array
|
||||||
): number;
|
): number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -244,20 +244,31 @@ export class StreamDecoder {
|
|||||||
|
|
||||||
const segmentSize = 64 * 1024; // 64KB segments
|
const segmentSize = 64 * 1024; // 64KB segments
|
||||||
const availableBytes = this.totalRawBytes - this.processedBytes;
|
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
|
// 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 = this.formatDecoder!.getAlignedSegmentSize(
|
const alignedSize = this.formatDecoder!.getAlignedSegmentSize(
|
||||||
this.formatInfo,
|
this.formatInfo,
|
||||||
availableBytes,
|
availableBytes,
|
||||||
segmentSize,
|
segmentSize,
|
||||||
this.streamComplete
|
this.streamComplete,
|
||||||
|
peekBytes
|
||||||
);
|
);
|
||||||
|
|
||||||
if (alignedSize <= 0) return null;
|
if (alignedSize <= 0) return null;
|
||||||
|
|
||||||
const segmentOffset = this.processedBytes;
|
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);
|
const decodableSegment = this.formatDecoder!.wrapSegment(this.formatInfo, rawSegment);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user