render-sounds.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. #!/usr/bin/env node
  2. /*
  3. * Renders the Cuelume cues the native app plays into WAV files, in every sound set.
  4. *
  5. * node scripts/render-sounds.mjs # every cue in CUES and every set, into the default folder
  6. * node scripts/render-sounds.mjs --out <dir> # somewhere else
  7. * node scripts/render-sounds.mjs --only toggle # some cues only
  8. * node scripts/render-sounds.mjs --sets bubble # some sets only
  9. *
  10. * Web plays Cuelume (npm `cuelume`, MIT) live with Web Audio. React Native has no
  11. * Web Audio, so the native app plays these files instead (`default/sounds.js`).
  12. * Each cue plays in headless Chrome with Cuelume's AudioContext replaced by an
  13. * OfflineAudioContext, so the file is what a browser plays, through Cuelume's
  14. * own output bus, limiter and room. The two channels are mixed to one (the room
  15. * is the only thing that differs between them), trailing silence under -70 dBFS
  16. * is trimmed and the file is written as 16-bit mono WAV at 32 kHz: every set has
  17. * to ship in the app, and nothing a cue plays needs more.
  18. *
  19. * Files are named [<set>-]<cue>[-<emphasis>][-back].wav; the default set and
  20. * normal emphasis add nothing. The same names are the keys of `Sounds` (see
  21. * `cueKey` in `lib/sound/cues.ts`). Math.random in the page is seeded from the
  22. * file name, so a rerun writes the same bytes. Rerender after bumping `cuelume`
  23. * in package.json.
  24. *
  25. * Chrome: $CHROME, else Google Chrome from /Applications.
  26. */
  27. import { spawn } from 'node:child_process';
  28. import fs from 'node:fs';
  29. import http from 'node:http';
  30. import os from 'node:os';
  31. import path from 'node:path';
  32. import { fileURLToPath } from 'node:url';
  33. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  34. const CUELUME_DIR = path.join(ROOT, 'node_modules', 'cuelume');
  35. const SAMPLE_RATE = 32000;
  36. const RENDER_SECONDS = 3; // longer than any cue's tail
  37. const FLOOR_DB = -70; // trailing samples under this are cut
  38. const KEEP_MS = 5; // kept after the last sample above the floor
  39. // Cuelume's themes; Studio > Settings > Sounds picks one, and one per kind of action.
  40. const SETS = ['default', 'mech', 'bubble', 'press'];
  41. // What the native app plays; keep in step with `default/sounds.js`.
  42. const CUES = [
  43. { cue: 'toggle' },
  44. { cue: 'toggle', direction: 'back' },
  45. { cue: 'open', emphasis: 'subtle' },
  46. { cue: 'close', emphasis: 'subtle' },
  47. { cue: 'navigate', emphasis: 'subtle' },
  48. { cue: 'success', emphasis: 'subtle' },
  49. { cue: 'ready' },
  50. { cue: 'ready', emphasis: 'subtle' },
  51. { cue: 'error' },
  52. ];
  53. const args = process.argv.slice(2);
  54. const option = (name, fallback) => {
  55. const i = args.indexOf(name);
  56. return i >= 0 ? args[i + 1] : fallback;
  57. };
  58. const OUT = path.resolve(ROOT, option('--out', 'packages/app/customization/resources/sounds'));
  59. const ONLY = option('--only', '').split(',').filter(Boolean);
  60. const ONLY_SETS = option('--sets', '').split(',').filter(Boolean);
  61. function fileName({ set, cue, emphasis, direction }) {
  62. const t = set && set !== 'default' ? `${set}-` : '';
  63. const e = emphasis && emphasis !== 'normal' ? `-${emphasis}` : '';
  64. const d = direction === 'back' ? '-back' : '';
  65. return `${t}${cue}${e}${d}`;
  66. }
  67. // ---- the page ---------------------------------------------------------------
  68. // Runs in the page: seeds Math.random, swaps AudioContext for an offline one,
  69. // loads a fresh copy of Cuelume (each job has its own tab, so its own module
  70. // state), plays the cue and returns both channels as base64 float32.
  71. const pageRender = async (seed, cue, options, sampleRate, seconds) => {
  72. let s = seed >>> 0;
  73. Math.random = () => {
  74. // mulberry32
  75. s = (s + 0x6d2b79f5) >>> 0;
  76. let t = s;
  77. t = Math.imul(t ^ (t >>> 15), t | 1);
  78. t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  79. return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  80. };
  81. const offline = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(sampleRate * seconds), sampleRate });
  82. // Cuelume plays at once only on a running context.
  83. Object.defineProperty(offline, 'state', { get: () => 'running', configurable: true });
  84. offline.resume = () => Promise.resolve();
  85. window.AudioContext = function AudioContext() {
  86. return offline;
  87. };
  88. // It stays silent until the page has had a user gesture.
  89. Object.defineProperty(navigator, 'userActivation', { value: { hasBeenActive: true, isActive: true }, configurable: true });
  90. const cuelume = await import('/cuelume/index.js');
  91. if (!cuelume.themes.includes(options.theme)) throw new Error(`cuelume has no theme ${options.theme}`);
  92. // Its cleanup timers disconnect the cue's nodes; offline they would cut the tail.
  93. const realTimeout = window.setTimeout;
  94. window.setTimeout = () => 0;
  95. cuelume.play(cue, options);
  96. window.setTimeout = realTimeout;
  97. const buffer = await offline.startRendering();
  98. const out = [];
  99. for (let c = 0; c < 2; c++) {
  100. const bytes = new Uint8Array(buffer.getChannelData(c).buffer);
  101. let bin = '';
  102. for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  103. out.push(btoa(bin));
  104. }
  105. return out;
  106. };
  107. // Serves Cuelume's ESM build to the page.
  108. function serve() {
  109. const dist = path.join(CUELUME_DIR, 'dist');
  110. const server = http.createServer((req, res) => {
  111. const url = new URL(req.url, 'http://localhost');
  112. if (url.pathname === '/') {
  113. res.writeHead(200, { 'content-type': 'text/html' });
  114. res.end('<!doctype html><title>render</title>');
  115. return;
  116. }
  117. const file = path.join(dist, path.normalize(url.pathname.replace(/^\/cuelume\//, '')));
  118. if (!url.pathname.startsWith('/cuelume/') || !file.startsWith(dist + path.sep) || !fs.existsSync(file)) {
  119. res.writeHead(404);
  120. res.end();
  121. return;
  122. }
  123. res.writeHead(200, { 'content-type': 'text/javascript' });
  124. res.end(fs.readFileSync(file));
  125. });
  126. return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
  127. }
  128. // ---- Chrome over the DevTools protocol --------------------------------------
  129. function findChrome() {
  130. const candidates = [
  131. process.env.CHROME,
  132. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  133. '/usr/bin/google-chrome',
  134. '/usr/bin/chromium',
  135. ];
  136. const found = candidates.find((p) => p && fs.existsSync(p));
  137. if (!found) throw new Error('No Chrome found: set CHROME=/path/to/chrome');
  138. return found;
  139. }
  140. async function launch() {
  141. const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'neo-sounds-chrome-'));
  142. const proc = spawn(findChrome(), ['--headless', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--mute-audio', 'about:blank'], {
  143. stdio: ['ignore', 'ignore', 'pipe'],
  144. });
  145. const wsUrl = await new Promise((resolve, reject) => {
  146. let err = '';
  147. proc.stderr.on('data', (d) => {
  148. err += d;
  149. const m = err.match(/DevTools listening on (ws:\/\/\S+)/);
  150. if (m) resolve(m[1]);
  151. });
  152. proc.on('exit', (code) => reject(new Error(`Chrome exited (${code}): ${err}`)));
  153. });
  154. const ws = new WebSocket(wsUrl);
  155. await new Promise((resolve, reject) => {
  156. ws.onopen = resolve;
  157. ws.onerror = reject;
  158. });
  159. let id = 0;
  160. const pending = new Map();
  161. ws.onmessage = (event) => {
  162. const msg = JSON.parse(event.data);
  163. const p = pending.get(msg.id);
  164. if (!p) return;
  165. pending.delete(msg.id);
  166. if (msg.error) p.reject(new Error(msg.error.message));
  167. else p.resolve(msg.result);
  168. };
  169. const send = (method, params = {}, sessionId) =>
  170. new Promise((resolve, reject) => {
  171. const msgId = ++id;
  172. pending.set(msgId, { resolve, reject });
  173. ws.send(JSON.stringify({ id: msgId, method, params, ...(sessionId ? { sessionId } : {}) }));
  174. });
  175. // One tab per job: a fresh module map, so every cue plays as a page's first sound.
  176. const evaluateInNewTab = async (url, expression) => {
  177. const { targetId } = await send('Target.createTarget', { url });
  178. try {
  179. const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
  180. const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId);
  181. if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
  182. return r.result.value;
  183. } finally {
  184. await send('Target.closeTarget', { targetId });
  185. }
  186. };
  187. const close = async () => {
  188. ws.close();
  189. const exited = new Promise((resolve) => proc.once('exit', resolve));
  190. proc.kill();
  191. await exited;
  192. fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
  193. };
  194. return { evaluateInNewTab, close };
  195. }
  196. // ---- files ------------------------------------------------------------------
  197. function seedFor(name) {
  198. // FNV-1a
  199. let h = 0x811c9dc5;
  200. for (const ch of name) h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193) >>> 0;
  201. return h;
  202. }
  203. function toWav(left, right) {
  204. const floor = 10 ** (FLOOR_DB / 20);
  205. const mono = left.map((v, i) => (v + right[i]) / 2);
  206. let last = 0;
  207. let peak = 0;
  208. for (let i = mono.length - 1; i >= 0; i--) {
  209. if (Math.abs(mono[i]) > floor) {
  210. last = i;
  211. break;
  212. }
  213. }
  214. const frames = Math.min(mono.length, last + 1 + Math.round((KEEP_MS / 1000) * SAMPLE_RATE));
  215. const data = Buffer.alloc(frames * 2);
  216. let clipped = 0;
  217. const q = (v) => {
  218. peak = Math.max(peak, Math.abs(v));
  219. if (v > 1 || v < -1) clipped++;
  220. return Math.max(-32768, Math.min(32767, Math.round(v * 32768)));
  221. };
  222. for (let i = 0; i < frames; i++) data.writeInt16LE(q(mono[i]), i * 2);
  223. const head = Buffer.alloc(44);
  224. head.write('RIFF', 0);
  225. head.writeUInt32LE(36 + data.length, 4);
  226. head.write('WAVE', 8);
  227. head.write('fmt ', 12);
  228. head.writeUInt32LE(16, 16);
  229. head.writeUInt16LE(1, 20); // PCM
  230. head.writeUInt16LE(1, 22); // mono
  231. head.writeUInt32LE(SAMPLE_RATE, 24);
  232. head.writeUInt32LE(SAMPLE_RATE * 2, 28);
  233. head.writeUInt16LE(2, 32);
  234. head.writeUInt16LE(16, 34);
  235. head.write('data', 36);
  236. head.writeUInt32LE(data.length, 40);
  237. return { wav: Buffer.concat([head, data]), frames, clipped, peak };
  238. }
  239. const floats = (b64) => {
  240. const buf = Buffer.from(b64, 'base64');
  241. return new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
  242. };
  243. // ---- run --------------------------------------------------------------------
  244. if (!fs.existsSync(path.join(CUELUME_DIR, 'package.json'))) {
  245. console.error('cuelume is not installed: run yarn install');
  246. process.exit(1);
  247. }
  248. const { version } = JSON.parse(fs.readFileSync(path.join(CUELUME_DIR, 'package.json'), 'utf8'));
  249. const jobs = SETS.filter((set) => !ONLY_SETS.length || ONLY_SETS.includes(set)).flatMap((set) =>
  250. CUES.filter((job) => !ONLY.length || ONLY.includes(job.cue)).map((job) => ({ ...job, set })),
  251. );
  252. fs.mkdirSync(OUT, { recursive: true });
  253. const server = await serve();
  254. const pageUrl = `http://127.0.0.1:${server.address().port}/`;
  255. const chrome = await launch();
  256. const failed = [];
  257. try {
  258. for (const job of jobs) {
  259. const name = fileName(job);
  260. const options = { theme: job.set, emphasis: job.emphasis ?? 'normal', ...(job.direction ? { direction: job.direction } : {}) };
  261. const call = `(${pageRender})(${seedFor(name)}, ${JSON.stringify(job.cue)}, ${JSON.stringify(options)}, ${SAMPLE_RATE}, ${RENDER_SECONDS})`;
  262. try {
  263. const [l, r] = await chrome.evaluateInNewTab(pageUrl, call);
  264. const { wav, frames, clipped, peak } = toWav(floats(l), floats(r));
  265. if (frames <= Math.round((KEEP_MS / 1000) * SAMPLE_RATE) + 1) throw new Error('silent');
  266. fs.writeFileSync(path.join(OUT, `${name}.wav`), wav);
  267. const ms = Math.round((frames / SAMPLE_RATE) * 1000);
  268. const db = (20 * Math.log10(peak)).toFixed(1);
  269. console.log(`${name}.wav ${ms} ms peak ${db} dBFS ${(wav.length / 1024).toFixed(1)} KB${clipped ? ` ${clipped} samples clipped` : ''}`);
  270. } catch (e) {
  271. failed.push(`${name}: ${e.message}`);
  272. }
  273. }
  274. } finally {
  275. await chrome.close();
  276. server.close();
  277. }
  278. console.log(`cuelume ${version}: wrote ${jobs.length - failed.length} files to ${path.relative(ROOT, OUT)}`);
  279. if (failed.length) {
  280. console.error(`failed:\n ${failed.join('\n ')}`);
  281. process.exit(1);
  282. }