The same library as the Lost in Production trailer: 29 semantic names (tap, type, pop, notif, whoosh, register, printer, stamp, tick, bell…) mapped to soundeffect-lab.info files in sfx/sources.json, each with its measured onset so the mixer puts the transient exactly on the beat. The site's terms allow commercial use but not redistributing the files, so they are gitignored and `npm run sfx` downloads them; catalog.json indexes the whole library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
95 lines
4.0 KiB
JavaScript
95 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Measures every fetched sound effect and prints a table; `--write` stores the
|
||
* numbers back into sfx/sources.json. Run it after changing a pick (and
|
||
* `npm run sfx`), since scripts/mix.mjs trusts these values.
|
||
*
|
||
* node sfx/measure.mjs [--write]
|
||
*
|
||
* Each public/sfx/<name>.mp3 is decoded exactly the way the mixer decodes it
|
||
* (ffmpeg → 48 kHz stereo float), so the times are the ones the mixer sees:
|
||
*
|
||
* duration length of the decoded file (s)
|
||
* onset first sample above −40 dBFS (s): the mixer puts it on the cue time
|
||
* peakDb sample peak (dBFS)
|
||
* peakAt where that peak is (s): for swells (whoosh, swoosh) the loud part
|
||
* audible last sample above −50 dBFS (s): how long it really rings
|
||
*/
|
||
import { spawnSync } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = path.dirname(HERE);
|
||
const SOURCES = path.join(HERE, "sources.json");
|
||
const SR = 48000;
|
||
const ONSET = 10 ** (-40 / 20);
|
||
const AUDIBLE = 10 ** (-50 / 20);
|
||
|
||
/** the system ffmpeg, else the one bundled with Remotion */
|
||
function ffmpeg(args) {
|
||
let r = spawnSync("ffmpeg", args, { maxBuffer: 1 << 30 });
|
||
if (r.error?.code === "ENOENT") r = spawnSync("npx", ["--no-install", "remotion", "ffmpeg", ...args], { cwd: ROOT, maxBuffer: 1 << 30 });
|
||
if (r.status !== 0) throw new Error(`ffmpeg ${args.join(" ")}: ${r.stderr}`);
|
||
return r.stdout;
|
||
}
|
||
|
||
/** 48 kHz stereo samples scaled to ±1. 16-bit WAV is the one PCM output Remotion's ffmpeg has. */
|
||
function decode(file) {
|
||
const buf = ffmpeg(["-hide_banner", "-loglevel", "error", "-i", file, "-af", `aresample=${SR}`, "-ac", "2", "-c:a", "pcm_s16le", "-f", "wav", "-"]);
|
||
let o = 12; // walk the RIFF chunks to "data" (its size is a placeholder when piped)
|
||
while (o + 8 <= buf.length && buf.toString("latin1", o, o + 4) !== "data") o += 8 + buf.readUInt32LE(o + 4) + (buf.readUInt32LE(o + 4) & 1);
|
||
if (o + 8 > buf.length) throw new Error(`no audio decoded from ${file}`);
|
||
const bytes = buf.length - o - 8;
|
||
const pcm = new Int16Array(buf.buffer.slice(buf.byteOffset + o + 8, buf.byteOffset + o + 8 + bytes - (bytes % 4)));
|
||
return Float32Array.from(pcm, (v) => v / 32768);
|
||
}
|
||
|
||
function measure(file) {
|
||
const pcm = decode(file);
|
||
const n = pcm.length / 2;
|
||
let peak = 0;
|
||
let peakI = 0;
|
||
let onsetI = -1;
|
||
let lastI = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const a = Math.max(Math.abs(pcm[2 * i]), Math.abs(pcm[2 * i + 1]));
|
||
if (a > peak) {
|
||
peak = a;
|
||
peakI = i;
|
||
}
|
||
if (onsetI < 0 && a >= ONSET) onsetI = i;
|
||
if (a >= AUDIBLE) lastI = i;
|
||
}
|
||
const s = (i, d = 3) => Number((i / SR).toFixed(d));
|
||
return {
|
||
duration: s(n),
|
||
onset: s(Math.max(onsetI, 0)),
|
||
peakDb: Number((20 * Math.log10(Math.max(peak, 1e-9))).toFixed(1)),
|
||
peakAt: s(peakI),
|
||
audible: s(lastI + 1),
|
||
};
|
||
}
|
||
|
||
const sources = JSON.parse(fs.readFileSync(SOURCES, "utf8"));
|
||
const rows = [];
|
||
for (const [name, entry] of Object.entries(sources)) {
|
||
const file = path.join(ROOT, "public/sfx", `${name}.mp3`);
|
||
if (!fs.existsSync(file)) throw new Error(`missing ${file}: run \`npm run sfx\` first`);
|
||
const m = measure(file);
|
||
Object.assign(entry, m);
|
||
rows.push([name, m.duration, m.onset, m.peakDb, m.peakAt, m.audible, entry.path + (entry.clip ? ` [${entry.clip.start}–${entry.clip.end}]` : "")]);
|
||
}
|
||
|
||
const head = ["name", "duration", "onset", "peakDb", "peakAt", "audible", "source"];
|
||
const w = head.map((h, c) => Math.max(h.length, ...rows.map((r) => String(r[c]).length)));
|
||
for (const r of [head, ...rows]) console.log(r.map((v, c) => (c === 0 || c === 6 ? String(v).padEnd(w[c]) : String(v).padStart(w[c]))).join(" "));
|
||
|
||
if (process.argv.includes("--write")) {
|
||
// one-line clip objects keep the file readable
|
||
const json = JSON.stringify(sources, null, 2).replace(/"clip": \{\s+([^}]*?)\s+\}/g, (_, body) => `"clip": { ${body.replace(/\s*\n\s*/g, " ")} }`);
|
||
fs.writeFileSync(SOURCES, json + "\n");
|
||
console.log(`→ ${path.relative(ROOT, SOURCES)} updated`);
|
||
}
|