/** * Placeholder instrawave adapter: the three bundled instrawave instruments * (drum, dulcimer, chime) exposed through midiboot's Instrument interface, * each at its default geometry with reduced accuracy so the first bake is * quick rather than faithful. Instrawave is itself a prototype and this * seam will be swapped for its successor; nothing outside this directory * knows instrawave exists, so the swap is confined here. * * What the placeholder deliberately leaves out: instance editing (geometry * and tuning are fixed at the file defaults), bake persistence (bakes live * in memory and are redone per page load), and a live noteOn voice * (monitoring falls back to the player's beep). Notes are treated as pure * onsets, the way a struck drum works: each note becomes one stimulus with * the pitch parameter overridden to the note's frequency (clamped to the * parameter's range) and the excitation strength scaled linearly by * velocity, against one shared bake. */ import { requestDevice } from 'instrawave/src/host/device.ts'; import { InstrumentSession } from 'instrawave/src/host/session.ts'; import type { Bake } from 'instrawave/src/host/bake.ts'; import { parseInstrument, paramValues, type InstrumentFile, 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'; import type { Instrument, RenderRequest } from '../types.ts'; import { registerInstrument } from '../registry.ts'; import { midiToFreq } from '../dsp.ts'; /** Coarse grid, short room response: a bake in seconds, not minutes. */ const PLACEHOLDER_ACCURACY: Record = { nx: 64, ir_seconds: 0.4 }; type StatusFn = (msg: string) => void; let onStatus: StatusFn = () => {}; export function setInstrawaveStatus(cb: StatusFn): void { onStatus = cb; } let devicePromise: Promise | null = null; function ensureDevice(): Promise { return (devicePromise ??= requestDevice()); } function resampleLinear(x: Float32Array, srFrom: number, srTo: number, outLen: number): Float32Array { const out = new Float32Array(outLen); if (x.length === 0) return out; for (let i = 0; i < outLen; i++) { const s = (i * srFrom) / srTo; const k = Math.floor(s); if (k >= x.length - 1) break; const f = s - k; out[i] = (1 - f) * x[k] + f * x[k + 1]; } return out; } class InstrawaveInstrument implements Instrument { readonly values: Record; private readonly pitchKey: string; private readonly strengthKey: string; private bake: Bake | null = null; private queue: Promise = Promise.resolve(); constructor( readonly id: string, readonly name: string, readonly file: InstrumentFile, ) { this.values = paramValues(file, PLACEHOLDER_ACCURACY); this.pitchKey = file.render.eventParams.includes('f0') ? 'f0' : 'f1'; this.strengthKey = file.render.eventParams.includes('vel') ? 'vel' : 'amp'; } render(req: RenderRequest): Promise { // One GPU render at a time per instrument; concurrent tracks queue. const run = this.queue.then(() => this.renderNow(req)); this.queue = run.catch(() => undefined); return run; } private async renderNow(req: RenderRequest): Promise { if (req.notes.length === 0) return new Float32Array(req.lengthSamples); const device = await ensureDevice(); const session = new InstrumentSession(device, this.file, this.values); if (!this.bake) { try { this.bake = await session.precompute((done, total) => onStatus(`baking ${this.name}: ${done}/${total}`), ); } finally { onStatus(''); } } const spec = this.file.params.find((p) => p.key === this.pitchKey)!; let clamped = 0; const events: StimulusEvent[] = req.notes.map((n) => { const f = midiToFreq(n.pitch); const v = Math.max(spec.min, Math.min(spec.max, f)); if (v !== f) clamped++; return { t: n.timeSec, set: { [this.pitchKey]: v, [this.strengthKey]: this.values[this.strengthKey] * n.velocity } }; }); if (clamped > 0) console.warn(`${this.name}: ${clamped} note(s) clamped to the ${spec.min}..${spec.max} Hz pitch range`); const seconds = req.lengthSamples / req.sampleRate; let trace: Float32Array; let dt: number; try { const r = await session.render(this.bake, seconds, events, [], (done, total) => onStatus(`rendering ${this.name}: ${done}/${total}`), ); trace = r.trace; dt = r.dt; } finally { onStatus(''); } return resampleLinear(trace, 1 / dt, req.sampleRate, req.lengthSamples); } } export function createInstrawaveInstruments(): Instrument[] { const files = [drumJson, dulcimerJson, chimeJson].map((j) => parseInstrument(JSON.stringify(j))); return files.map((f) => new InstrawaveInstrument(`iw-${f.name}`, `Instrawave ${f.name} (GPU)`, f)); } /** Registers the placeholder instruments when WebGPU is available; * returns whether it did. Without WebGPU nothing is registered and a song * bound to these ids falls back to a builtin. */ export function registerInstrawaveInstruments(): boolean { if (typeof navigator === 'undefined' || !navigator.gpu) return false; for (const ins of createInstrawaveInstruments()) registerInstrument(ins); return true; }