0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 1import type { Song } from '../types.ts';
2import type { LiveVoice } from '../instruments/types.ts';
3import { getInstrument } from '../instruments/registry.ts';
4import { TempoMap } from '../midi/time.ts';
5import { audibleTracks, encodeWavMono16, mixTracks, renderTrackBuffer, trackRenderKey } from './render.ts';
7// The transport. Playback is offline-rendered per track (cached, keyed by
8// everything that affects the audio) and played as buffer sources, so an
9// expensive instrument costs one render, not a realtime budget. The live
10// path (previewNoteOn) is for monitoring while recording and for
11// previewing notes; it goes through the instrument's noteOn.
13export type TransportState = 'stopped' | 'playing' | 'recording';
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 15export interface PlayOpts {
16 record?: boolean;
17 countInBars?: number;
18 loop?: { startSec: number; endSec: number };
19}
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 21const EXPORT_SAMPLE_RATE = 44100;
23function scheduleClick(ctx: BaseAudioContext, dest: AudioNode, when: number, accent: boolean) {
24 const osc = ctx.createOscillator();
25 osc.frequency.value = accent ? 1760 : 1175;
26 const gain = ctx.createGain();
27 gain.gain.setValueAtTime(accent ? 0.5 : 0.3, when);
28 gain.gain.exponentialRampToValueAtTime(0.001, when + 0.06);
29 osc.connect(gain);
30 gain.connect(dest);
31 osc.start(when);
32 osc.stop(when + 0.08);
33}
35class Player {
36 private ctx: AudioContext | null = null;
37 private master: GainNode | null = null;
38 private sources: AudioBufferSourceNode[] = [];
39 private cache = new Map<string, { key: string; buffer: Float32Array }>();
40 private metroTimer: ReturnType<typeof setInterval> | null = null;
41 private raf = 0;
42 private lastEmit = 0;
43 private startCtxTime = 0;
44 private startPosSec = 0;
45 private endSec = 0;
46 private listeners = new Set<() => void>();
47 version = 0;
48 state: TransportState = 'stopped';
49 posSec = 0;
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 50 private loop: { startSec: number; endSec: number } | null = null;
51 private lastPlay: { song: Song; opts: PlayOpts } | null = null;
ff3d286Metronome off by default; scroll piano roll to playhead on seekJeremy Magland 52 metronome = false;
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 53 preparing = false;
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 54 /** Per-track render progress while preparing, keyed by track id. */
55 renderProgress = new Map<string, { frac: number; stage: string }>();
56 private prepareAbort: AbortController | null = null;
58 subscribe = (cb: () => void): (() => void) => {
59 this.listeners.add(cb);
60 return () => this.listeners.delete(cb);
61 };
63 private emit() {
64 this.version++;
65 for (const cb of this.listeners) cb();
66 }
68 ensureCtx(): AudioContext {
69 if (!this.ctx) {
70 this.ctx = new AudioContext();
71 this.master = this.ctx.createGain();
72 this.master.gain.value = 0.9;
73 this.master.connect(this.ctx.destination);
74 }
75 if (this.ctx.state === 'suspended') void this.ctx.resume();
76 return this.ctx;
77 }
79 getPositionSec(): number {
80 if (this.state === 'stopped' || !this.ctx) return this.posSec;
81 return this.startPosSec + (this.ctx.currentTime - this.startCtxTime);
82 }
84 private async prepareTracks(song: Song, sampleRate: number): Promise<void> {
85 const ids = new Set(song.tracks.map((t) => t.id));
86 for (const id of [...this.cache.keys()]) if (!ids.has(id)) this.cache.delete(id);
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 87 this.prepareAbort = new AbortController();
88 const signal = this.prepareAbort.signal;
89 try {
90 for (const track of song.tracks) {
91 const key = trackRenderKey(song, track) + '@' + sampleRate;
92 const cached = this.cache.get(track.id);
93 if (cached && cached.key === key) continue;
94 this.renderProgress.set(track.id, { frac: 0, stage: 'starting' });
95 this.emit();
96 const buffer = await renderTrackBuffer(song, track, getInstrument(track.instrumentId), sampleRate, 'preview', {
97 signal,
98 onProgress: (p) => {
99 this.renderProgress.set(track.id, p);
100 this.emit();
101 },
102 });
103 this.renderProgress.delete(track.id);
104 this.emit();
105 this.cache.set(track.id, { key, buffer });
106 }
107 } finally {
108 this.renderProgress.clear();
109 this.prepareAbort = null;
110 this.emit();
112 }
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 114 /** Abort the renders behind a pending play or export. */
115 cancelRender(): void {
116 this.prepareAbort?.abort();
117 }
119 static isAbort(err: unknown): boolean {
120 return err instanceof DOMException && err.name === 'AbortError';
121 }
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 123 async play(song: Song, opts: PlayOpts = {}): Promise<void> {
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 124 if (this.state !== 'stopped') this.stop();
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 125 this.loop = !opts.record && opts.loop ? opts.loop : null;
126 this.lastPlay = { song, opts };
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 127 const ctx = this.ensureCtx();
128 await ctx.resume();
129 this.preparing = true;
130 this.emit();
131 try {
132 await this.prepareTracks(song, ctx.sampleRate);
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 133 } catch (err) {
134 if (Player.isAbort(err)) { this.preparing = false; this.emit(); return; }
135 throw err;
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 136 } finally {
137 this.preparing = false;
138 }
139 const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
141 // Count-in: one or more bars of clicks at the signature and tempo in
142 // force at the start position.
143 let countInSec = 0;
144 const startTick = tm.secToTick(this.posSec);
145 let sig = tm.timeSig[0];
146 for (const s of tm.timeSig) if (s.tick <= startTick) sig = s;
147 const beatSec = (60 / tm.bpmAt(startTick)) * (4 / sig.denominator);
148 const countBeats = (opts.countInBars ?? 0) * sig.numerator;
149 countInSec = countBeats * beatSec;
151 const t0 = ctx.currentTime + 0.12 + countInSec;
152 for (let k = 0; k < countBeats; k++) {
153 scheduleClick(ctx, this.master!, t0 - countInSec + k * beatSec, k % sig.numerator === 0);
154 }
156 const audible = audibleTracks(song.tracks);
157 this.endSec = this.posSec;
158 this.sources = [];
159 for (const track of song.tracks) {
160 if (!audible.has(track.id)) continue;
161 const entry = this.cache.get(track.id);
162 if (!entry) continue;
163 const durSec = entry.buffer.length / ctx.sampleRate;
164 this.endSec = Math.max(this.endSec, durSec);
165 if (this.posSec >= durSec) continue;
166 const buf = ctx.createBuffer(1, entry.buffer.length, ctx.sampleRate);
167 buf.copyToChannel(entry.buffer as Float32Array<ArrayBuffer>, 0);
168 const src = ctx.createBufferSource();
169 src.buffer = buf;
170 const gain = ctx.createGain();
171 gain.gain.value = track.volume;
172 src.connect(gain);
173 gain.connect(this.master!);
174 src.start(t0, this.posSec);
175 this.sources.push(src);
176 }
178 this.startCtxTime = t0;
179 this.startPosSec = this.posSec;
180 this.state = opts.record ? 'recording' : 'playing';
181 this.emit();
183 // Metronome: schedule beat clicks a little ahead of time.
184 if (this.metronome || opts.record) {
185 let lastTick = Math.floor(tm.secToTick(this.posSec)) - 1;
186 this.metroTimer = setInterval(() => {
187 if (this.state === 'stopped' || !this.ctx) return;
188 const now = this.getPositionSec();
189 const horizon = now + 0.4;
190 if (horizon < 0) return;
191 const t0tick = Math.max(lastTick + 1, Math.ceil(tm.secToTick(Math.max(0, now))));
192 const t1tick = Math.ceil(tm.secToTick(Math.max(0, horizon))) + 1;
193 for (const line of tm.gridLines(t0tick, t1tick)) {
194 if (line.tick <= lastTick) continue;
195 const when = this.startCtxTime + (tm.tickToSec(line.tick) - this.startPosSec);
196 if (when < this.ctx.currentTime) continue;
197 scheduleClick(this.ctx, this.master!, when, line.isBar);
198 lastTick = line.tick;
199 }
200 }, 60);
201 }
203 const tick = () => {
204 if (this.state === 'stopped') return;
205 const now = performance.now();
206 if (now - this.lastEmit > 100) {
207 this.lastEmit = now;
208 this.emit();
209 }
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 210 if (this.state === 'playing') {
211 const pos = this.getPositionSec();
212 if (this.loop && pos >= this.loop.endSec) {
213 // Loop: restart from the loop start; renders are cached, so this
214 // is quick. The tiny seam is acceptable for a preview transport.
215 const lp = this.lastPlay;
216 const start = this.loop.startSec;
217 this.stop();
218 if (lp) {
219 this.posSec = start;
220 void this.play(lp.song, lp.opts);
221 }
222 return;
223 }
224 if (!this.loop && pos > this.endSec + 0.3) {
225 this.stop();
226 return;
227 }
229 this.raf = requestAnimationFrame(tick);
230 };
231 this.raf = requestAnimationFrame(tick);
232 }
234 stop(): void {
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 235 this.cancelRender();
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 236 if (this.state === 'stopped') return;
237 this.posSec = Math.max(0, this.getPositionSec());
238 for (const src of this.sources) {
239 try { src.stop(); } catch { /* already stopped */ }
240 }
241 this.sources = [];
242 if (this.metroTimer !== null) { clearInterval(this.metroTimer); this.metroTimer = null; }
243 cancelAnimationFrame(this.raf);
244 this.state = 'stopped';
245 this.emit();
246 }
248 seek(sec: number): void {
249 this.posSec = Math.max(0, sec);
250 this.emit();
251 }
253 previewNoteOn(instrumentId: string, pitch: number, velocity127: number): LiveVoice {
254 const ctx = this.ensureCtx();
255 const ins = getInstrument(instrumentId);
256 if (ins.noteOn) return ins.noteOn(ctx, this.master!, pitch, Math.max(0, Math.min(1, velocity127 / 127)), ctx.currentTime);
257 // Fallback for instruments with no live path: a plain beep.
258 const osc = ctx.createOscillator();
259 osc.frequency.value = 440 * Math.pow(2, (pitch - 69) / 12);
260 const gain = ctx.createGain();
261 gain.gain.value = 0.2 * (velocity127 / 127);
262 osc.connect(gain);
263 gain.connect(this.master!);
264 osc.start();
265 const off = () => {
266 const t = ctx.currentTime;
267 gain.gain.setTargetAtTime(0, t, 0.03);
268 osc.stop(t + 0.3);
269 };
270 return { noteOff: off, cut: off };
271 }
273 async exportWav(song: Song): Promise<Uint8Array> {
cdfbf5bRender whole tracks through direct-import instrument modulesJeremy Magland 274 // Full-quality renders bypass the preview cache; progress is reported
275 // the same way playback preparation is, so the UI shows it and the
276 // same cancel applies.
277 this.prepareAbort = new AbortController();
278 const signal = this.prepareAbort.signal;
279 this.preparing = true;
280 this.emit();
281 try {
282 const buffers = new Map<string, Float32Array>();
283 for (const track of song.tracks) {
284 this.renderProgress.set(track.id, { frac: 0, stage: 'starting' });
285 this.emit();
286 buffers.set(track.id, await renderTrackBuffer(song, track, getInstrument(track.instrumentId), EXPORT_SAMPLE_RATE, 'full', {
287 signal,
288 onProgress: (p) => {
289 this.renderProgress.set(track.id, p);
290 this.emit();
291 },
292 }));
293 this.renderProgress.delete(track.id);
294 this.emit();
295 }
296 const mix = mixTracks(song.tracks, buffers);
297 return encodeWavMono16(mix, EXPORT_SAMPLE_RATE);
298 } finally {
299 this.renderProgress.clear();
300 this.prepareAbort = null;
301 this.preparing = false;
302 this.emit();
304 }
305}
307export const player = new Player();