/** * One instrument under one setting of its parameters: the scope, the bake, * the render. This is the whole public surface a CLI or a page needs. * * The cost model is the point. `precompute()` runs the instrument's * precompute schedule (for these instruments, the reciprocal room * recording) and depends only on geometry and accuracy parameters; * `render()` consumes the bake and the tuning parameters and is cheap. The * session checks the bake's key on every render, so a stale bake is an * error rather than a wrong sound. * * Reserved derived names the host itself reads: `dt` (the timestep, * seconds; sets the trace's sample rate). If `n_rows` and `ir_steps` are * defined and the bake holds `kernels`, `kernelDecay` reports how far the * room response has decayed where the recording truncates it. */ import { evalDerived } from '../expr.ts'; import type { InstrumentFile, StimulusEvent } from '../format.ts'; import { bakeKey, type Bake } from './bake.ts'; import { PhaseCancelled, runPhase } from './engine.ts'; export { PhaseCancelled }; /** Samples the synthesis processes per GPU pass; a whole trace is * ceil(total_steps / tile) such tiles. */ export const TILE = 16384; export interface RenderResult { trace: Float32Array; dt: number; totalSteps: number; /** Extra render buffers read back on request (e.g. the slots a spectrum * view marks predicted partials from). */ probes: Record; } export class InstrumentSession { readonly device: GPUDevice; readonly inst: InstrumentFile; values: Record; constructor(device: GPUDevice, inst: InstrumentFile, values: Record) { this.device = device; this.inst = inst; this.values = values; } /** Params plus derived, fully evaluated. */ scope(): Record { return evalDerived(this.inst.derived, this.values); } get dt(): number { const s = this.scope(); if (!('dt' in s)) throw new Error(`instrument '${this.inst.name}' defines no derived 'dt'`); return s.dt; } async bakeKey(): Promise { return bakeKey(this.inst, this.values); } async precompute( onProgress?: (done: number, total: number) => void, cancelled?: () => boolean, ): Promise { const scope = this.scope(); const arrays = await runPhase({ device: this.device, shaders: this.inst.shaders, phase: this.inst.precompute, scope, outputs: this.inst.precompute.outputs, label: `${this.inst.name}-precompute`, onProgress, cancelled, }); return { key: await this.bakeKey(), instrument: this.inst.name, scope, arrays, }; } async render( bake: Bake, seconds: number, events: StimulusEvent[] = [{ t: 0 }], probes: string[] = [], onProgress?: (done: number, total: number) => void, cancelled?: () => boolean, ): Promise { if (bake.key !== (await this.bakeKey())) { throw new Error( 'the bake does not match the current geometry and accuracy parameters; precompute again', ); } if (events.length === 0) throw new Error('a render needs at least one stimulus event'); const base = this.scope(); const dt = base.dt; const totalSteps = Math.max(1, Math.round(seconds / dt)); let scope: Record = { ...base, n_events: events.length, total_steps: totalSteps, tile: TILE, n_tiles: Math.ceil(totalSteps / TILE), }; if (this.inst.render.derived) scope = evalDerived(this.inst.render.derived, scope); // The stimulus list, packed: one row per event, start_step then the // instrument's eventParams, defaulting to the current tuning values. const ep = this.inst.render.eventParams; const rows = new Float32Array(events.length * (1 + ep.length)); events.forEach((ev, i) => { rows[i * (1 + ep.length)] = Math.max(0, Math.round(ev.t / dt)); ep.forEach((k, j) => { const v = ev.set?.[k] ?? this.values[k]; if (v === undefined) throw new Error(`event parameter '${k}' has no value`); rows[i * (1 + ep.length) + 1 + j] = v; }); for (const k of Object.keys(ev.set ?? {})) { if (!ep.includes(k)) throw new Error(`event ${i} sets '${k}', not an event parameter`); } }); const inputs: Record = { __events: rows }; for (const name of this.inst.render.inputs) { const a = bake.arrays[name]; if (!a) throw new Error(`the bake is missing '${name}'`); inputs[name] = a; } const phase = this.inst.render; const hasEventsBuffer = phase.buffers.some((b) => b.name === '__events'); const buffers = hasEventsBuffer ? phase.buffers : [...phase.buffers, { name: '__events', count: `${rows.length}` }]; const out = await runPhase({ device: this.device, shaders: this.inst.shaders, phase: { ...phase, buffers }, scope, inputs, outputs: [phase.trace, ...probes.filter((p) => p !== phase.trace)], label: `${this.inst.name}-render`, onProgress, cancelled, }); const probed: Record = {}; for (const p of probes) probed[p] = out[p]; return { trace: out[phase.trace].subarray(0, totalSteps), dt, totalSteps, probes: probed }; } } /** Peak |kernel| in the recording's last tenth over the overall peak, if * the bake carries kernels the reserved names describe. */ export function kernelDecay(bake: Bake): number | null { const { n_rows, ir_steps } = bake.scope; const k = bake.arrays.kernels; if (!k || !Number.isInteger(n_rows) || !Number.isInteger(ir_steps)) return null; let peak = 0; let tailPeak = 0; const tailFrom = Math.floor(0.9 * ir_steps); for (let q = 0; q < n_rows; q++) { for (let t = 0; t < ir_steps; t++) { const v = Math.abs(k[q * ir_steps + t]); if (v > peak) peak = v; if (t >= tailFrom && v > tailPeak) tailPeak = v; } } return peak > 0 ? tailPeak / peak : 0; }