feat(audio): add FLAC IFormatDecoder for chunked streaming + seek
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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]) {
|
||||
console.warn('FlacFormatDecoder: invalid fLaC magic — corrupt stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
): 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user