#!/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 # 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 [-][-][-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('render'); 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); }