1import type { Instrument, LiveVoice, NoteSpec, TrackRenderRequest, TrackRenderResult } from './types.ts';
2import { brassNote, drumNote, epianoNote, leadNote, organNote, pianoNote, pluckNote, stringsNote, synthBassNote } from './dsp.ts';
4// Built-in instruments, each constructed from a pure per-note DSP function.
5// The render path sums per-note buffers; the live path plays one note's
6// buffer through a gain node whose release implements noteOff. A custom
7// instrument with its own rendering logic does not have to go through
8// this helper; it only has to satisfy the Instrument interface.
10export type NoteDsp = (sr: number, pitch: number, velocity: number, durationSec: number) => Float32Array;
12interface DspOpts {
13 sustains: boolean; // if true, live voices are rendered long and gated by noteOff
14 liveDurationSec?: number;
15}
17export function instrumentFromNoteDsp(id: string, name: string, dsp: NoteDsp, opts: DspOpts): Instrument {
18 return {
19 id,
20 name,
21 renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
22 // lengthSamples is a minimum: a release that outlives the last note
23 // is kept rather than clipped.
24 const rendered: { off: number; buf: Float32Array }[] = [];
25 let length = req.lengthSamples;
26 for (const note of req.notes) {
27 const buf = dsp(req.sampleRate, note.pitch, note.velocity, note.durationSec);
28 const off = Math.round(note.timeSec * req.sampleRate);
29 rendered.push({ off, buf });
30 length = Math.max(length, off + buf.length);
31 }
32 const out = new Float32Array(length);
33 for (const r of rendered) {
34 const end = Math.min(out.length, r.off + r.buf.length);
35 for (let i = Math.max(0, r.off); i < end; i++) out[i] += r.buf[i - r.off];
36 }
37 return Promise.resolve({ audio: out });
38 },
39 noteOn(ctx, dest, pitch, velocity, whenSec): LiveVoice {
40 const dur = opts.sustains ? (opts.liveDurationSec ?? 20) : 0.25;
41 const data = dsp(ctx.sampleRate, pitch, velocity, dur);
42 const buffer = ctx.createBuffer(1, data.length, ctx.sampleRate);
43 buffer.copyToChannel(data as Float32Array<ArrayBuffer>, 0);
44 const src = ctx.createBufferSource();
45 src.buffer = buffer;
46 const gain = ctx.createGain();
47 src.connect(gain);
48 gain.connect(dest);
49 src.start(whenSec);
50 let done = false;
51 const finish = (fadeSec: number) => {
52 if (done) return;
53 done = true;
54 const t = Math.max(ctx.currentTime, whenSec);
55 gain.gain.setValueAtTime(gain.gain.value, t);
56 gain.gain.setTargetAtTime(0, t, fadeSec / 4);
57 src.stop(t + fadeSec + 0.1);
58 };
59 src.onended = () => { done = true; };
60 return {
61 noteOff: () => { if (opts.sustains) finish(0.15); },
62 cut: () => finish(0.02),
63 };
64 },
65 };
66}
68// The cheap synthetic set: one instrument per broad General MIDI family,
69// so an imported MIDI file is playable without a soundfont.
70export const builtinInstruments: Instrument[] = [
71 instrumentFromNoteDsp('piano', 'Piano (FM)', pianoNote, { sustains: true, liveDurationSec: 10 }),
72 instrumentFromNoteDsp('epiano', 'E-Piano (FM)', epianoNote, { sustains: true, liveDurationSec: 8 }),
73 instrumentFromNoteDsp('organ', 'Organ (drawbar)', organNote, { sustains: true, liveDurationSec: 20 }),
74 instrumentFromNoteDsp('pluck', 'Pluck (Karplus-Strong)', pluckNote, { sustains: true, liveDurationSec: 3 }),
75 instrumentFromNoteDsp('synthbass', 'Synth Bass', synthBassNote, { sustains: true, liveDurationSec: 6 }),
76 instrumentFromNoteDsp('strings', 'Strings (pad)', stringsNote, { sustains: true, liveDurationSec: 20 }),
77 instrumentFromNoteDsp('brass', 'Brass (saw)', brassNote, { sustains: true, liveDurationSec: 20 }),
78 instrumentFromNoteDsp('lead', 'Lead (square)', leadNote, { sustains: true, liveDurationSec: 20 }),
79 instrumentFromNoteDsp('drumkit', 'Drum kit (GM)', drumNote, { sustains: false }),
80];
82export function noteSpecVelocity(velocity127: number): number {
83 return Math.max(0, Math.min(1, velocity127 / 127));
84}
86export type { NoteSpec };