// File -> AudioBuffer. Decoding is done once, up front, and the PCM is kept: // the analysis pass, the seed hash, the click track and the export mux all read // the same decoded samples. let sharedContext = null; function context() { if (!sharedContext) { sharedContext = new (window.AudioContext || window.webkitAudioContext)(); } return sharedContext; } export async function decodeFile(file) { const arrayBuffer = await file.arrayBuffer(); return decodeArrayBuffer(arrayBuffer); } /** * The rate everything is decoded at: the synth's rate (see audio/synth.js), * never the hardware context rate. * * `AudioContext.decodeAudioData` resamples to the CONTEXT rate, so the same * file came out 44.1k in one browser and 48k in another — and, worse, the * probe pages (filmstrip/gallery/venue-gallery) all render from 44.1k synth * while the app decoded the bank wav at the context rate. Different spectra, * different features, different videos: no preview deep-link could ever * reproduce its image. Decoding in a fixed-rate OfflineAudioContext makes * the app see the same audio the probes do. See src/ui/preview.js. */ export const DECODE_RATE = 44100; export async function decodeArrayBuffer(arrayBuffer) { // Our own files first, sample-exact: even at a 1.0 ratio Chrome's // decodeAudioData perturbs int16→float conversion (measured 2.6e-5), // which is enough to flip section classification on quiet tracks and // break every preview deep-link. A manual parse keeps every bit. const wav = decodeWav(arrayBuffer); if (wav) return wav; const ctx = new OfflineAudioContext(2, DECODE_RATE, DECODE_RATE); // decodeAudioData detaches the buffer, so hand it a copy if the caller may // still need the original bytes. return await ctx.decodeAudioData(arrayBuffer.slice(0)); } /** * Exact WAV decode: 8/16/24-bit PCM and 32-bit float, any rate, any * channels. No resampling, no conversion noise — int16 comes back as * V / 32768, the same floats the bank PCM holds. Returns null for * anything else (compressed formats still go through decodeAudioData). */ export function decodeWav(arrayBuffer) { const bytes = new Uint8Array(arrayBuffer); const dv = new DataView(arrayBuffer); const str = (o, n) => { let s = ''; for (let i = 0; i < n; i++) s += String.fromCharCode(bytes[o + i]); return s; }; if (bytes.length < 44 || str(0, 4) !== 'RIFF' || str(8, 4) !== 'WAVE') return null; let fmt = null, dataOff = 0, dataLen = 0; let o = 12; while (o + 8 <= bytes.length) { const id = str(o, 4); const size = dv.getUint32(o + 4, true); if (id === 'fmt ') { fmt = { audioFormat: dv.getUint16(o + 8, true), channels: dv.getUint16(o + 10, true), sampleRate: dv.getUint32(o + 12, true), bits: dv.getUint16(o + 22, true), }; } else if (id === 'data') { dataOff = o + 8; dataLen = Math.min(size, bytes.length - dataOff); } o += 8 + size + (size % 2); } if (!fmt || !dataLen || (fmt.audioFormat !== 1 && fmt.audioFormat !== 3)) return null; if (fmt.channels < 1 || fmt.channels > 32 || !fmt.sampleRate) return null; const bytesPerSample = fmt.audioFormat === 3 ? 4 : fmt.bits / 8; if (![1, 2, 3, 4].includes(bytesPerSample) || (fmt.audioFormat === 3 && fmt.bits !== 32)) return null; const frames = Math.floor(dataLen / (bytesPerSample * fmt.channels)); if (frames < 1) return null; let out; try { out = new AudioBuffer({ length: frames, sampleRate: fmt.sampleRate, numberOfChannels: fmt.channels, }); } catch { return null; } for (let c = 0; c < fmt.channels; c++) { const ch = out.getChannelData(c); for (let i = 0; i < frames; i++) { const at = dataOff + (i * fmt.channels + c) * bytesPerSample; let v; if (fmt.audioFormat === 3) { v = dv.getFloat32(at, true); } else if (bytesPerSample === 1) { v = (bytes[at] - 128) / 128; } else if (bytesPerSample === 2) { v = dv.getInt16(at, true) / 32768; } else if (bytesPerSample === 3) { let s = bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16); if (s & 0x800000) s -= 0x1000000; v = s / 8388608; } else { v = dv.getInt32(at, true) / 2147483648; } ch[i] = v; } } return out; } /** Interleaved mono mixdown. Used for the content hash that seeds the look. */ export function monoSamples(audioBuffer) { const channels = audioBuffer.numberOfChannels; const left = audioBuffer.getChannelData(0); if (channels === 1) return left; const right = audioBuffer.getChannelData(1); const out = new Float32Array(left.length); for (let i = 0; i < left.length; i++) out[i] = (left[i] + right[i]) * 0.5; return out; } export function audioContext() { return context(); } /** Human-readable duration, used by the transport display. */ export function formatTime(seconds) { if (!isFinite(seconds) || seconds < 0) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${String(s).padStart(2, '0')}`; }