879c30a5e5
StreamDecoder peeks candidate bytes; FlacFormatDecoder scans backward for 0xFF/0xF8 sync. Fixes mid-stream decode failure where segments started mid-frame.
225 lines
9.6 KiB
TypeScript
225 lines
9.6 KiB
TypeScript
/**
|
|
* FlacFormatDecoder - FLAC implementation of IFormatDecoder.
|
|
*
|
|
* FLAC differs from WAV (raw PCM) and MP3 (self-contained frames): no audio frame
|
|
* is decodable without the STREAMINFO metadata block that opens the stream. So this
|
|
* decoder captures STREAMINFO during header parsing and wrapSegment prepends a minimal
|
|
* valid FLAC header ("fLaC" + STREAMINFO) to every raw audio segment, making each segment
|
|
* independently decodable by the browser's decodeAudioData.
|
|
*
|
|
* Seeking uses the optional SEEKTABLE metadata block when present; absent a table, seek
|
|
* degrades to the start of audio (restart), which is correct behavior with no seek points.
|
|
*/
|
|
|
|
import { FlacSeekData, FormatInfo, IFormatDecoder } from './IFormatDecoder.js';
|
|
|
|
const FLAC_MAGIC = [0x66, 0x4c, 0x61, 0x43]; // "fLaC"
|
|
const STREAMINFO_DATA_LEN = 34;
|
|
const SEEK_POINT_SIZE = 18;
|
|
const PLACEHOLDER_HI = 0xffffffff; // sample_number placeholder = 0xFFFFFFFFFFFFFFFF
|
|
const TWO_POW_32 = 4294967296;
|
|
|
|
export class FlacFormatDecoder implements IFormatDecoder {
|
|
tryParseHeader(chunks: Uint8Array[], totalSize: number): FormatInfo | null {
|
|
const buf = concat(chunks, totalSize);
|
|
|
|
// Need at least the magic to decide anything.
|
|
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]) {
|
|
return null; // silently; StreamDecoder will error when MAX_HEADER_SEARCH_BYTES is exhausted
|
|
}
|
|
|
|
let sampleRate = 0;
|
|
let channels = 0;
|
|
let bitsPerSample = 0;
|
|
let totalSamples = 0;
|
|
let streamInfoBytes: Uint8Array | null = null;
|
|
let seekPoints: FlacSeekData['points'] = [];
|
|
|
|
// Scan metadata blocks starting after the 4-byte magic.
|
|
let offset = 4;
|
|
while (true) {
|
|
// Each block opens with a 4-byte header.
|
|
if (offset + 4 > buf.length) return null;
|
|
|
|
const isLast = (buf[offset] & 0x80) !== 0;
|
|
const blockType = buf[offset] & 0x7f;
|
|
const dataLen = (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3];
|
|
const dataStart = offset + 4;
|
|
|
|
// Need the full block data before we can advance.
|
|
if (dataStart + dataLen > buf.length) return null;
|
|
|
|
if (blockType === 0) {
|
|
// STREAMINFO (mandatory first block). data offsets are relative to dataStart.
|
|
const d = dataStart;
|
|
sampleRate = (buf[d + 10] << 12) | (buf[d + 11] << 4) | (buf[d + 12] >> 4);
|
|
channels = ((buf[d + 12] >> 1) & 0x07) + 1;
|
|
bitsPerSample = (((buf[d + 12] & 0x01) << 4) | (buf[d + 13] >> 4)) + 1;
|
|
totalSamples = ((buf[d + 13] & 0x0f) * TWO_POW_32) + readUint32BE(buf, d + 14);
|
|
|
|
// Build the 38-byte synthetic block: header (is_last=1, type=0, len=34) + 34 data bytes.
|
|
streamInfoBytes = new Uint8Array(4 + STREAMINFO_DATA_LEN);
|
|
streamInfoBytes[0] = 0x80; // is_last=1, block_type=0
|
|
streamInfoBytes[1] = 0x00;
|
|
streamInfoBytes[2] = 0x00;
|
|
streamInfoBytes[3] = STREAMINFO_DATA_LEN; // 0x22
|
|
streamInfoBytes.set(buf.subarray(d, d + STREAMINFO_DATA_LEN), 4);
|
|
} else if (blockType === 3) {
|
|
// SEEKTABLE (optional). Each point is 18 bytes.
|
|
const count = Math.floor(dataLen / SEEK_POINT_SIZE);
|
|
const points: FlacSeekData['points'] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const p = dataStart + i * SEEK_POINT_SIZE;
|
|
const sampleHi = readUint32BE(buf, p);
|
|
const sampleLo = readUint32BE(buf, p + 4);
|
|
// Placeholder points (sample_number = all 1s) carry no offset — skip.
|
|
if (sampleHi === PLACEHOLDER_HI && sampleLo === PLACEHOLDER_HI) continue;
|
|
|
|
// sample_number: imprecise beyond 2^53 (~8h at 44100Hz); acceptable for seek nearest.
|
|
const sampleNumber = sampleHi * TWO_POW_32 + sampleLo;
|
|
// stream_offset: bytes from start of audio frames; safe to 2^53 for sub-petabyte files.
|
|
const offsetHi = readUint32BE(buf, p + 8);
|
|
const offsetLo = readUint32BE(buf, p + 12);
|
|
const streamOffset = offsetHi * TWO_POW_32 + offsetLo;
|
|
points.push({ sampleNumber, streamOffset });
|
|
}
|
|
seekPoints = points;
|
|
}
|
|
|
|
offset = dataStart + dataLen;
|
|
if (isLast) break;
|
|
}
|
|
|
|
if (!streamInfoBytes) {
|
|
console.warn('FlacFormatDecoder: no STREAMINFO block found');
|
|
return null;
|
|
}
|
|
|
|
const audioDataOffset = offset; // 4 (magic) + sum of all block header+data sizes
|
|
const totalDuration = sampleRate > 0 && totalSamples > 0
|
|
? totalSamples / sampleRate : null;
|
|
|
|
return {
|
|
sampleRate,
|
|
channels,
|
|
bitsPerSample,
|
|
byteRate: 0, // FLAC is VBR; seeking uses SEEKTABLE or degrades gracefully.
|
|
blockAlign: 0, // Variable-size FLAC frames; no fixed alignment.
|
|
totalDuration,
|
|
audioDataOffset,
|
|
seekData: {
|
|
kind: 'flac-seektable',
|
|
points: seekPoints,
|
|
streamInfoBytes,
|
|
metadataBlocksSize: audioDataOffset - 4 // metadata bytes, excluding fLaC magic
|
|
}
|
|
};
|
|
}
|
|
|
|
getAlignedSegmentSize(
|
|
info: FormatInfo,
|
|
availableBytes: number,
|
|
requestedSize: number,
|
|
streamComplete: boolean,
|
|
rawData?: Uint8Array
|
|
): number {
|
|
if (availableBytes === 0) return 0;
|
|
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 {
|
|
const flacData = info.seekData as FlacSeekData | null | undefined;
|
|
const streamInfoBytes = flacData?.streamInfoBytes;
|
|
if (!streamInfoBytes) {
|
|
// Defensive: without STREAMINFO the segment isn't decodable. This path shouldn't
|
|
// occur in practice — tryParseHeader always populates streamInfoBytes on success.
|
|
return rawBytes;
|
|
}
|
|
|
|
// Build: fLaC (4) + STREAMINFO block (38) + audio frames.
|
|
const result = new Uint8Array(4 + streamInfoBytes.length + rawBytes.length);
|
|
result[0] = FLAC_MAGIC[0];
|
|
result[1] = FLAC_MAGIC[1];
|
|
result[2] = FLAC_MAGIC[2];
|
|
result[3] = FLAC_MAGIC[3];
|
|
result.set(streamInfoBytes, 4);
|
|
result.set(rawBytes, 4 + streamInfoBytes.length);
|
|
return result;
|
|
}
|
|
|
|
calculateByteOffset(info: FormatInfo, positionSeconds: number): number {
|
|
const flacData = info.seekData?.kind === 'flac-seektable'
|
|
? info.seekData as FlacSeekData : null;
|
|
|
|
if (flacData?.points && flacData.points.length > 0 && info.sampleRate > 0) {
|
|
// SEEKTABLE binary search for the nearest point at or before the target sample.
|
|
const targetSample = positionSeconds * info.sampleRate;
|
|
const points = flacData.points;
|
|
|
|
let lo = 0, hi = points.length - 1, best = 0;
|
|
while (lo <= hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (points[mid].sampleNumber <= targetSample) {
|
|
best = mid;
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid - 1;
|
|
}
|
|
}
|
|
// streamOffset is bytes from start of audio data; add audioDataOffset for file-absolute.
|
|
return info.audioDataOffset + points[best].streamOffset;
|
|
}
|
|
|
|
// No SEEKTABLE: degrade to start of audio (seek restarts from beginning).
|
|
return info.audioDataOffset;
|
|
}
|
|
}
|
|
|
|
function concat(chunks: Uint8Array[], totalSize: number): Uint8Array {
|
|
if (chunks.length === 1) return chunks[0];
|
|
const out = new Uint8Array(totalSize);
|
|
let pos = 0;
|
|
for (const c of chunks) {
|
|
out.set(c, pos);
|
|
pos += c.length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function readUint32BE(buf: Uint8Array, p: number): number {
|
|
return ((buf[p] << 24) | (buf[p + 1] << 16) | (buf[p + 2] << 8) | buf[p + 3]) >>> 0;
|
|
}
|