2 * One instrument under one setting of its parameters: the scope, the bake,
3 * the render. This is the whole public surface a CLI or a page needs.
4 *
5 * The cost model is the point. `precompute()` runs the instrument's
6 * precompute schedule (for these instruments, the reciprocal room
7 * recording) and depends only on geometry and accuracy parameters;
8 * `render()` consumes the bake and the tuning parameters and is cheap. The
9 * session checks the bake's key on every render, so a stale bake is an
10 * error rather than a wrong sound.
11 *
12 * Reserved derived names the host itself reads: `dt` (the timestep,
13 * seconds; sets the trace's sample rate). If `n_rows` and `ir_steps` are
14 * defined and the bake holds `kernels`, `kernelDecay` reports how far the
15 * room response has decayed where the recording truncates it.
16 */
17import { evalDerived } from '../expr.ts';
18import type { InstrumentFile, StimulusEvent } from '../format.ts';
19import { bakeKey, type Bake } from './bake.ts';
20import { PhaseCancelled, runPhase } from './engine.ts';
22export { PhaseCancelled };
24/** Samples the synthesis processes per GPU pass; a whole trace is
25 * ceil(total_steps / tile) such tiles. */
26export const TILE = 16384;
28export interface RenderResult {
29 trace: Float32Array;
30 dt: number;
31 totalSteps: number;
32 /** Extra render buffers read back on request (e.g. the slots a spectrum
33 * view marks predicted partials from). */
34 probes: Record<string, Float32Array>;
35}
37export class InstrumentSession {
38 readonly device: GPUDevice;
39 readonly inst: InstrumentFile;
40 values: Record<string, number>;
42 constructor(device: GPUDevice, inst: InstrumentFile, values: Record<string, number>) {
43 this.device = device;
44 this.inst = inst;
45 this.values = values;
46 }
48 /** Params plus derived, fully evaluated. */
49 scope(): Record<string, number> {
50 return evalDerived(this.inst.derived, this.values);
51 }
53 get dt(): number {
54 const s = this.scope();
55 if (!('dt' in s)) throw new Error(`instrument '${this.inst.name}' defines no derived 'dt'`);
56 return s.dt;
57 }
59 async bakeKey(): Promise<string> {
60 return bakeKey(this.inst, this.values);
61 }
63 async precompute(
64 onProgress?: (done: number, total: number) => void,
65 cancelled?: () => boolean,
66 ): Promise<Bake> {
67 const scope = this.scope();
68 const arrays = await runPhase({
69 device: this.device,
70 shaders: this.inst.shaders,
71 phase: this.inst.precompute,
72 scope,
73 outputs: this.inst.precompute.outputs,
74 label: `${this.inst.name}-precompute`,
75 onProgress,
76 cancelled,
77 });
78 return {
79 key: await this.bakeKey(),
80 instrument: this.inst.name,
81 scope,
82 arrays,
83 };
84 }
86 async render(
87 bake: Bake,
88 seconds: number,
89 events: StimulusEvent[] = [{ t: 0 }],
90 probes: string[] = [],
91 onProgress?: (done: number, total: number) => void,
92 cancelled?: () => boolean,
93 ): Promise<RenderResult> {
94 if (bake.key !== (await this.bakeKey())) {
95 throw new Error(
96 'the bake does not match the current geometry and accuracy parameters; precompute again',
97 );
98 }
99 if (events.length === 0) throw new Error('a render needs at least one stimulus event');
100 const base = this.scope();
101 const dt = base.dt;
102 const totalSteps = Math.max(1, Math.round(seconds / dt));
104 let scope: Record<string, number> = {
105 ...base,
106 n_events: events.length,
107 total_steps: totalSteps,
108 tile: TILE,
109 n_tiles: Math.ceil(totalSteps / TILE),
110 };
111 if (this.inst.render.derived) scope = evalDerived(this.inst.render.derived, scope);
113 // The stimulus list, packed: one row per event, start_step then the
114 // instrument's eventParams, defaulting to the current tuning values.
115 const ep = this.inst.render.eventParams;
116 const rows = new Float32Array(events.length * (1 + ep.length));
117 events.forEach((ev, i) => {
118 rows[i * (1 + ep.length)] = Math.max(0, Math.round(ev.t / dt));
119 ep.forEach((k, j) => {
120 const v = ev.set?.[k] ?? this.values[k];
121 if (v === undefined) throw new Error(`event parameter '${k}' has no value`);
122 rows[i * (1 + ep.length) + 1 + j] = v;
123 });
124 for (const k of Object.keys(ev.set ?? {})) {
125 if (!ep.includes(k)) throw new Error(`event ${i} sets '${k}', not an event parameter`);
126 }
127 });
129 const inputs: Record<string, Float32Array> = { __events: rows };
130 for (const name of this.inst.render.inputs) {
131 const a = bake.arrays[name];
132 if (!a) throw new Error(`the bake is missing '${name}'`);
133 inputs[name] = a;
134 }
136 const phase = this.inst.render;
137 const hasEventsBuffer = phase.buffers.some((b) => b.name === '__events');
138 const buffers = hasEventsBuffer
139 ? phase.buffers
140 : [...phase.buffers, { name: '__events', count: `${rows.length}` }];
141 const out = await runPhase({
142 device: this.device,
143 shaders: this.inst.shaders,
144 phase: { ...phase, buffers },
145 scope,
146 inputs,
147 outputs: [phase.trace, ...probes.filter((p) => p !== phase.trace)],
148 label: `${this.inst.name}-render`,
149 onProgress,
150 cancelled,
151 });
152 const probed: Record<string, Float32Array> = {};
153 for (const p of probes) probed[p] = out[p];
154 return { trace: out[phase.trace].subarray(0, totalSteps), dt, totalSteps, probes: probed };
155 }
156}
158/** Peak |kernel| in the recording's last tenth over the overall peak, if
159 * the bake carries kernels the reserved names describe. */
160export function kernelDecay(bake: Bake): number | null {
161 const { n_rows, ir_steps } = bake.scope;
162 const k = bake.arrays.kernels;
163 if (!k || !Number.isInteger(n_rows) || !Number.isInteger(ir_steps)) return null;
164 let peak = 0;
165 let tailPeak = 0;
166 const tailFrom = Math.floor(0.9 * ir_steps);
167 for (let q = 0; q < n_rows; q++) {
168 for (let t = 0; t < ir_steps; t++) {
169 const v = Math.abs(k[q * ir_steps + t]);
170 if (v > peak) peak = v;
171 if (t >= tailFrom && v > tailPeak) tailPeak = v;
172 }
173 }
174 return peak > 0 ? tailPeak / peak : 0;
175}