Files
ai-agent/reel/sfx/measure.mjs
Gabriel Vidal 450c940b89 feat(reel): UI sound palette from 効果音ラボ, fetched on demand
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>
2026-10-11 04:50:48 +02:00

95 lines
4.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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`);
}