/** * The instrument file: one JSON document that is the whole instrument. * * The host (src/host/) is a generic runner; everything specific to an * instrument lives here. The file carries: * * - a parameter spec, every parameter in SI units and classed by cost: * `geometry` and `accuracy` parameters feed the precompute (changing one * invalidates the bake); `tuning` parameters only feed the render and are * free to move. The microphone position is a geometry parameter like any * other. * - `derived`, an ordered map of expressions (src/expr.ts) giving the * discretization: grid sizes, the timestep, mode counts, buffer sizes, * dispatch sizes. The host evaluates these; the instrument file, not the * host, knows its stability bounds. * - the WGSL programs. Grid sizes and parameter values reach a shader as * pipeline override constants, so the WGSL text is fixed and one file * covers every setting of every parameter. * - two schedules, `precompute` and `render`: each declares its buffers, * a list of setup passes, and optionally a loop (the air run; the * synthesis tiles). The precompute's `outputs` are read back into the * bake; the render's `inputs` are uploaded from it. * * The render schedule additionally sees `total_steps`, `n_events`, `tile` * and `n_tiles` in scope, and an `__events` buffer the host packs from the * stimulus list: one row per event, [start_step, ...eventParams], f32. * Either schedule's loop may bind `__tick`, a u32 the host zeroes before * the loop and increments after every iteration. */ export type ParamClass = 'geometry' | 'accuracy' | 'tuning'; export interface ParamSpec { key: string; label: string; /** SI unit as text ('m', 'Hz', 's', 'm/s', '1'). Documentation, not code. */ unit: string; value: number; min: number; max: number; step?: number; class: ParamClass; hint?: string; } export interface ShaderSpec { code: string; /** Names from the evaluation scope passed as pipeline override constants. * Must match the shader's `override` declarations exactly. */ constants: string[]; } export interface PassSpec { shader: string; /** Buffer names bound in order from binding 0. */ buffers: string[]; /** Thread count, an expression over the scope. */ dispatch: string; } export interface LoopSpec { count: string; /** Iterations encoded per submission; the host awaits between batches. */ batch?: number; /** Groups of buffer names rotated one place per iteration (state * histories: [p, pm, pn] makes this step's pn the next step's p). */ rotate?: string[][]; body: PassSpec[]; } export interface PhaseSpec { buffers: { name: string; count: string }[]; setup: PassSpec[]; loop?: LoopSpec; } export interface PrecomputeSpec extends PhaseSpec { /** Buffers read back into the bake, by name. */ outputs: string[]; } export interface RenderSpec extends PhaseSpec { /** Bake arrays uploaded before the render, by name. */ inputs: string[]; /** Tuning parameters each stimulus event may override; the host packs * them, after start_step, into each __events row. */ eventParams: string[]; /** Extra derived values evaluated with the render scope in view. */ derived?: Record; /** The buffer holding the finished microphone trace. */ trace: string; } /** A visualization marker: a crisp annotation drawn over the raymarched * fields, its coordinates expressions over the evaluated scope. Markers * annotate (an object's outline, the microphone); the geometry itself is * the medium shader's own fields, never a second copy. */ export type MarkerSpec = | { kind: 'point'; at: [string, string, string]; color: [number, number, number]; label?: string } | { kind: 'line'; from: [string, string, string]; to: [string, string, string]; color: [number, number, number]; label?: string } | { kind: 'rect'; corner: [string, string, string]; du: [string, string, string]; dv: [string, string, string]; color: [number, number, number]; label?: string; }; export interface VolumeSpec { buffer: string; /** 'solid' shades an opaque surface where the value crosses 0.5 (a body * mask); 'cloud' accumulates the value as a translucent density (a * coupling profile). */ style: 'solid' | 'cloud'; /** Draw 1 - value instead (a wall mask's body is 1 - wall). */ invert?: boolean; color: [number, number, number]; } /** How to draw the instrument: run `setup` (typically the medium pass) over * `buffers` at the current parameters, raymarch `volumes` on the grid the * named dims/extents describe, overlay `markers`. `partials` tells a * spectrum view where each rendered mode's pole sits in the slots buffer, * so measured peaks can be compared against the scheme's own partials. */ export interface VisualSpec { dims: [string, string, string]; extents: [string, string, string]; buffers: { name: string; count: string }[]; setup: PassSpec[]; volumes: VolumeSpec[]; markers: MarkerSpec[]; partials?: { buffer: string; stride: number; zRe: number; zIm: number }; } export interface InstrumentFile { instrawave: 1; name: string; description: string; params: ParamSpec[]; derived: Record; shaders: Record; precompute: PrecomputeSpec; render: RenderSpec; visual?: VisualSpec; } /** One stimulus: an excitation at time t (seconds), with any of the * instrument's eventParams overridden for this event alone. */ export interface StimulusEvent { t: number; set?: Record; } export class InstrumentError extends Error { constructor(message: string) { super(`not a valid instrawave instrument: ${message}`); this.name = 'InstrumentError'; } } const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); const need = (cond: boolean, msg: string): void => { if (!cond) throw new InstrumentError(msg); }; function parsePass(v: unknown, where: string): PassSpec { need(isRecord(v), `${where} must be an object`); const p = v as Record; need(typeof p.shader === 'string', `${where}.shader must be a string`); need( Array.isArray(p.buffers) && p.buffers.every((b) => typeof b === 'string'), `${where}.buffers must be a string array`, ); need(typeof p.dispatch === 'string', `${where}.dispatch must be an expression`); return { shader: p.shader as string, buffers: p.buffers as string[], dispatch: p.dispatch as string }; } function parsePhase(v: unknown, where: string): PhaseSpec { need(isRecord(v), `missing "${where}"`); const ph = v as Record; need(Array.isArray(ph.buffers), `${where}.buffers must be an array`); const buffers = (ph.buffers as unknown[]).map((b, i) => { need(isRecord(b), `${where}.buffers[${i}] must be an object`); const r = b as Record; need(typeof r.name === 'string', `${where}.buffers[${i}].name must be a string`); need(typeof r.count === 'string', `${where}.buffers[${i}].count must be an expression`); return { name: r.name as string, count: r.count as string }; }); need(Array.isArray(ph.setup), `${where}.setup must be an array`); const setup = (ph.setup as unknown[]).map((p, i) => parsePass(p, `${where}.setup[${i}]`)); let loop: LoopSpec | undefined; if (ph.loop !== undefined) { need(isRecord(ph.loop), `${where}.loop must be an object`); const l = ph.loop as Record; need(typeof l.count === 'string', `${where}.loop.count must be an expression`); need(Array.isArray(l.body), `${where}.loop.body must be an array`); loop = { count: l.count as string, batch: typeof l.batch === 'number' ? l.batch : undefined, rotate: l.rotate === undefined ? undefined : (l.rotate as string[][]).map((g, i) => { need( Array.isArray(g) && g.length >= 2 && g.every((n) => typeof n === 'string'), `${where}.loop.rotate[${i}] must be 2+ buffer names`, ); return g; }), body: (l.body as unknown[]).map((p, i) => parsePass(p, `${where}.loop.body[${i}]`)), }; } return { buffers, setup, loop }; } export function parseInstrument(text: string): InstrumentFile { let raw: unknown; try { raw = JSON.parse(text); } catch (e) { throw new InstrumentError(`not JSON (${e instanceof Error ? e.message : e})`); } need(isRecord(raw), 'the top level must be an object'); const r = raw as Record; need(r.instrawave === 1, 'missing or unsupported format marker (want "instrawave": 1)'); need(typeof r.name === 'string' && r.name.length > 0, 'missing "name"'); need(typeof r.description === 'string', 'missing "description"'); need(Array.isArray(r.params), '"params" must be an array'); const seen = new Set(); const params = (r.params as unknown[]).map((p, i) => { need(isRecord(p), `params[${i}] must be an object`); const q = p as Record; need( typeof q.key === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(q.key), `params[${i}] needs an identifier "key"`, ); need(!seen.has(q.key as string), `duplicate parameter '${q.key}'`); seen.add(q.key as string); for (const k of ['value', 'min', 'max'] as const) { need(typeof q[k] === 'number' && Number.isFinite(q[k] as number), `params '${q.key}'.${k} must be a number`); } need( q.class === 'geometry' || q.class === 'accuracy' || q.class === 'tuning', `params '${q.key}'.class must be geometry, accuracy or tuning`, ); return { key: q.key as string, label: typeof q.label === 'string' ? q.label : (q.key as string), unit: typeof q.unit === 'string' ? q.unit : '1', value: q.value as number, min: q.min as number, max: q.max as number, step: typeof q.step === 'number' ? q.step : undefined, class: q.class as ParamClass, hint: typeof q.hint === 'string' ? q.hint : undefined, }; }); need(isRecord(r.derived), '"derived" must be an object of expressions'); for (const [k, v] of Object.entries(r.derived as Record)) { need(typeof v === 'string', `derived.${k} must be an expression string`); } need(isRecord(r.shaders), '"shaders" must be an object'); const shaders: Record = {}; for (const [k, v] of Object.entries(r.shaders as Record)) { need(isRecord(v), `shaders.${k} must be an object`); const s = v as Record; need(typeof s.code === 'string', `shaders.${k}.code must be WGSL text`); need( Array.isArray(s.constants) && s.constants.every((c) => typeof c === 'string'), `shaders.${k}.constants must be a string array`, ); shaders[k] = { code: s.code as string, constants: s.constants as string[] }; } const pre = parsePhase(r.precompute, 'precompute') as PrecomputeSpec; need( Array.isArray((r.precompute as Record).outputs), 'precompute.outputs must name the buffers baked', ); pre.outputs = (r.precompute as Record).outputs as string[]; const ren = parsePhase(r.render, 'render') as RenderSpec; const rr = r.render as Record; need(Array.isArray(rr.inputs), 'render.inputs must name the bake arrays uploaded'); ren.inputs = rr.inputs as string[]; need(Array.isArray(rr.eventParams), 'render.eventParams must be a string array'); ren.eventParams = rr.eventParams as string[]; need(typeof rr.trace === 'string', 'render.trace must name the trace buffer'); ren.trace = rr.trace as string; if (rr.derived !== undefined) { need(isRecord(rr.derived), 'render.derived must be an object of expressions'); ren.derived = rr.derived as Record; } // Every pass must reference a declared shader; every eventParam a // tuning parameter. Buffer names are checked at run time, where the // built-ins (__tick, __events) are in scope. const passes = [ ...pre.setup, ...(pre.loop?.body ?? []), ...ren.setup, ...(ren.loop?.body ?? []), ]; for (const p of passes) { need(p.shader in shaders, `pass uses undeclared shader '${p.shader}'`); } for (const k of ren.eventParams) { const spec = params.find((p) => p.key === k); need(spec !== undefined && spec.class === 'tuning', `eventParam '${k}' must be a tuning parameter`); } let visual: VisualSpec | undefined; if (r.visual !== undefined) { need(isRecord(r.visual), '"visual" must be an object'); const v = r.visual as Record; const triple = (x: unknown, what: string): [string, string, string] => { need( Array.isArray(x) && x.length === 3 && x.every((e) => typeof e === 'string'), `${what} must be three expressions`, ); return x as [string, string, string]; }; const color = (x: unknown, what: string): [number, number, number] => { need( Array.isArray(x) && x.length === 3 && x.every((e) => typeof e === 'number'), `${what} must be [r, g, b]`, ); return x as [number, number, number]; }; const vp = parsePhase({ buffers: v.buffers, setup: v.setup }, 'visual'); need(Array.isArray(v.volumes) && (v.volumes as unknown[]).length > 0, 'visual.volumes must be a non-empty array'); const volumes = (v.volumes as unknown[]).map((q, i) => { need(isRecord(q), `visual.volumes[${i}] must be an object`); const w = q as Record; need(typeof w.buffer === 'string', `visual.volumes[${i}].buffer must be a string`); need(w.style === 'solid' || w.style === 'cloud', `visual.volumes[${i}].style must be solid or cloud`); return { buffer: w.buffer as string, style: w.style as 'solid' | 'cloud', invert: w.invert === true ? true : undefined, color: color(w.color, `visual.volumes[${i}].color`), }; }); const markers = (Array.isArray(v.markers) ? (v.markers as unknown[]) : []).map((q, i) => { need(isRecord(q), `visual.markers[${i}] must be an object`); const w = q as Record; const label = typeof w.label === 'string' ? w.label : undefined; const c = color(w.color, `visual.markers[${i}].color`); if (w.kind === 'point') return { kind: 'point' as const, at: triple(w.at, `visual.markers[${i}].at`), color: c, label }; if (w.kind === 'line') { return { kind: 'line' as const, from: triple(w.from, `visual.markers[${i}].from`), to: triple(w.to, `visual.markers[${i}].to`), color: c, label, }; } need(w.kind === 'rect', `visual.markers[${i}].kind must be point, line or rect`); return { kind: 'rect' as const, corner: triple(w.corner, `visual.markers[${i}].corner`), du: triple(w.du, `visual.markers[${i}].du`), dv: triple(w.dv, `visual.markers[${i}].dv`), color: c, label, }; }); let partials: VisualSpec['partials']; if (v.partials !== undefined) { need(isRecord(v.partials), 'visual.partials must be an object'); const w = v.partials as Record; need( typeof w.buffer === 'string' && typeof w.stride === 'number' && typeof w.zRe === 'number' && typeof w.zIm === 'number', 'visual.partials wants {buffer, stride, zRe, zIm}', ); partials = { buffer: w.buffer as string, stride: w.stride as number, zRe: w.zRe as number, zIm: w.zIm as number }; } for (const p of vp.setup) need(p.shader in shaders, `visual pass uses undeclared shader '${p.shader}'`); visual = { dims: triple(v.dims, 'visual.dims'), extents: triple(v.extents, 'visual.extents'), buffers: vp.buffers, setup: vp.setup, volumes, markers, partials, }; } return { instrawave: 1, name: r.name as string, description: r.description as string, params, derived: r.derived as Record, shaders, precompute: pre, render: ren, ...(visual ? { visual } : {}), }; } /** Parameter values by key, defaults filled from the spec. */ export function paramValues(inst: InstrumentFile, overrides: Record = {}): Record { const out: Record = {}; for (const p of inst.params) { out[p.key] = overrides[p.key] ?? p.value; // Worst-case values for stability sizing, so no tuning move ever // invalidates a grid: every parameter's range is in scope. out[`${p.key}__min`] = p.min; out[`${p.key}__max`] = p.max; } for (const k of Object.keys(overrides)) { if (!(k in out)) throw new InstrumentError(`unknown parameter '${k}'`); } return out; } /** The subset of values that feed the precompute: geometry and accuracy. */ export function bakeRelevantValues( inst: InstrumentFile, values: Record, ): Record { const out: Record = {}; for (const p of inst.params) { if (p.class !== 'tuning') out[p.key] = values[p.key]; } return out; }