// SPDX-License-Identifier: Apache-2.0 // // Decode a dropped audio file entirely inside this tab. // // This is the browser counterpart of `master_core.AudioFile.__init__`: tags -> // song name, `librosa.load(sr=None)` -> native-rate samples, `librosa.to_mono` // -> the mono signal every metric reads. Nothing is uploaded or stored: the File // is read into memory, decoded, downmixed, and the compressed bytes and // per-channel buffers are dropped as soon as the mono signal exists. // // Two decode paths: // * WAV and AIFF PCM/float are read directly with libsndfile's sample scaling, // so the signal is bit-identical to what librosa hands the desktop metrics // (Chromium's WAV decoder scales positive int16 by 1/32767, a small but // systematic gain skew; browsers don't decode AIFF at all). // * Everything else goes through decodeAudioData on an OfflineAudioContext // running at the rate sniffed from the container header, so nothing is // resampled — the same "analyse the full band" guarantee as `sr=None`. const MIN_CONTEXT_RATE = 3000; // OfflineAudioContext's legal range const MAX_CONTEXT_RATE = 768000; const FALLBACK_RATE = 48000; /** * Decode `file` to a mono Float32Array at its native sample rate. * * Returns {name, songName, format, sampleRate, nativeRateKnown, channels, * duration, mono, maxAmplitude, avgAmplitude, size}. */ export async function decodeFile(file) { const bytes = await file.arrayBuffer(); const view = new DataView(bytes); // Everything that reads the raw bytes must run before decodeAudioData, which // detaches the ArrayBuffer. const format = sniffFormat(view); const tags = format === 'mp3' ? readId3(view) : null; let decoded = format === 'wav' || format === 'aiff' ? readPcm(view, format) : null; let nativeRateKnown = true; if (!decoded) { const sniffed = sniffSampleRate(view, format); nativeRateKnown = sniffed !== null && sniffed >= MIN_CONTEXT_RATE && sniffed <= MAX_CONTEXT_RATE; const sampleRate = nativeRateKnown ? sniffed : FALLBACK_RATE; const ctx = new OfflineAudioContext({ numberOfChannels: 1, length: 1, sampleRate }); let buffer; try { buffer = await ctx.decodeAudioData(bytes); } catch (err) { throw new Error(`this browser cannot decode ${format || 'this file'} (${err.message || err})`); } decoded = { sampleRate: buffer.sampleRate, channels: buffer.numberOfChannels, mono: bufferToMono(buffer) }; } const { mono, sampleRate, channels } = decoded; let maxAmplitude = 0; let absSum = 0; for (let i = 0; i < mono.length; i++) { const a = Math.abs(mono[i]); if (a > maxAmplitude) maxAmplitude = a; absSum += a; } // Same naming rule as AudioFile: "artist - title" when both tags exist. const songName = tags && tags.artist && tags.title ? `${tags.artist} - ${tags.title}` : file.name; return { name: file.name, songName, format, sampleRate, nativeRateKnown, channels, duration: mono.length / sampleRate, mono, maxAmplitude, avgAmplitude: mono.length ? absSum / mono.length : 0, size: file.size, }; } /** * `librosa.to_mono`: the float32 mean across channels. * * Accumulating in a Float32Array rounds each partial sum to float32 exactly as * numpy's float32 `mean(axis=0)` does, so downmixes match bit for bit. */ function bufferToMono(buffer) { const channels = buffer.numberOfChannels; if (channels === 1) return new Float32Array(buffer.getChannelData(0)); const n = buffer.length; const mono = new Float32Array(n); for (let c = 0; c < channels; c++) { const data = buffer.getChannelData(c); for (let i = 0; i < n; i++) mono[i] += data[i]; } for (let i = 0; i < n; i++) mono[i] /= channels; return mono; } // ---- direct PCM (WAV / AIFF) ------------------------------------------------ function ascii(view, offset, length) { if (offset < 0 || offset + length > view.byteLength) return ''; let s = ''; for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i)); return s; } /** * A reader for one sample as libsndfile's sf_read_float returns it, or null * for encodings left to the browser decoder. */ function sampleReader(view, encoding, bytesPerSample, littleEndian) { if (encoding === 'int') { switch (bytesPerSample) { case 1: return null; // handled by the caller: WAV is unsigned, AIFF signed case 2: return (p) => view.getInt16(p, littleEndian) / 0x8000; case 3: return littleEndian ? (p) => (((view.getUint8(p) | (view.getUint8(p + 1) << 8) | (view.getUint8(p + 2) << 16)) << 8) >> 8) / 0x800000 : (p) => (((view.getUint8(p + 2) | (view.getUint8(p + 1) << 8) | (view.getUint8(p) << 16)) << 8) >> 8) / 0x800000; case 4: return (p) => Math.fround(view.getInt32(p, littleEndian)) / 0x80000000; default: return null; } } if (encoding === 'float') { if (bytesPerSample === 4) return (p) => view.getFloat32(p, littleEndian); if (bytesPerSample === 8) return (p) => Math.fround(view.getFloat64(p, littleEndian)); } return null; } /** Interleaved frames -> float32 mono, channel sums in channel order. */ function interleavedToMono(view, dataOffset, frames, channels, bytesPerSample, read) { const mono = new Float32Array(frames); const scratch = new Float32Array(1); for (let f = 0; f < frames; f++) { const base = dataOffset + f * channels * bytesPerSample; scratch[0] = 0; for (let c = 0; c < channels; c++) scratch[0] += read(base + c * bytesPerSample); mono[f] = channels === 1 ? scratch[0] : scratch[0] / channels; } return mono; } function readPcm(view, format) { return format === 'wav' ? readWav(view) : readAiff(view); } function readWav(view) { let fmt = null; let dataOffset = -1; let dataSize = 0; let p = 12; while (p + 8 <= view.byteLength) { const id = ascii(view, p, 4); const size = view.getUint32(p + 4, true); if (id === 'fmt ' && p + 24 <= view.byteLength) { let code = view.getUint16(p + 8, true); // WAVE_FORMAT_EXTENSIBLE: the real format code leads the SubFormat GUID. if (code === 0xfffe && size >= 40 && p + 34 <= view.byteLength) code = view.getUint16(p + 32, true); fmt = { code, channels: view.getUint16(p + 10, true), sampleRate: view.getUint32(p + 12, true), blockAlign: view.getUint16(p + 20, true), }; } else if (id === 'data') { dataOffset = p + 8; // Streamed/RF64 files may carry a placeholder size; trust the file length. dataSize = size === 0xffffffff || dataOffset + size > view.byteLength ? view.byteLength - dataOffset : size; break; } p += 8 + size + (size & 1); } if (!fmt || dataOffset < 0 || !fmt.channels || !fmt.blockAlign) return null; const bytesPerSample = fmt.blockAlign / fmt.channels; const frames = Math.floor(dataSize / fmt.blockAlign); let read; if (fmt.code === 1 && bytesPerSample === 1) read = (q) => (view.getUint8(q) - 128) / 0x80; else if (fmt.code === 1) read = sampleReader(view, 'int', bytesPerSample, true); else if (fmt.code === 3) read = sampleReader(view, 'float', bytesPerSample, true); if (!read) return null; return { sampleRate: fmt.sampleRate, channels: fmt.channels, mono: interleavedToMono(view, dataOffset, frames, fmt.channels, bytesPerSample, read), }; } function readAiff(view) { const aifc = ascii(view, 8, 4) === 'AIFC'; let comm = null; let dataOffset = -1; let dataSize = 0; let p = 12; while (p + 8 <= view.byteLength) { const id = ascii(view, p, 4); const size = view.getUint32(p + 4, false); if (id === 'COMM' && p + 26 <= view.byteLength) { comm = { channels: view.getUint16(p + 8, false), frames: view.getUint32(p + 10, false), bits: view.getUint16(p + 14, false), sampleRate: aiffRate(view, p + 16), compression: aifc && p + 30 <= view.byteLength ? ascii(view, p + 26, 4) : 'NONE', }; } else if (id === 'SSND' && p + 16 <= view.byteLength) { dataOffset = p + 16 + view.getUint32(p + 8, false); dataSize = Math.min(size - 8, view.byteLength - dataOffset); } p += 8 + size + (size & 1); } if (!comm || dataOffset < 0 || !comm.channels) return null; const bytesPerSample = Math.ceil(comm.bits / 8); let read = null; const c = comm.compression; if ((c === 'NONE' || c === 'twos') && bytesPerSample === 1) read = (q) => view.getInt8(q) / 0x80; else if (c === 'NONE' || c === 'twos') read = sampleReader(view, 'int', bytesPerSample, false); else if (c === 'sowt') read = sampleReader(view, 'int', bytesPerSample, true); else if (c === 'fl32' || c === 'FL32') read = sampleReader(view, 'float', 4, false); else if (c === 'fl64' || c === 'FL64') read = sampleReader(view, 'float', 8, false); if (!read) return null; const width = c.toLowerCase() === 'fl32' ? 4 : c.toLowerCase() === 'fl64' ? 8 : bytesPerSample; const frames = Math.min(comm.frames, Math.floor(dataSize / (width * comm.channels))); return { sampleRate: comm.sampleRate, channels: comm.channels, mono: interleavedToMono(view, dataOffset, frames, comm.channels, width, read), }; } /** 80-bit IEEE 754 extended float (AIFF COMM sample rate). */ function aiffRate(view, q) { const exponent = view.getUint16(q, false) & 0x7fff; const mantissa = view.getUint32(q + 2, false) * 2 ** 32 + view.getUint32(q + 6, false); return Math.round(mantissa * 2 ** (exponent - 16383 - 63)); } // ---- container sniffing (browser-decoded formats) --------------------------- function syncsafe(view, offset) { return ((view.getUint8(offset) & 0x7f) << 21) | ((view.getUint8(offset + 1) & 0x7f) << 14) | ((view.getUint8(offset + 2) & 0x7f) << 7) | (view.getUint8(offset + 3) & 0x7f); } /** Byte offset just past a leading ID3v2 tag (0 when there is none). */ function skipId3(view) { if (ascii(view, 0, 3) !== 'ID3' || view.byteLength < 10) return 0; const footer = (view.getUint8(5) & 0x10) ? 10 : 0; return 10 + syncsafe(view, 6) + footer; } export function sniffFormat(view) { const riff = ascii(view, 0, 4); if ((riff === 'RIFF' || riff === 'RF64') && ascii(view, 8, 4) === 'WAVE') return 'wav'; if (riff === 'FORM' && /^AIF[FC]$/.test(ascii(view, 8, 4))) return 'aiff'; if (riff === 'OggS') return 'ogg'; if (ascii(view, 4, 4) === 'ftyp') return 'mp4'; const start = skipId3(view); if (ascii(view, start, 4) === 'fLaC') return 'flac'; if (findMpegFrame(view, start) !== null) return 'mp3'; return null; } export function sniffSampleRate(view, format) { switch (format) { case 'wav': return wavRate(view); case 'aiff': return aiffCommRate(view); case 'flac': return flacRate(view, skipId3(view)); case 'mp3': { const frame = findMpegFrame(view, skipId3(view)); return frame ? frame.sampleRate : null; } case 'ogg': return oggRate(view); case 'mp4': return mp4Rate(view); default: return null; } } function wavRate(view) { let p = 12; while (p + 8 <= view.byteLength) { const id = ascii(view, p, 4); const size = view.getUint32(p + 4, true); if (id === 'fmt ' && p + 16 <= view.byteLength) return view.getUint32(p + 12, true); p += 8 + size + (size & 1); } return null; } function aiffCommRate(view) { let p = 12; while (p + 8 <= view.byteLength) { const size = view.getUint32(p + 4, false); if (ascii(view, p, 4) === 'COMM' && p + 26 <= view.byteLength) return aiffRate(view, p + 16); p += 8 + size + (size & 1); } return null; } function flacRate(view, start) { // STREAMINFO is always the first metadata block: 4-byte marker, 4-byte block // header, then 10 bytes of block/frame sizes before the 20-bit sample rate. const q = start + 8 + 10; if (q + 3 > view.byteLength) return null; return (view.getUint8(q) << 12) | (view.getUint8(q + 1) << 4) | (view.getUint8(q + 2) >> 4); } const MPEG_RATES = { 3: [44100, 48000, 32000], 2: [22050, 24000, 16000], 0: [11025, 12000, 8000] }; const L3_KBPS = { v1: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320], v2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160], }; function parseMpegHeader(view, p) { if (p + 4 > view.byteLength) return null; const b1 = view.getUint8(p + 1), b2 = view.getUint8(p + 2); if (view.getUint8(p) !== 0xff || (b1 & 0xe0) !== 0xe0) return null; const version = (b1 >> 3) & 3; const layer = (b1 >> 1) & 3; const bitrateIndex = (b2 >> 4) & 15; const rateIndex = (b2 >> 2) & 3; if (version === 1 || layer === 0 || bitrateIndex === 15 || rateIndex === 3) return null; const sampleRate = MPEG_RATES[version][rateIndex]; let frameLength = null; if (layer === 1 && bitrateIndex !== 0) { // Layer III const kbps = (version === 3 ? L3_KBPS.v1 : L3_KBPS.v2)[bitrateIndex]; const coeff = version === 3 ? 144 : 72; frameLength = Math.floor((coeff * kbps * 1000) / sampleRate) + ((b2 >> 1) & 1); } return { sampleRate, frameLength }; } /** * First plausible MPEG audio frame at or after `start`. * * A lone 0xFFE sync pattern is common in arbitrary bytes, so a Layer III frame * is only accepted when another header with the same rate follows at its * computed length. */ function findMpegFrame(view, start) { const limit = Math.min(view.byteLength - 4, start + 64 * 1024); for (let p = start; p < limit; p++) { const head = parseMpegHeader(view, p); if (!head) continue; if (head.frameLength === null) return head; const next = parseMpegHeader(view, p + head.frameLength); if (next && next.sampleRate === head.sampleRate) return head; } return null; } function oggRate(view) { const segments = view.byteLength > 26 ? view.getUint8(26) : 0; const packet = 27 + segments; if (ascii(view, packet, 7) === '\x01vorbis' && packet + 16 <= view.byteLength) { return view.getUint32(packet + 12, true); } // Opus always decodes at 48 kHz; its header's "input rate" is informational. if (ascii(view, packet, 8) === 'OpusHead') return 48000; return null; } function mp4Rate(view) { // Audio sample entries (mp4a/alac/Opus/fLaC) carry a 16.16 fixed-point rate // 32 bytes into the box. moov may sit at the end of the file, so scan it all. const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); const types = ['mp4a', 'alac', 'Opus', 'fLaC'].map((t) => [...t].map((c) => c.charCodeAt(0))); for (let i = 4; i + 30 < bytes.length; i++) { for (const t of types) { if (bytes[i] === t[0] && bytes[i + 1] === t[1] && bytes[i + 2] === t[2] && bytes[i + 3] === t[3]) { const rate = view.getUint16(i - 4 + 32, false); if (rate >= MIN_CONTEXT_RATE) return rate; } } } return null; } // ---- ID3 tags --------------------------------------------------------------- /** {artist, title} from an ID3v2.2/2.3/2.4 tag (EasyID3's TPE1/TIT2), or null. */ export function readId3(view) { if (ascii(view, 0, 3) !== 'ID3' || view.byteLength < 10) return null; const major = view.getUint8(3); const flags = view.getUint8(5); const end = Math.min(10 + syncsafe(view, 6), view.byteLength); let p = 10; if (flags & 0x40) p += major === 4 ? syncsafe(view, 10) : view.getUint32(10, false) + 4; const v22 = major === 2; const idLen = v22 ? 3 : 4; const headerLen = v22 ? 6 : 10; const wanted = v22 ? { TT2: 'title', TP1: 'artist' } : { TIT2: 'title', TPE1: 'artist' }; const out = {}; while (p + headerLen <= end) { const id = ascii(view, p, idLen); if (!/^[A-Z0-9]+$/.test(id)) break; // reached padding let size; if (v22) size = (view.getUint8(p + 3) << 16) | (view.getUint8(p + 4) << 8) | view.getUint8(p + 5); else if (major === 4) size = syncsafe(view, p + 4); else size = view.getUint32(p + 4, false); const body = p + headerLen; if (size < 0 || body + size > end) break; if (wanted[id] && size > 1) { const text = decodeId3Text(new Uint8Array(view.buffer, view.byteOffset + body, size)); if (text) out[wanted[id]] = text; } p = body + size; } return out; } function decodeId3Text(bytes) { const encoding = bytes[0]; let data = bytes.subarray(1); let label = 'latin1'; if (encoding === 1) { if (data[0] === 0xfe && data[1] === 0xff) { label = 'utf-16be'; data = data.subarray(2); } else { label = 'utf-16le'; if (data[0] === 0xff && data[1] === 0xfe) data = data.subarray(2); } } else if (encoding === 2) { label = 'utf-16be'; } else if (encoding === 3) { label = 'utf-8'; } // v2.4 separates multiple values with NUL; EasyID3 reports the first. return new TextDecoder(label).decode(data).split('\0')[0].trim(); }