Files
deepdrft/DeepDrftPublic/Interop/audio/FlacFormatDecoder.ts
T
daniel-c-harvey 879c30a5e5 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.
2026-06-11 09:08:33 -04:00

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;
}