1/**
2 * Placeholder instrawave adapter: the three bundled instrawave instruments
3 * (drum, dulcimer, chime) exposed through midiboot's Instrument interface,
4 * each at its default geometry with reduced accuracy so the first bake is
5 * quick rather than faithful. Instrawave is itself a prototype and this
6 * seam will be swapped for its successor; nothing outside this directory
7 * knows instrawave exists, so the swap is confined here.
8 *
9 * What the placeholder deliberately leaves out: instance editing (geometry
10 * and tuning are fixed at the file defaults), bake persistence (bakes live
11 * in memory and are redone per page load), and a live noteOn voice
12 * (monitoring falls back to the player's beep). Notes are treated as pure
13 * onsets, the way a struck drum works: each note becomes one stimulus with
14 * the pitch parameter overridden to the note's frequency (clamped to the
15 * parameter's range) and the excitation strength scaled linearly by
16 * velocity, against one shared bake.
17 */
18import { requestDevice } from 'instrawave/src/host/device.ts';
19import { InstrumentSession } from 'instrawave/src/host/session.ts';
20import type { Bake } from 'instrawave/src/host/bake.ts';
21import { parseInstrument, paramValues, type InstrumentFile, type StimulusEvent } from 'instrawave/src/format.ts';
22import drumJson from 'instrawave/instruments/drum.instrument.json';
23import dulcimerJson from 'instrawave/instruments/dulcimer.instrument.json';
24import chimeJson from 'instrawave/instruments/chime.instrument.json';
25import type { Instrument, RenderRequest } from '../types.ts';
26import { registerInstrument } from '../registry.ts';
27import { midiToFreq } from '../dsp.ts';
29/** Coarse grid, short room response: a bake in seconds, not minutes. */
30const PLACEHOLDER_ACCURACY: Record<string, number> = { nx: 64, ir_seconds: 0.4 };
32type StatusFn = (msg: string) => void;
33let onStatus: StatusFn = () => {};
34export function setInstrawaveStatus(cb: StatusFn): void {
35 onStatus = cb;
36}
38let devicePromise: Promise<GPUDevice> | null = null;
39function ensureDevice(): Promise<GPUDevice> {
40 return (devicePromise ??= requestDevice());
41}
43function resampleLinear(x: Float32Array, srFrom: number, srTo: number, outLen: number): Float32Array {
44 const out = new Float32Array(outLen);
45 if (x.length === 0) return out;
46 for (let i = 0; i < outLen; i++) {
47 const s = (i * srFrom) / srTo;
48 const k = Math.floor(s);
49 if (k >= x.length - 1) break;
50 const f = s - k;
51 out[i] = (1 - f) * x[k] + f * x[k + 1];
52 }
53 return out;
54}
56class InstrawaveInstrument implements Instrument {
57 readonly values: Record<string, number>;
58 private readonly pitchKey: string;
59 private readonly strengthKey: string;
60 private bake: Bake | null = null;
61 private queue: Promise<unknown> = Promise.resolve();
63 constructor(
64 readonly id: string,
65 readonly name: string,
66 readonly file: InstrumentFile,
67 ) {
68 this.values = paramValues(file, PLACEHOLDER_ACCURACY);
69 this.pitchKey = file.render.eventParams.includes('f0') ? 'f0' : 'f1';
70 this.strengthKey = file.render.eventParams.includes('vel') ? 'vel' : 'amp';
71 }
73 render(req: RenderRequest): Promise<Float32Array> {
74 // One GPU render at a time per instrument; concurrent tracks queue.
75 const run = this.queue.then(() => this.renderNow(req));
76 this.queue = run.catch(() => undefined);
77 return run;
78 }
80 private async renderNow(req: RenderRequest): Promise<Float32Array> {
81 if (req.notes.length === 0) return new Float32Array(req.lengthSamples);
82 const device = await ensureDevice();
83 const session = new InstrumentSession(device, this.file, this.values);
84 if (!this.bake) {
85 try {
86 this.bake = await session.precompute((done, total) =>
87 onStatus(`baking ${this.name}: ${done}/${total}`),
88 );
89 } finally {
90 onStatus('');
91 }
92 }
93 const spec = this.file.params.find((p) => p.key === this.pitchKey)!;
94 let clamped = 0;
95 const events: StimulusEvent[] = req.notes.map((n) => {
96 const f = midiToFreq(n.pitch);
97 const v = Math.max(spec.min, Math.min(spec.max, f));
98 if (v !== f) clamped++;
99 return { t: n.timeSec, set: { [this.pitchKey]: v, [this.strengthKey]: this.values[this.strengthKey] * n.velocity } };
100 });
101 if (clamped > 0) console.warn(`${this.name}: ${clamped} note(s) clamped to the ${spec.min}..${spec.max} Hz pitch range`);
102 const seconds = req.lengthSamples / req.sampleRate;
103 let trace: Float32Array;
104 let dt: number;
105 try {
106 const r = await session.render(this.bake, seconds, events, [], (done, total) =>
107 onStatus(`rendering ${this.name}: ${done}/${total}`),
108 );
109 trace = r.trace;
110 dt = r.dt;
111 } finally {
112 onStatus('');
113 }
114 return resampleLinear(trace, 1 / dt, req.sampleRate, req.lengthSamples);
115 }
116}
118export function createInstrawaveInstruments(): Instrument[] {
119 const files = [drumJson, dulcimerJson, chimeJson].map((j) => parseInstrument(JSON.stringify(j)));
120 return files.map((f) => new InstrawaveInstrument(`iw-${f.name}`, `Instrawave ${f.name} (GPU)`, f));
121}
123/** Registers the placeholder instruments when WebGPU is available;
124 * returns whether it did. Without WebGPU nothing is registered and a song
125 * bound to these ids falls back to a builtin. */
126export function registerInstrawaveInstruments(): boolean {
127 if (typeof navigator === 'undefined' || !navigator.gpu) return false;
128 for (const ins of createInstrawaveInstruments()) registerInstrument(ins);
129 return true;
130}