/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / audio / player.ts
249 lines · 8.8 KBCodeBlameHistory
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 };
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);
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;
55 subscribe = (cb: () => void): (() => void) => {
56 this.listeners.add(cb);
57 return () => this.listeners.delete(cb);
58 };
60 private emit() {
61 this.version++;
62 for (const cb of this.listeners) cb();
63 }
65 ensureCtx(): AudioContext {
66 if (!this.ctx) {
67 this.ctx = new AudioContext();
68 this.master = this.ctx.createGain();
69 this.master.gain.value = 0.9;
70 this.master.connect(this.ctx.destination);
71 }
72 if (this.ctx.state === 'suspended') void this.ctx.resume();
73 return this.ctx;
74 }
76 getPositionSec(): number {
77 if (this.state === 'stopped' || !this.ctx) return this.posSec;
78 return this.startPosSec + (this.ctx.currentTime - this.startCtxTime);
79 }
81 private async prepareTracks(song: Song, sampleRate: number): Promise<void> {
82 const ids = new Set(song.tracks.map((t) => t.id));
83 for (const id of [...this.cache.keys()]) if (!ids.has(id)) this.cache.delete(id);
84 for (const track of song.tracks) {
85 const key = trackRenderKey(song, track) + '@' + sampleRate;
86 const cached = this.cache.get(track.id);
87 if (cached && cached.key === key) continue;
78851f6Replace instrawave with simulated instruments driven by manifestsJeremy Magland 88 const buffer = await renderTrackBuffer(song, track, getInstrument(track.instrumentId), sampleRate, 'preview');
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 89 this.cache.set(track.id, { key, buffer });
90 }
91 }
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 93 async play(song: Song, opts: PlayOpts = {}): Promise<void> {
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 94 if (this.state !== 'stopped') this.stop();
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 95 this.loop = !opts.record && opts.loop ? opts.loop : null;
96 this.lastPlay = { song, opts };
0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 97 const ctx = this.ensureCtx();
98 await ctx.resume();
99 this.preparing = true;
100 this.emit();
101 try {
102 await this.prepareTracks(song, ctx.sampleRate);
103 } finally {
104 this.preparing = false;
105 }
106 const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
108 // Count-in: one or more bars of clicks at the signature and tempo in
109 // force at the start position.
110 let countInSec = 0;
111 const startTick = tm.secToTick(this.posSec);
112 let sig = tm.timeSig[0];
113 for (const s of tm.timeSig) if (s.tick <= startTick) sig = s;
114 const beatSec = (60 / tm.bpmAt(startTick)) * (4 / sig.denominator);
115 const countBeats = (opts.countInBars ?? 0) * sig.numerator;
116 countInSec = countBeats * beatSec;
118 const t0 = ctx.currentTime + 0.12 + countInSec;
119 for (let k = 0; k < countBeats; k++) {
120 scheduleClick(ctx, this.master!, t0 - countInSec + k * beatSec, k % sig.numerator === 0);
121 }
123 const audible = audibleTracks(song.tracks);
124 this.endSec = this.posSec;
125 this.sources = [];
126 for (const track of song.tracks) {
127 if (!audible.has(track.id)) continue;
128 const entry = this.cache.get(track.id);
129 if (!entry) continue;
130 const durSec = entry.buffer.length / ctx.sampleRate;
131 this.endSec = Math.max(this.endSec, durSec);
132 if (this.posSec >= durSec) continue;
133 const buf = ctx.createBuffer(1, entry.buffer.length, ctx.sampleRate);
134 buf.copyToChannel(entry.buffer as Float32Array<ArrayBuffer>, 0);
135 const src = ctx.createBufferSource();
136 src.buffer = buf;
137 const gain = ctx.createGain();
138 gain.gain.value = track.volume;
139 src.connect(gain);
140 gain.connect(this.master!);
141 src.start(t0, this.posSec);
142 this.sources.push(src);
143 }
145 this.startCtxTime = t0;
146 this.startPosSec = this.posSec;
147 this.state = opts.record ? 'recording' : 'playing';
148 this.emit();
150 // Metronome: schedule beat clicks a little ahead of time.
151 if (this.metronome || opts.record) {
152 let lastTick = Math.floor(tm.secToTick(this.posSec)) - 1;
153 this.metroTimer = setInterval(() => {
154 if (this.state === 'stopped' || !this.ctx) return;
155 const now = this.getPositionSec();
156 const horizon = now + 0.4;
157 if (horizon < 0) return;
158 const t0tick = Math.max(lastTick + 1, Math.ceil(tm.secToTick(Math.max(0, now))));
159 const t1tick = Math.ceil(tm.secToTick(Math.max(0, horizon))) + 1;
160 for (const line of tm.gridLines(t0tick, t1tick)) {
161 if (line.tick <= lastTick) continue;
162 const when = this.startCtxTime + (tm.tickToSec(line.tick) - this.startPosSec);
163 if (when < this.ctx.currentTime) continue;
164 scheduleClick(this.ctx, this.master!, when, line.isBar);
165 lastTick = line.tick;
166 }
167 }, 60);
168 }
170 const tick = () => {
171 if (this.state === 'stopped') return;
172 const now = performance.now();
173 if (now - this.lastEmit > 100) {
174 this.lastEmit = now;
175 this.emit();
176 }
f67f407Time selections, loop playback, and further DAW-standard editingJeremy Magland 177 if (this.state === 'playing') {
178 const pos = this.getPositionSec();
179 if (this.loop && pos >= this.loop.endSec) {
180 // Loop: restart from the loop start; renders are cached, so this
181 // is quick. The tiny seam is acceptable for a preview transport.
182 const lp = this.lastPlay;
183 const start = this.loop.startSec;
184 this.stop();
185 if (lp) {
186 this.posSec = start;
187 void this.play(lp.song, lp.opts);
188 }
189 return;
190 }
191 if (!this.loop && pos > this.endSec + 0.3) {
192 this.stop();
193 return;
194 }
196 this.raf = requestAnimationFrame(tick);
197 };
198 this.raf = requestAnimationFrame(tick);
199 }
201 stop(): void {
202 if (this.state === 'stopped') return;
203 this.posSec = Math.max(0, this.getPositionSec());
204 for (const src of this.sources) {
205 try { src.stop(); } catch { /* already stopped */ }
206 }
207 this.sources = [];
208 if (this.metroTimer !== null) { clearInterval(this.metroTimer); this.metroTimer = null; }
209 cancelAnimationFrame(this.raf);
210 this.state = 'stopped';
211 this.emit();
212 }
214 seek(sec: number): void {
215 this.posSec = Math.max(0, sec);
216 this.emit();
217 }
219 previewNoteOn(instrumentId: string, pitch: number, velocity127: number): LiveVoice {
220 const ctx = this.ensureCtx();
221 const ins = getInstrument(instrumentId);
222 if (ins.noteOn) return ins.noteOn(ctx, this.master!, pitch, Math.max(0, Math.min(1, velocity127 / 127)), ctx.currentTime);
223 // Fallback for instruments with no live path: a plain beep.
224 const osc = ctx.createOscillator();
225 osc.frequency.value = 440 * Math.pow(2, (pitch - 69) / 12);
226 const gain = ctx.createGain();
227 gain.gain.value = 0.2 * (velocity127 / 127);
228 osc.connect(gain);
229 gain.connect(this.master!);
230 osc.start();
231 const off = () => {
232 const t = ctx.currentTime;
233 gain.gain.setTargetAtTime(0, t, 0.03);
234 osc.stop(t + 0.3);
235 };
236 return { noteOff: off, cut: off };
237 }
239 async exportWav(song: Song): Promise<Uint8Array> {
240 const buffers = new Map<string, Float32Array>();
241 for (const track of song.tracks) {
78851f6Replace instrawave with simulated instruments driven by manifestsJeremy Magland 242 buffers.set(track.id, await renderTrackBuffer(song, track, getInstrument(track.instrumentId), EXPORT_SAMPLE_RATE, 'full'));
244 const mix = mixTracks(song.tracks, buffers);
245 return encodeWavMono16(mix, EXPORT_SAMPLE_RATE);
246 }
249export const player = new Player();
↑↓ move↵openescclose