Every rendered still links to the app frame that produced it (song + seed + frame + venue/viz pins + probe warm-up, new window), via shared src/ui/preview.js. Supporting work, each measured: app honors ?frame/?venueSel/?viz/?warm= with pre-build pins (Show.applyPins) and primes deep-link loads; shared pinViz/pinVenueShell paths for gallery, filmstrip and app; probes prime and carry their warm-up; bank probes analyse the bank PCM and file decode is fixed-rate with bit-exact WAV parsing so app and probes see identical samples; materials built mid-update compile synchronously (needsCompile). Round trip verified bit-identical gallery-versus-app.
137 lines
5.3 KiB
JavaScript
137 lines
5.3 KiB
JavaScript
// 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')}`;
|
|
}
|