Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157// SPDX-License-Identifier: CC0-1.0//// this (very simple) library reads MP3 LAME tags and iTunSMPB metadata to find encoder delay/padding for gapless playback trimming :)//// this is used in tinysub's Web Audio engine for nearly perfect gapless playback//// http://gabriel.mp3-tech.org/mp3infotag.html// https://web.dev/articles/mse-seamless-playback
export interface GaplessTrim { startSamples: number; endSamples: number; totalSamples: number; sampleRate?: number;}
export const NO_TRIM: GaplessTrim = { startSamples: 0, endSamples: 0, totalSamples: 0,};
const matchAt = (data: Uint8Array, at: number, text: string) => { for (let i = 0; i < text.length; i++) if (data[at + i] !== text.charCodeAt(i)) return false; return true;};
const readU32 = (data: Uint8Array, at: number) => data[at] * 0x1000000 + (data[at + 1] << 16) + (data[at + 2] << 8) + data[at + 3];
const parseLame = (data: Uint8Array): GaplessTrim | null => { const head = matchAt(data, 0, "ID3") ? 10 + ((data[6] & 0x7f) << 21) + ((data[7] & 0x7f) << 14) + ((data[8] & 0x7f) << 7) + (data[9] & 0x7f) : 0; const synced = data[head] === 0xff && (data[head + 1] & 0xe0) === 0xe0; const version = (data[head + 1] >> 3) & 0x3; const samplesPerFrame = !synced || version === 0x3 ? 1152 : 576; const rateIndex = (data[head + 2] >> 2) & 0x3; const rateTable = [ [11025, 12000, 8000], null, [22050, 24000, 16000], [44100, 48000, 32000], ][version]; const nativeRate = synced && rateTable ? rateTable[rateIndex] : undefined; const end = Math.min(data.length, head + 4096); for (let at = head; at + 12 <= end; at++) { if (!matchAt(data, at, "Xing") && !matchAt(data, at, "Info")) continue; const flags = readU32(data, at + 4); if (!(flags & 0x1)) continue; const frames = readU32(data, at + 8); const cursor = at + 12 + (flags & 0x2 ? 4 : 0) + (flags & 0x4 ? 100 : 0) + (flags & 0x8 ? 4 : 0); if (cursor + 24 > data.length) continue; if (!matchAt(data, cursor, "LAME") && !matchAt(data, cursor, "Lavf")) continue; const lead = (data[cursor + 21] << 4) + (data[cursor + 22] >> 4); const tail = ((data[cursor + 22] & 0x0f) << 8) + data[cursor + 23]; const total = frames * samplesPerFrame; const pad = lead + tail; if (pad <= 0 || pad >= total) continue; return { startSamples: lead, endSamples: tail, totalSamples: total, sampleRate: nativeRate, }; } return null;};
const parseSmpb = (data: Uint8Array): GaplessTrim | null => { const decoder = new TextDecoder(); for (let at = 0; at + 8 <= data.length; at++) { if (!matchAt(data, at, "iTunSMPB")) continue; const text = decoder.decode(data.subarray(at, at + 160)); const fields = text.match( /[0-9a-fA-F]{8} ([0-9a-fA-F]{8}) ([0-9a-fA-F]{8}) ([0-9a-fA-F]+)/, ); if (!fields) continue; const [lead, tail, real] = fields .slice(1, 4) .map((h) => Number.parseInt(h, 16)); if (!(real > 0 && (lead || tail))) continue; return { startSamples: lead, endSamples: tail, totalSamples: lead + real + tail, }; } return null;};
export const getTrim = (data: Uint8Array): GaplessTrim | null => parseLame(data) ?? parseSmpb(data);
const isTrimmedDecode = ( decodedLength: number, decodedRate: number, trim: GaplessTrim,): boolean => { const native = trim.sampleRate; if ( decodedLength <= 0 || decodedRate <= 0 || trim.totalSamples <= 0 || !(native !== undefined && native > 0) ) return false; const pad = trim.startSamples + trim.endSamples; if (pad <= 0 || pad >= trim.totalSamples) return false; const scale = decodedRate / native; const expectedFull = trim.totalSamples * scale; const expectedAudible = (trim.totalSamples - pad) * scale; const margin = Math.max(256, Math.round(pad * scale * 0.15)); if (expectedFull - expectedAudible < margin * 2) return false; return ( Math.abs(decodedLength - expectedAudible) < Math.abs(decodedLength - expectedFull) - margin );};
export const trimFrames = ( decodedLength: number, trim: GaplessTrim, decodedRate: number,): { start: number; end: number } => { if ( decodedLength <= 0 || trim.totalSamples <= 0 || isTrimmedDecode(decodedLength, decodedRate, trim) ) return { start: 0, end: 0 }; const scale = decodedLength / trim.totalSamples; const start = Math.max( 0, Math.min(decodedLength, Math.round(trim.startSamples * scale)), ); const end = Math.max( 0, Math.min(decodedLength - start, Math.round(trim.endSamples * scale)), ); return { start, end };};