/** * The headless renderer: instrument file in, .wav out. * * npm run render -- instruments/drum.instrument.json --seconds 5 * npm run render -- instruments/drum.instrument.json --set f0=90 --set nx=128 * npm run render -- instruments/drum.instrument.json --events hits.json * * The bake is kept in a sidecar file (.bake by default) keyed * to the geometry and accuracy parameters; a render whose key matches * reuses it and only the cheap synthesis runs. --events takes a JSON list * of stimuli, [{"t": 0}, {"t": 0.5, "set": {"f0": 147}}], each event an * excitation at time t with any tuning parameter overridden for that * event alone; with no list, one event at t = 0. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { parseBake, serializeBake } from '../src/host/bake.ts'; import { requestDevice } from '../src/host/device.ts'; import { InstrumentSession, kernelDecay } from '../src/host/session.ts'; import { parseInstrument, paramValues, type StimulusEvent } from '../src/format.ts'; import { traceToWavBytes, WAV_RATE } from '../src/wav.ts'; import { errMsg, installWebGpu } from './nodeWebGpu.ts'; const usage = `usage: npm run render -- [options] --seconds trace length (default 5) --set k=v override a parameter (repeatable) --events stimulus list JSON: [{"t": 0, "set": {...}}, ...] --out output (default .wav) --bake bake sidecar (default .bake) --rebake precompute even if a matching bake exists --info print parameters and derived values, then exit`; const args = process.argv.slice(2); let file: string | null = null; let seconds = 5; let out: string | null = null; let bakePath: string | null = null; let eventsPath: string | null = null; let rebake = false; let info = false; const sets: Record = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; const next = (): string => { if (i + 1 >= args.length) throw new Error(`${a} needs a value`); return args[++i]; }; if (a === '--seconds') seconds = Number(next()); else if (a === '--out') out = next(); else if (a === '--bake') bakePath = next(); else if (a === '--events') eventsPath = next(); else if (a === '--rebake') rebake = true; else if (a === '--info') info = true; else if (a === '--set') { const kv = next(); const m = /^([A-Za-z_][A-Za-z0-9_]*)=(-?[0-9.eE+-]+)$/.exec(kv); if (!m) throw new Error(`--set wants key=value, got '${kv}'`); sets[m[1]] = Number(m[2]); } else if (a === '--help' || a === '-h') { console.log(usage); process.exit(0); } else if (!a.startsWith('--') && file === null) file = a; else throw new Error(`unknown argument '${a}'\n${usage}`); } if (!file) { console.log(usage); process.exit(1); } const inst = parseInstrument(readFileSync(file, 'utf8')); const values = paramValues(inst, sets); const stem = file.replace(/\.instrument\.json$|\.json$/, ''); const outPath = out ?? `${stem}.wav`; const bakeFile = bakePath ?? `${stem}.bake`; let events: StimulusEvent[] = [{ t: 0 }]; if (eventsPath) { events = JSON.parse(readFileSync(eventsPath, 'utf8')) as StimulusEvent[]; if (!Array.isArray(events) || events.some((e) => typeof e.t !== 'number')) { throw new Error(`${eventsPath} must be a JSON list of {"t": seconds, "set": {...}}`); } } console.log(await installWebGpu()); const device = await requestDevice(); const session = new InstrumentSession(device, inst, values); const scope = session.scope(); console.log( `${inst.name}: air ${scope.nx}x${scope.ny}x${scope.nz}, dt = ${(1e6 * scope.dt).toFixed(2)} µs ` + `(${Math.round(1 / scope.dt).toLocaleString()} steps/s), ${scope.n_rows} kernel rows of ${scope.ir_steps} steps`, ); if (info) { for (const p of inst.params) { console.log(` ${p.class.padEnd(8)} ${p.key.padEnd(12)} = ${values[p.key]} ${p.unit} (${p.label})`); } console.log(` derived: ${JSON.stringify(scope)}`); process.exit(0); } const key = await session.bakeKey(); let bake = null; if (!rebake && existsSync(bakeFile)) { const b = parseBake(new Uint8Array(readFileSync(bakeFile))); if (b.key === key) { bake = b; console.log(`bake: reusing ${bakeFile}`); } else { console.log(`bake: ${bakeFile} is for different geometry/accuracy parameters; precomputing afresh`); } } if (!bake) { const t0 = performance.now(); let lastPct = -1; bake = await session.precompute((done, total) => { const pct = Math.floor((100 * done) / total); if (pct !== lastPct && pct % 10 === 0) { process.stdout.write(`\rprecompute: ${pct}%`); lastPct = pct; } }); process.stdout.write('\r'); const bytes = serializeBake(bake); writeFileSync(bakeFile, bytes); console.log( `precompute: ${((performance.now() - t0) / 1000).toFixed(1)} s, ` + `${(bytes.length / 1e6).toFixed(1)} MB -> ${bakeFile}`, ); } const decay = kernelDecay(bake); if (decay !== null) { console.log( `room response at truncation: ${decay > 0 ? (20 * Math.log10(decay)).toFixed(0) : '-inf'} dB` + (decay > 1e-3 ? ' (still ringing; consider a longer ir_seconds)' : ''), ); } const t1 = performance.now(); const { trace, dt } = await session.render(bake, seconds, events); console.log( `render: ${events.length} event${events.length === 1 ? '' : 's'}, ` + `${trace.length.toLocaleString()} samples in ${((performance.now() - t1) / 1000).toFixed(1)} s`, ); writeFileSync(outPath, traceToWavBytes(trace, dt)); console.log(`wrote ${outPath} (${seconds} s at ${WAV_RATE} Hz)`); process.exit(0);