| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- #!/usr/bin/env node
- /*
- * Renders the Cuelume cues the native app plays into WAV files, in every sound set.
- *
- * node scripts/render-sounds.mjs # every cue in CUES and every set, into the default folder
- * node scripts/render-sounds.mjs --out <dir> # somewhere else
- * node scripts/render-sounds.mjs --only toggle # some cues only
- * node scripts/render-sounds.mjs --sets bubble # some sets only
- *
- * Web plays Cuelume (npm `cuelume`, MIT) live with Web Audio. React Native has no
- * Web Audio, so the native app plays these files instead (`default/sounds.js`).
- * Each cue plays in headless Chrome with Cuelume's AudioContext replaced by an
- * OfflineAudioContext, so the file is what a browser plays, through Cuelume's
- * own output bus, limiter and room. The two channels are mixed to one (the room
- * is the only thing that differs between them), trailing silence under -70 dBFS
- * is trimmed and the file is written as 16-bit mono WAV at 32 kHz: every set has
- * to ship in the app, and nothing a cue plays needs more.
- *
- * Files are named [<set>-]<cue>[-<emphasis>][-back].wav; the default set and
- * normal emphasis add nothing. The same names are the keys of `Sounds` (see
- * `cueKey` in `lib/sound/cues.ts`). Math.random in the page is seeded from the
- * file name, so a rerun writes the same bytes. Rerender after bumping `cuelume`
- * in package.json.
- *
- * Chrome: $CHROME, else Google Chrome from /Applications.
- */
- import { spawn } from 'node:child_process';
- import fs from 'node:fs';
- import http from 'node:http';
- import os from 'node:os';
- import path from 'node:path';
- import { fileURLToPath } from 'node:url';
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
- const CUELUME_DIR = path.join(ROOT, 'node_modules', 'cuelume');
- const SAMPLE_RATE = 32000;
- const RENDER_SECONDS = 3; // longer than any cue's tail
- const FLOOR_DB = -70; // trailing samples under this are cut
- const KEEP_MS = 5; // kept after the last sample above the floor
- // Cuelume's themes; Studio > Settings > Sounds picks one, and one per kind of action.
- const SETS = ['default', 'mech', 'bubble', 'press'];
- // What the native app plays; keep in step with `default/sounds.js`.
- const CUES = [
- { cue: 'toggle' },
- { cue: 'toggle', direction: 'back' },
- { cue: 'open', emphasis: 'subtle' },
- { cue: 'close', emphasis: 'subtle' },
- { cue: 'navigate', emphasis: 'subtle' },
- { cue: 'success', emphasis: 'subtle' },
- { cue: 'ready' },
- { cue: 'ready', emphasis: 'subtle' },
- { cue: 'error' },
- ];
- const args = process.argv.slice(2);
- const option = (name, fallback) => {
- const i = args.indexOf(name);
- return i >= 0 ? args[i + 1] : fallback;
- };
- const OUT = path.resolve(ROOT, option('--out', 'packages/app/customization/resources/sounds'));
- const ONLY = option('--only', '').split(',').filter(Boolean);
- const ONLY_SETS = option('--sets', '').split(',').filter(Boolean);
- function fileName({ set, cue, emphasis, direction }) {
- const t = set && set !== 'default' ? `${set}-` : '';
- const e = emphasis && emphasis !== 'normal' ? `-${emphasis}` : '';
- const d = direction === 'back' ? '-back' : '';
- return `${t}${cue}${e}${d}`;
- }
- // ---- the page ---------------------------------------------------------------
- // Runs in the page: seeds Math.random, swaps AudioContext for an offline one,
- // loads a fresh copy of Cuelume (each job has its own tab, so its own module
- // state), plays the cue and returns both channels as base64 float32.
- const pageRender = async (seed, cue, options, sampleRate, seconds) => {
- let s = seed >>> 0;
- Math.random = () => {
- // mulberry32
- s = (s + 0x6d2b79f5) >>> 0;
- let t = s;
- t = Math.imul(t ^ (t >>> 15), t | 1);
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
- const offline = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(sampleRate * seconds), sampleRate });
- // Cuelume plays at once only on a running context.
- Object.defineProperty(offline, 'state', { get: () => 'running', configurable: true });
- offline.resume = () => Promise.resolve();
- window.AudioContext = function AudioContext() {
- return offline;
- };
- // It stays silent until the page has had a user gesture.
- Object.defineProperty(navigator, 'userActivation', { value: { hasBeenActive: true, isActive: true }, configurable: true });
- const cuelume = await import('/cuelume/index.js');
- if (!cuelume.themes.includes(options.theme)) throw new Error(`cuelume has no theme ${options.theme}`);
- // Its cleanup timers disconnect the cue's nodes; offline they would cut the tail.
- const realTimeout = window.setTimeout;
- window.setTimeout = () => 0;
- cuelume.play(cue, options);
- window.setTimeout = realTimeout;
- const buffer = await offline.startRendering();
- const out = [];
- for (let c = 0; c < 2; c++) {
- const bytes = new Uint8Array(buffer.getChannelData(c).buffer);
- let bin = '';
- for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
- out.push(btoa(bin));
- }
- return out;
- };
- // Serves Cuelume's ESM build to the page.
- function serve() {
- const dist = path.join(CUELUME_DIR, 'dist');
- const server = http.createServer((req, res) => {
- const url = new URL(req.url, 'http://localhost');
- if (url.pathname === '/') {
- res.writeHead(200, { 'content-type': 'text/html' });
- res.end('<!doctype html><title>render</title>');
- return;
- }
- const file = path.join(dist, path.normalize(url.pathname.replace(/^\/cuelume\//, '')));
- if (!url.pathname.startsWith('/cuelume/') || !file.startsWith(dist + path.sep) || !fs.existsSync(file)) {
- res.writeHead(404);
- res.end();
- return;
- }
- res.writeHead(200, { 'content-type': 'text/javascript' });
- res.end(fs.readFileSync(file));
- });
- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
- }
- // ---- Chrome over the DevTools protocol --------------------------------------
- function findChrome() {
- const candidates = [
- process.env.CHROME,
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
- '/usr/bin/google-chrome',
- '/usr/bin/chromium',
- ];
- const found = candidates.find((p) => p && fs.existsSync(p));
- if (!found) throw new Error('No Chrome found: set CHROME=/path/to/chrome');
- return found;
- }
- async function launch() {
- const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'neo-sounds-chrome-'));
- const proc = spawn(findChrome(), ['--headless', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--mute-audio', 'about:blank'], {
- stdio: ['ignore', 'ignore', 'pipe'],
- });
- const wsUrl = await new Promise((resolve, reject) => {
- let err = '';
- proc.stderr.on('data', (d) => {
- err += d;
- const m = err.match(/DevTools listening on (ws:\/\/\S+)/);
- if (m) resolve(m[1]);
- });
- proc.on('exit', (code) => reject(new Error(`Chrome exited (${code}): ${err}`)));
- });
- const ws = new WebSocket(wsUrl);
- await new Promise((resolve, reject) => {
- ws.onopen = resolve;
- ws.onerror = reject;
- });
- let id = 0;
- const pending = new Map();
- ws.onmessage = (event) => {
- const msg = JSON.parse(event.data);
- const p = pending.get(msg.id);
- if (!p) return;
- pending.delete(msg.id);
- if (msg.error) p.reject(new Error(msg.error.message));
- else p.resolve(msg.result);
- };
- const send = (method, params = {}, sessionId) =>
- new Promise((resolve, reject) => {
- const msgId = ++id;
- pending.set(msgId, { resolve, reject });
- ws.send(JSON.stringify({ id: msgId, method, params, ...(sessionId ? { sessionId } : {}) }));
- });
- // One tab per job: a fresh module map, so every cue plays as a page's first sound.
- const evaluateInNewTab = async (url, expression) => {
- const { targetId } = await send('Target.createTarget', { url });
- try {
- const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
- const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId);
- if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
- return r.result.value;
- } finally {
- await send('Target.closeTarget', { targetId });
- }
- };
- const close = async () => {
- ws.close();
- const exited = new Promise((resolve) => proc.once('exit', resolve));
- proc.kill();
- await exited;
- fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
- };
- return { evaluateInNewTab, close };
- }
- // ---- files ------------------------------------------------------------------
- function seedFor(name) {
- // FNV-1a
- let h = 0x811c9dc5;
- for (const ch of name) h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193) >>> 0;
- return h;
- }
- function toWav(left, right) {
- const floor = 10 ** (FLOOR_DB / 20);
- const mono = left.map((v, i) => (v + right[i]) / 2);
- let last = 0;
- let peak = 0;
- for (let i = mono.length - 1; i >= 0; i--) {
- if (Math.abs(mono[i]) > floor) {
- last = i;
- break;
- }
- }
- const frames = Math.min(mono.length, last + 1 + Math.round((KEEP_MS / 1000) * SAMPLE_RATE));
- const data = Buffer.alloc(frames * 2);
- let clipped = 0;
- const q = (v) => {
- peak = Math.max(peak, Math.abs(v));
- if (v > 1 || v < -1) clipped++;
- return Math.max(-32768, Math.min(32767, Math.round(v * 32768)));
- };
- for (let i = 0; i < frames; i++) data.writeInt16LE(q(mono[i]), i * 2);
- const head = Buffer.alloc(44);
- head.write('RIFF', 0);
- head.writeUInt32LE(36 + data.length, 4);
- head.write('WAVE', 8);
- head.write('fmt ', 12);
- head.writeUInt32LE(16, 16);
- head.writeUInt16LE(1, 20); // PCM
- head.writeUInt16LE(1, 22); // mono
- head.writeUInt32LE(SAMPLE_RATE, 24);
- head.writeUInt32LE(SAMPLE_RATE * 2, 28);
- head.writeUInt16LE(2, 32);
- head.writeUInt16LE(16, 34);
- head.write('data', 36);
- head.writeUInt32LE(data.length, 40);
- return { wav: Buffer.concat([head, data]), frames, clipped, peak };
- }
- const floats = (b64) => {
- const buf = Buffer.from(b64, 'base64');
- return new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
- };
- // ---- run --------------------------------------------------------------------
- if (!fs.existsSync(path.join(CUELUME_DIR, 'package.json'))) {
- console.error('cuelume is not installed: run yarn install');
- process.exit(1);
- }
- const { version } = JSON.parse(fs.readFileSync(path.join(CUELUME_DIR, 'package.json'), 'utf8'));
- const jobs = SETS.filter((set) => !ONLY_SETS.length || ONLY_SETS.includes(set)).flatMap((set) =>
- CUES.filter((job) => !ONLY.length || ONLY.includes(job.cue)).map((job) => ({ ...job, set })),
- );
- fs.mkdirSync(OUT, { recursive: true });
- const server = await serve();
- const pageUrl = `http://127.0.0.1:${server.address().port}/`;
- const chrome = await launch();
- const failed = [];
- try {
- for (const job of jobs) {
- const name = fileName(job);
- const options = { theme: job.set, emphasis: job.emphasis ?? 'normal', ...(job.direction ? { direction: job.direction } : {}) };
- const call = `(${pageRender})(${seedFor(name)}, ${JSON.stringify(job.cue)}, ${JSON.stringify(options)}, ${SAMPLE_RATE}, ${RENDER_SECONDS})`;
- try {
- const [l, r] = await chrome.evaluateInNewTab(pageUrl, call);
- const { wav, frames, clipped, peak } = toWav(floats(l), floats(r));
- if (frames <= Math.round((KEEP_MS / 1000) * SAMPLE_RATE) + 1) throw new Error('silent');
- fs.writeFileSync(path.join(OUT, `${name}.wav`), wav);
- const ms = Math.round((frames / SAMPLE_RATE) * 1000);
- const db = (20 * Math.log10(peak)).toFixed(1);
- console.log(`${name}.wav ${ms} ms peak ${db} dBFS ${(wav.length / 1024).toFixed(1)} KB${clipped ? ` ${clipped} samples clipped` : ''}`);
- } catch (e) {
- failed.push(`${name}: ${e.message}`);
- }
- }
- } finally {
- await chrome.close();
- server.close();
- }
- console.log(`cuelume ${version}: wrote ${jobs.length - failed.length} files to ${path.relative(ROOT, OUT)}`);
- if (failed.length) {
- console.error(`failed:\n ${failed.join('\n ')}`);
- process.exit(1);
- }
|