1/**
2 * The headless renderer: instrument file in, .wav out.
3 *
4 * npm run render -- instruments/drum.instrument.json --seconds 5
5 * npm run render -- instruments/drum.instrument.json --set f0=90 --set nx=128
6 * npm run render -- instruments/drum.instrument.json --events hits.json
7 *
8 * The bake is kept in a sidecar file (<instrument>.bake by default) keyed
9 * to the geometry and accuracy parameters; a render whose key matches
10 * reuses it and only the cheap synthesis runs. --events takes a JSON list
11 * of stimuli, [{"t": 0}, {"t": 0.5, "set": {"f0": 147}}], each event an
12 * excitation at time t with any tuning parameter overridden for that
13 * event alone; with no list, one event at t = 0.
14 */
15import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16import { parseBake, serializeBake } from '../src/host/bake.ts';
17import { requestDevice } from '../src/host/device.ts';
18import { InstrumentSession, kernelDecay } from '../src/host/session.ts';
19import { parseInstrument, paramValues, type StimulusEvent } from '../src/format.ts';
20import { traceToWavBytes, WAV_RATE } from '../src/wav.ts';
21import { errMsg, installWebGpu } from './nodeWebGpu.ts';
23const usage = `usage: npm run render -- <instrument.json> [options]
24 --seconds <s> trace length (default 5)
25 --set k=v override a parameter (repeatable)
26 --events <file> stimulus list JSON: [{"t": 0, "set": {...}}, ...]
27 --out <file.wav> output (default <instrument>.wav)
28 --bake <file> bake sidecar (default <instrument>.bake)
29 --rebake precompute even if a matching bake exists
30 --info print parameters and derived values, then exit`;
32const args = process.argv.slice(2);
33let file: string | null = null;
34let seconds = 5;
35let out: string | null = null;
36let bakePath: string | null = null;
37let eventsPath: string | null = null;
38let rebake = false;
39let info = false;
40const sets: Record<string, number> = {};
41for (let i = 0; i < args.length; i++) {
42 const a = args[i];
43 const next = (): string => {
44 if (i + 1 >= args.length) throw new Error(`${a} needs a value`);
45 return args[++i];
46 };
47 if (a === '--seconds') seconds = Number(next());
48 else if (a === '--out') out = next();
49 else if (a === '--bake') bakePath = next();
50 else if (a === '--events') eventsPath = next();
51 else if (a === '--rebake') rebake = true;
52 else if (a === '--info') info = true;
53 else if (a === '--set') {
54 const kv = next();
55 const m = /^([A-Za-z_][A-Za-z0-9_]*)=(-?[0-9.eE+-]+)$/.exec(kv);
56 if (!m) throw new Error(`--set wants key=value, got '${kv}'`);
57 sets[m[1]] = Number(m[2]);
58 } else if (a === '--help' || a === '-h') {
59 console.log(usage);
60 process.exit(0);
61 } else if (!a.startsWith('--') && file === null) file = a;
62 else throw new Error(`unknown argument '${a}'\n${usage}`);
63}
64if (!file) {
65 console.log(usage);
66 process.exit(1);
67}
69const inst = parseInstrument(readFileSync(file, 'utf8'));
70const values = paramValues(inst, sets);
71const stem = file.replace(/\.instrument\.json$|\.json$/, '');
72const outPath = out ?? `${stem}.wav`;
73const bakeFile = bakePath ?? `${stem}.bake`;
75let events: StimulusEvent[] = [{ t: 0 }];
76if (eventsPath) {
77 events = JSON.parse(readFileSync(eventsPath, 'utf8')) as StimulusEvent[];
78 if (!Array.isArray(events) || events.some((e) => typeof e.t !== 'number')) {
79 throw new Error(`${eventsPath} must be a JSON list of {"t": seconds, "set": {...}}`);
80 }
81}
83console.log(await installWebGpu());
84const device = await requestDevice();
85const session = new InstrumentSession(device, inst, values);
86const scope = session.scope();
88console.log(
89 `${inst.name}: air ${scope.nx}x${scope.ny}x${scope.nz}, dt = ${(1e6 * scope.dt).toFixed(2)} µs ` +
90 `(${Math.round(1 / scope.dt).toLocaleString()} steps/s), ${scope.n_rows} kernel rows of ${scope.ir_steps} steps`,
91);
92if (info) {
93 for (const p of inst.params) {
94 console.log(` ${p.class.padEnd(8)} ${p.key.padEnd(12)} = ${values[p.key]} ${p.unit} (${p.label})`);
95 }
96 console.log(` derived: ${JSON.stringify(scope)}`);
97 process.exit(0);
98}
100const key = await session.bakeKey();
101let bake = null;
102if (!rebake && existsSync(bakeFile)) {
103 const b = parseBake(new Uint8Array(readFileSync(bakeFile)));
104 if (b.key === key) {
105 bake = b;
106 console.log(`bake: reusing ${bakeFile}`);
107 } else {
108 console.log(`bake: ${bakeFile} is for different geometry/accuracy parameters; precomputing afresh`);
109 }
110}
111if (!bake) {
112 const t0 = performance.now();
113 let lastPct = -1;
114 bake = await session.precompute((done, total) => {
115 const pct = Math.floor((100 * done) / total);
116 if (pct !== lastPct && pct % 10 === 0) {
117 process.stdout.write(`\rprecompute: ${pct}%`);
118 lastPct = pct;
119 }
120 });
121 process.stdout.write('\r');
122 const bytes = serializeBake(bake);
123 writeFileSync(bakeFile, bytes);
124 console.log(
125 `precompute: ${((performance.now() - t0) / 1000).toFixed(1)} s, ` +
126 `${(bytes.length / 1e6).toFixed(1)} MB -> ${bakeFile}`,
127 );
128}
129const decay = kernelDecay(bake);
130if (decay !== null) {
131 console.log(
132 `room response at truncation: ${decay > 0 ? (20 * Math.log10(decay)).toFixed(0) : '-inf'} dB` +
133 (decay > 1e-3 ? ' (still ringing; consider a longer ir_seconds)' : ''),
134 );
135}
137const t1 = performance.now();
138const { trace, dt } = await session.render(bake, seconds, events);
139console.log(
140 `render: ${events.length} event${events.length === 1 ? '' : 's'}, ` +
141 `${trace.length.toLocaleString()} samples in ${((performance.now() - t1) / 1000).toFixed(1)} s`,
142);
143writeFileSync(outPath, traceToWavBytes(trace, dt));
144console.log(`wrote ${outPath} (${seconds} s at ${WAV_RATE} Hz)`);
145process.exit(0);