/** * The instrawave side of the renderer seam. Each of the three bundled * instruments carries one set of geometry and accuracy values (defaults * plus the user's overrides, edited in the instrument panel), so each has * exactly one live bake; the bake key tells a stale one from a current * one, and serialized bakes round-trip through IndexedDB so a reload does * not repeat the precompute. All per-voice character travels as tuning, * which is free: every voice bound to the same instrument shares its bake, * and one render call carries all their events, each with the voice's * tuning overrides and its strength scaling the instrument's excitation * amplitude (`vel`, or `amp` where there is no strike). */ import { requestDevice } from 'instrawave/src/host/device.ts'; import { InstrumentSession } from 'instrawave/src/host/session.ts'; import { bakeKey, serializeBake, type Bake } from 'instrawave/src/host/bake.ts'; import { evalDerived } from 'instrawave/src/expr.ts'; import { parseInstrument, paramValues, type InstrumentFile, type ParamSpec, type StimulusEvent, } from 'instrawave/src/format.ts'; import drumJson from 'instrawave/instruments/drum.instrument.json'; import dulcimerJson from 'instrawave/instruments/dulcimer.instrument.json'; import chimeJson from 'instrawave/instruments/chime.instrument.json'; export { PhaseCancelled } from 'instrawave/src/host/session.ts'; export { parseBake } from 'instrawave/src/host/bake.ts'; export type { StimulusEvent, Bake }; const files: InstrumentFile[] = [drumJson, dulcimerJson, chimeJson].map((j) => parseInstrument(JSON.stringify(j)), ); export type Progress = (done: number, total: number) => void; export class IwEngine { private device: GPUDevice | null = null; private bakes = new Map(); private insts = new Map(files.map((f) => [f.name, f])); private overrides: Record> = {}; names(): string[] { return [...this.insts.keys()]; } file(name: string): InstrumentFile { const f = this.insts.get(name); if (!f) throw new Error(`unknown instrument '${name}'`); return f; } /** The tuning parameters a binding may override: tuning-class params * that are also event parameters, so they can vary per hit. */ tuning(name: string): ParamSpec[] { const f = this.file(name); return f.params.filter((p) => p.class === 'tuning' && f.render.eventParams.includes(p.key)); } /** Geometry and accuracy: the parameters the bake depends on. */ geometryParams(name: string): ParamSpec[] { return this.file(name).params.filter((p) => p.class !== 'tuning'); } /** The parameter a hit's strength scales: the strike speed where there * is one, the pluck amplitude otherwise. Linear equations, so it is a * pure scale. */ strengthParam(name: string): string { const ep = this.file(name).render.eventParams; return ep.includes('vel') ? 'vel' : 'amp'; } defaultValue(name: string, key: string): number { const p = this.file(name).params.find((q) => q.key === key); if (!p) throw new Error(`instrument '${name}' has no parameter '${key}'`); return p.value; } setOverrides(all: Record>): void { this.overrides = all; } setOverride(name: string, key: string, value: number): void { (this.overrides[name] ??= {})[key] = value; } dumpOverrides(): Record> { return this.overrides; } values(name: string): Record { return paramValues(this.file(name), this.overrides[name] ?? {}); } /** Params plus derived, fully evaluated (what the viewer draws from). */ scope(name: string): Record { const f = this.file(name); return evalDerived(f.derived, this.values(name)); } currentKey(name: string): Promise { return bakeKey(this.file(name), this.values(name)); } async ensureDevice(): Promise { return (this.device ??= await requestDevice()); } /** True when an in-memory bake matches the current geometry. */ async bakeReady(name: string): Promise { const b = this.bakes.get(name); return !!b && b.key === (await this.currentKey(name)); } adoptBake(name: string, bake: Bake): void { this.bakes.set(name, bake); } serializedBake(name: string): { key: string; bytes: Uint8Array } | null { const b = this.bakes.get(name); return b ? { key: b.key, bytes: serializeBake(b) } : null; } async precompute(name: string, onProgress?: Progress, cancelled?: () => boolean): Promise { const inst = this.file(name); const device = await this.ensureDevice(); const session = new InstrumentSession(device, inst, this.values(name)); this.bakes.set(name, await session.precompute(onProgress, cancelled)); } async render( name: string, seconds: number, events: StimulusEvent[], onProgress?: Progress, ): Promise<{ trace: Float32Array; dt: number }> { const inst = this.file(name); const bake = this.bakes.get(name); if (!bake) throw new Error(`no bake for '${name}'; precompute first`); const device = await this.ensureDevice(); const session = new InstrumentSession(device, inst, this.values(name)); const { trace, dt } = await session.render(bake, seconds, events, [], onProgress); return { trace, dt }; } }