/ proof-of-concept / instrawave
Sign in
proof-of-concept / instrawave
instrawave / src / format.ts
435 lines · 16.9 KBCodeBlameHistory
2 * The instrument file: one JSON document that is the whole instrument.
3 *
4 * The host (src/host/) is a generic runner; everything specific to an
5 * instrument lives here. The file carries:
6 *
7 * - a parameter spec, every parameter in SI units and classed by cost:
8 * `geometry` and `accuracy` parameters feed the precompute (changing one
9 * invalidates the bake); `tuning` parameters only feed the render and are
10 * free to move. The microphone position is a geometry parameter like any
11 * other.
12 * - `derived`, an ordered map of expressions (src/expr.ts) giving the
13 * discretization: grid sizes, the timestep, mode counts, buffer sizes,
14 * dispatch sizes. The host evaluates these; the instrument file, not the
15 * host, knows its stability bounds.
16 * - the WGSL programs. Grid sizes and parameter values reach a shader as
17 * pipeline override constants, so the WGSL text is fixed and one file
18 * covers every setting of every parameter.
19 * - two schedules, `precompute` and `render`: each declares its buffers,
20 * a list of setup passes, and optionally a loop (the air run; the
21 * synthesis tiles). The precompute's `outputs` are read back into the
22 * bake; the render's `inputs` are uploaded from it.
23 *
24 * The render schedule additionally sees `total_steps`, `n_events`, `tile`
25 * and `n_tiles` in scope, and an `__events` buffer the host packs from the
26 * stimulus list: one row per event, [start_step, ...eventParams], f32.
27 * Either schedule's loop may bind `__tick`, a u32 the host zeroes before
28 * the loop and increments after every iteration.
29 */
31export type ParamClass = 'geometry' | 'accuracy' | 'tuning';
33export interface ParamSpec {
34 key: string;
35 label: string;
36 /** SI unit as text ('m', 'Hz', 's', 'm/s', '1'). Documentation, not code. */
37 unit: string;
38 value: number;
39 min: number;
40 max: number;
41 step?: number;
42 class: ParamClass;
43 hint?: string;
46export interface ShaderSpec {
47 code: string;
48 /** Names from the evaluation scope passed as pipeline override constants.
49 * Must match the shader's `override` declarations exactly. */
50 constants: string[];
53export interface PassSpec {
54 shader: string;
55 /** Buffer names bound in order from binding 0. */
56 buffers: string[];
57 /** Thread count, an expression over the scope. */
58 dispatch: string;
61export interface LoopSpec {
62 count: string;
63 /** Iterations encoded per submission; the host awaits between batches. */
64 batch?: number;
65 /** Groups of buffer names rotated one place per iteration (state
66 * histories: [p, pm, pn] makes this step's pn the next step's p). */
67 rotate?: string[][];
68 body: PassSpec[];
71export interface PhaseSpec {
72 buffers: { name: string; count: string }[];
73 setup: PassSpec[];
74 loop?: LoopSpec;
77export interface PrecomputeSpec extends PhaseSpec {
78 /** Buffers read back into the bake, by name. */
79 outputs: string[];
82export interface RenderSpec extends PhaseSpec {
83 /** Bake arrays uploaded before the render, by name. */
84 inputs: string[];
85 /** Tuning parameters each stimulus event may override; the host packs
86 * them, after start_step, into each __events row. */
87 eventParams: string[];
88 /** Extra derived values evaluated with the render scope in view. */
89 derived?: Record<string, string>;
90 /** The buffer holding the finished microphone trace. */
91 trace: string;
94/** A visualization marker: a crisp annotation drawn over the raymarched
95 * fields, its coordinates expressions over the evaluated scope. Markers
96 * annotate (an object's outline, the microphone); the geometry itself is
97 * the medium shader's own fields, never a second copy. */
98export type MarkerSpec =
99 | { kind: 'point'; at: [string, string, string]; color: [number, number, number]; label?: string }
100 | { kind: 'line'; from: [string, string, string]; to: [string, string, string]; color: [number, number, number]; label?: string }
101 | {
102 kind: 'rect';
103 corner: [string, string, string];
104 du: [string, string, string];
105 dv: [string, string, string];
106 color: [number, number, number];
107 label?: string;
108 };
110export interface VolumeSpec {
111 buffer: string;
112 /** 'solid' shades an opaque surface where the value crosses 0.5 (a body
113 * mask); 'cloud' accumulates the value as a translucent density (a
114 * coupling profile). */
115 style: 'solid' | 'cloud';
116 /** Draw 1 - value instead (a wall mask's body is 1 - wall). */
117 invert?: boolean;
118 color: [number, number, number];
121/** How to draw the instrument: run `setup` (typically the medium pass) over
122 * `buffers` at the current parameters, raymarch `volumes` on the grid the
123 * named dims/extents describe, overlay `markers`. `partials` tells a
124 * spectrum view where each rendered mode's pole sits in the slots buffer,
125 * so measured peaks can be compared against the scheme's own partials. */
126export interface VisualSpec {
127 dims: [string, string, string];
128 extents: [string, string, string];
129 buffers: { name: string; count: string }[];
130 setup: PassSpec[];
131 volumes: VolumeSpec[];
132 markers: MarkerSpec[];
133 partials?: { buffer: string; stride: number; zRe: number; zIm: number };
136export interface InstrumentFile {
137 instrawave: 1;
138 name: string;
139 description: string;
140 params: ParamSpec[];
141 derived: Record<string, string>;
142 shaders: Record<string, ShaderSpec>;
143 precompute: PrecomputeSpec;
144 render: RenderSpec;
145 visual?: VisualSpec;
148/** One stimulus: an excitation at time t (seconds), with any of the
149 * instrument's eventParams overridden for this event alone. */
150export interface StimulusEvent {
151 t: number;
152 set?: Record<string, number>;
155export class InstrumentError extends Error {
156 constructor(message: string) {
157 super(`not a valid instrawave instrument: ${message}`);
158 this.name = 'InstrumentError';
159 }
162const isRecord = (v: unknown): v is Record<string, unknown> =>
163 typeof v === 'object' && v !== null && !Array.isArray(v);
165const need = (cond: boolean, msg: string): void => {
166 if (!cond) throw new InstrumentError(msg);
167};
169function parsePass(v: unknown, where: string): PassSpec {
170 need(isRecord(v), `${where} must be an object`);
171 const p = v as Record<string, unknown>;
172 need(typeof p.shader === 'string', `${where}.shader must be a string`);
173 need(
174 Array.isArray(p.buffers) && p.buffers.every((b) => typeof b === 'string'),
175 `${where}.buffers must be a string array`,
176 );
177 need(typeof p.dispatch === 'string', `${where}.dispatch must be an expression`);
178 return { shader: p.shader as string, buffers: p.buffers as string[], dispatch: p.dispatch as string };
181function parsePhase(v: unknown, where: string): PhaseSpec {
182 need(isRecord(v), `missing "${where}"`);
183 const ph = v as Record<string, unknown>;
184 need(Array.isArray(ph.buffers), `${where}.buffers must be an array`);
185 const buffers = (ph.buffers as unknown[]).map((b, i) => {
186 need(isRecord(b), `${where}.buffers[${i}] must be an object`);
187 const r = b as Record<string, unknown>;
188 need(typeof r.name === 'string', `${where}.buffers[${i}].name must be a string`);
189 need(typeof r.count === 'string', `${where}.buffers[${i}].count must be an expression`);
190 return { name: r.name as string, count: r.count as string };
191 });
192 need(Array.isArray(ph.setup), `${where}.setup must be an array`);
193 const setup = (ph.setup as unknown[]).map((p, i) => parsePass(p, `${where}.setup[${i}]`));
194 let loop: LoopSpec | undefined;
195 if (ph.loop !== undefined) {
196 need(isRecord(ph.loop), `${where}.loop must be an object`);
197 const l = ph.loop as Record<string, unknown>;
198 need(typeof l.count === 'string', `${where}.loop.count must be an expression`);
199 need(Array.isArray(l.body), `${where}.loop.body must be an array`);
200 loop = {
201 count: l.count as string,
202 batch: typeof l.batch === 'number' ? l.batch : undefined,
203 rotate:
204 l.rotate === undefined
205 ? undefined
206 : (l.rotate as string[][]).map((g, i) => {
207 need(
208 Array.isArray(g) && g.length >= 2 && g.every((n) => typeof n === 'string'),
209 `${where}.loop.rotate[${i}] must be 2+ buffer names`,
210 );
211 return g;
212 }),
213 body: (l.body as unknown[]).map((p, i) => parsePass(p, `${where}.loop.body[${i}]`)),
214 };
215 }
216 return { buffers, setup, loop };
219export function parseInstrument(text: string): InstrumentFile {
220 let raw: unknown;
221 try {
222 raw = JSON.parse(text);
223 } catch (e) {
224 throw new InstrumentError(`not JSON (${e instanceof Error ? e.message : e})`);
225 }
226 need(isRecord(raw), 'the top level must be an object');
227 const r = raw as Record<string, unknown>;
228 need(r.instrawave === 1, 'missing or unsupported format marker (want "instrawave": 1)');
229 need(typeof r.name === 'string' && r.name.length > 0, 'missing "name"');
230 need(typeof r.description === 'string', 'missing "description"');
232 need(Array.isArray(r.params), '"params" must be an array');
233 const seen = new Set<string>();
234 const params = (r.params as unknown[]).map((p, i) => {
235 need(isRecord(p), `params[${i}] must be an object`);
236 const q = p as Record<string, unknown>;
237 need(
238 typeof q.key === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(q.key),
239 `params[${i}] needs an identifier "key"`,
240 );
241 need(!seen.has(q.key as string), `duplicate parameter '${q.key}'`);
242 seen.add(q.key as string);
243 for (const k of ['value', 'min', 'max'] as const) {
244 need(typeof q[k] === 'number' && Number.isFinite(q[k] as number), `params '${q.key}'.${k} must be a number`);
245 }
246 need(
247 q.class === 'geometry' || q.class === 'accuracy' || q.class === 'tuning',
248 `params '${q.key}'.class must be geometry, accuracy or tuning`,
249 );
250 return {
251 key: q.key as string,
252 label: typeof q.label === 'string' ? q.label : (q.key as string),
253 unit: typeof q.unit === 'string' ? q.unit : '1',
254 value: q.value as number,
255 min: q.min as number,
256 max: q.max as number,
257 step: typeof q.step === 'number' ? q.step : undefined,
258 class: q.class as ParamClass,
259 hint: typeof q.hint === 'string' ? q.hint : undefined,
260 };
261 });
263 need(isRecord(r.derived), '"derived" must be an object of expressions');
264 for (const [k, v] of Object.entries(r.derived as Record<string, unknown>)) {
265 need(typeof v === 'string', `derived.${k} must be an expression string`);
266 }
268 need(isRecord(r.shaders), '"shaders" must be an object');
269 const shaders: Record<string, ShaderSpec> = {};
270 for (const [k, v] of Object.entries(r.shaders as Record<string, unknown>)) {
271 need(isRecord(v), `shaders.${k} must be an object`);
272 const s = v as Record<string, unknown>;
273 need(typeof s.code === 'string', `shaders.${k}.code must be WGSL text`);
274 need(
275 Array.isArray(s.constants) && s.constants.every((c) => typeof c === 'string'),
276 `shaders.${k}.constants must be a string array`,
277 );
278 shaders[k] = { code: s.code as string, constants: s.constants as string[] };
279 }
281 const pre = parsePhase(r.precompute, 'precompute') as PrecomputeSpec;
282 need(
283 Array.isArray((r.precompute as Record<string, unknown>).outputs),
284 'precompute.outputs must name the buffers baked',
285 );
286 pre.outputs = (r.precompute as Record<string, unknown>).outputs as string[];
288 const ren = parsePhase(r.render, 'render') as RenderSpec;
289 const rr = r.render as Record<string, unknown>;
290 need(Array.isArray(rr.inputs), 'render.inputs must name the bake arrays uploaded');
291 ren.inputs = rr.inputs as string[];
292 need(Array.isArray(rr.eventParams), 'render.eventParams must be a string array');
293 ren.eventParams = rr.eventParams as string[];
294 need(typeof rr.trace === 'string', 'render.trace must name the trace buffer');
295 ren.trace = rr.trace as string;
296 if (rr.derived !== undefined) {
297 need(isRecord(rr.derived), 'render.derived must be an object of expressions');
298 ren.derived = rr.derived as Record<string, string>;
299 }
301 // Every pass must reference a declared shader; every eventParam a
302 // tuning parameter. Buffer names are checked at run time, where the
303 // built-ins (__tick, __events) are in scope.
304 const passes = [
305 ...pre.setup, ...(pre.loop?.body ?? []),
306 ...ren.setup, ...(ren.loop?.body ?? []),
307 ];
308 for (const p of passes) {
309 need(p.shader in shaders, `pass uses undeclared shader '${p.shader}'`);
310 }
311 for (const k of ren.eventParams) {
312 const spec = params.find((p) => p.key === k);
313 need(spec !== undefined && spec.class === 'tuning', `eventParam '${k}' must be a tuning parameter`);
314 }
316 let visual: VisualSpec | undefined;
317 if (r.visual !== undefined) {
318 need(isRecord(r.visual), '"visual" must be an object');
319 const v = r.visual as Record<string, unknown>;
320 const triple = (x: unknown, what: string): [string, string, string] => {
321 need(
322 Array.isArray(x) && x.length === 3 && x.every((e) => typeof e === 'string'),
323 `${what} must be three expressions`,
324 );
325 return x as [string, string, string];
326 };
327 const color = (x: unknown, what: string): [number, number, number] => {
328 need(
329 Array.isArray(x) && x.length === 3 && x.every((e) => typeof e === 'number'),
330 `${what} must be [r, g, b]`,
331 );
332 return x as [number, number, number];
333 };
334 const vp = parsePhase({ buffers: v.buffers, setup: v.setup }, 'visual');
335 need(Array.isArray(v.volumes) && (v.volumes as unknown[]).length > 0, 'visual.volumes must be a non-empty array');
336 const volumes = (v.volumes as unknown[]).map((q, i) => {
337 need(isRecord(q), `visual.volumes[${i}] must be an object`);
338 const w = q as Record<string, unknown>;
339 need(typeof w.buffer === 'string', `visual.volumes[${i}].buffer must be a string`);
340 need(w.style === 'solid' || w.style === 'cloud', `visual.volumes[${i}].style must be solid or cloud`);
341 return {
342 buffer: w.buffer as string,
343 style: w.style as 'solid' | 'cloud',
344 invert: w.invert === true ? true : undefined,
345 color: color(w.color, `visual.volumes[${i}].color`),
346 };
347 });
348 const markers = (Array.isArray(v.markers) ? (v.markers as unknown[]) : []).map((q, i) => {
349 need(isRecord(q), `visual.markers[${i}] must be an object`);
350 const w = q as Record<string, unknown>;
351 const label = typeof w.label === 'string' ? w.label : undefined;
352 const c = color(w.color, `visual.markers[${i}].color`);
353 if (w.kind === 'point') return { kind: 'point' as const, at: triple(w.at, `visual.markers[${i}].at`), color: c, label };
354 if (w.kind === 'line') {
355 return {
356 kind: 'line' as const,
357 from: triple(w.from, `visual.markers[${i}].from`),
358 to: triple(w.to, `visual.markers[${i}].to`),
359 color: c,
360 label,
361 };
362 }
363 need(w.kind === 'rect', `visual.markers[${i}].kind must be point, line or rect`);
364 return {
365 kind: 'rect' as const,
366 corner: triple(w.corner, `visual.markers[${i}].corner`),
367 du: triple(w.du, `visual.markers[${i}].du`),
368 dv: triple(w.dv, `visual.markers[${i}].dv`),
369 color: c,
370 label,
371 };
372 });
373 let partials: VisualSpec['partials'];
374 if (v.partials !== undefined) {
375 need(isRecord(v.partials), 'visual.partials must be an object');
376 const w = v.partials as Record<string, unknown>;
377 need(
378 typeof w.buffer === 'string' && typeof w.stride === 'number' &&
379 typeof w.zRe === 'number' && typeof w.zIm === 'number',
380 'visual.partials wants {buffer, stride, zRe, zIm}',
381 );
382 partials = { buffer: w.buffer as string, stride: w.stride as number, zRe: w.zRe as number, zIm: w.zIm as number };
383 }
384 for (const p of vp.setup) need(p.shader in shaders, `visual pass uses undeclared shader '${p.shader}'`);
385 visual = {
386 dims: triple(v.dims, 'visual.dims'),
387 extents: triple(v.extents, 'visual.extents'),
388 buffers: vp.buffers,
389 setup: vp.setup,
390 volumes,
391 markers,
392 partials,
393 };
394 }
396 return {
397 instrawave: 1,
398 name: r.name as string,
399 description: r.description as string,
400 params,
401 derived: r.derived as Record<string, string>,
402 shaders,
403 precompute: pre,
404 render: ren,
405 ...(visual ? { visual } : {}),
406 };
409/** Parameter values by key, defaults filled from the spec. */
410export function paramValues(inst: InstrumentFile, overrides: Record<string, number> = {}): Record<string, number> {
411 const out: Record<string, number> = {};
412 for (const p of inst.params) {
413 out[p.key] = overrides[p.key] ?? p.value;
414 // Worst-case values for stability sizing, so no tuning move ever
415 // invalidates a grid: every parameter's range is in scope.
416 out[`${p.key}__min`] = p.min;
417 out[`${p.key}__max`] = p.max;
418 }
419 for (const k of Object.keys(overrides)) {
420 if (!(k in out)) throw new InstrumentError(`unknown parameter '${k}'`);
421 }
422 return out;
425/** The subset of values that feed the precompute: geometry and accuracy. */
426export function bakeRelevantValues(
427 inst: InstrumentFile,
428 values: Record<string, number>,
429): Record<string, number> {
430 const out: Record<string, number> = {};
431 for (const p of inst.params) {
432 if (p.class !== 'tuning') out[p.key] = values[p.key];
433 }
434 return out;
↑↓ move↵openescclose