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';
15const EXPORT_SAMPLE_RATE = 44100;
17function scheduleClick(ctx: BaseAudioContext, dest: AudioNode, when: number, accent: boolean) {
18 const osc = ctx.createOscillator();
19 osc.frequency.value = accent ? 1760 : 1175;
20 const gain = ctx.createGain();
21 gain.gain.setValueAtTime(accent ? 0.5 : 0.3, when);
22 gain.gain.exponentialRampToValueAtTime(0.001, when + 0.06);
23 osc.connect(gain);
24 gain.connect(dest);
25 osc.start(when);
26 osc.stop(when + 0.08);
27}
29class Player {
30 private ctx: AudioContext | null = null;
31 private master: GainNode | null = null;
32 private sources: AudioBufferSourceNode[] = [];
33 private cache = new Map<string, { key: string; buffer: Float32Array }>();
34 private metroTimer: ReturnType<typeof setInterval> | null = null;
35 private raf = 0;
36 private lastEmit = 0;
37 private startCtxTime = 0;
38 private startPosSec = 0;
39 private endSec = 0;
40 private listeners = new Set<() => void>();
41 version = 0;
42 state: TransportState = 'stopped';
43 posSec = 0;
44 metronome = true;
45 preparing = false;
47 subscribe = (cb: () => void): (() => void) => {
48 this.listeners.add(cb);
49 return () => this.listeners.delete(cb);
50 };
52 private emit() {
53 this.version++;
54 for (const cb of this.listeners) cb();
55 }
57 ensureCtx(): AudioContext {
58 if (!this.ctx) {
59 this.ctx = new AudioContext();
60 this.master = this.ctx.createGain();
61 this.master.gain.value = 0.9;
62 this.master.connect(this.ctx.destination);
63 }
64 if (this.ctx.state === 'suspended') void this.ctx.resume();
65 return this.ctx;
66 }
68 getPositionSec(): number {
69 if (this.state === 'stopped' || !this.ctx) return this.posSec;
70 return this.startPosSec + (this.ctx.currentTime - this.startCtxTime);
71 }
73 private async prepareTracks(song: Song, sampleRate: number): Promise<void> {
74 const ids = new Set(song.tracks.map((t) => t.id));
75 for (const id of [...this.cache.keys()]) if (!ids.has(id)) this.cache.delete(id);
76 for (const track of song.tracks) {
77 const key = trackRenderKey(song, track) + '@' + sampleRate;
78 const cached = this.cache.get(track.id);
79 if (cached && cached.key === key) continue;
80 const buffer = await renderTrackBuffer(song, track, getInstrument(track.instrumentId), sampleRate);
81 this.cache.set(track.id, { key, buffer });
82 }
83 }
85 async play(song: Song, opts: { record?: boolean; countInBars?: number } = {}): Promise<void> {
86 if (this.state !== 'stopped') this.stop();
87 const ctx = this.ensureCtx();
88 await ctx.resume();
89 this.preparing = true;
90 this.emit();
91 try {
92 await this.prepareTracks(song, ctx.sampleRate);
93 } finally {
94 this.preparing = false;
95 }
96 const tm = new TempoMap(song.ppq, song.tempo, song.timeSig);
98 // Count-in: one or more bars of clicks at the signature and tempo in
99 // force at the start position.
100 let countInSec = 0;
101 const startTick = tm.secToTick(this.posSec);
102 let sig = tm.timeSig[0];
103 for (const s of tm.timeSig) if (s.tick <= startTick) sig = s;
104 const beatSec = (60 / tm.bpmAt(startTick)) * (4 / sig.denominator);
105 const countBeats = (opts.countInBars ?? 0) * sig.numerator;
106 countInSec = countBeats * beatSec;
108 const t0 = ctx.currentTime + 0.12 + countInSec;
109 for (let k = 0; k < countBeats; k++) {
110 scheduleClick(ctx, this.master!, t0 - countInSec + k * beatSec, k % sig.numerator === 0);
111 }
113 const audible = audibleTracks(song.tracks);
114 this.endSec = this.posSec;
115 this.sources = [];
116 for (const track of song.tracks) {
117 if (!audible.has(track.id)) continue;
118 const entry = this.cache.get(track.id);
119 if (!entry) continue;
120 const durSec = entry.buffer.length / ctx.sampleRate;
121 this.endSec = Math.max(this.endSec, durSec);
122 if (this.posSec >= durSec) continue;
123 const buf = ctx.createBuffer(1, entry.buffer.length, ctx.sampleRate);
124 buf.copyToChannel(entry.buffer as Float32Array<ArrayBuffer>, 0);
125 const src = ctx.createBufferSource();
126 src.buffer = buf;
127 const gain = ctx.createGain();
128 gain.gain.value = track.volume;
129 src.connect(gain);
130 gain.connect(this.master!);
131 src.start(t0, this.posSec);
132 this.sources.push(src);
133 }
135 this.startCtxTime = t0;
136 this.startPosSec = this.posSec;
137 this.state = opts.record ? 'recording' : 'playing';
138 this.emit();
140 // Metronome: schedule beat clicks a little ahead of time.
141 if (this.metronome || opts.record) {
142 let lastTick = Math.floor(tm.secToTick(this.posSec)) - 1;
143 this.metroTimer = setInterval(() => {
144 if (this.state === 'stopped' || !this.ctx) return;
145 const now = this.getPositionSec();
146 const horizon = now + 0.4;
147 if (horizon < 0) return;
148 const t0tick = Math.max(lastTick + 1, Math.ceil(tm.secToTick(Math.max(0, now))));
149 const t1tick = Math.ceil(tm.secToTick(Math.max(0, horizon))) + 1;
150 for (const line of tm.gridLines(t0tick, t1tick)) {
151 if (line.tick <= lastTick) continue;
152 const when = this.startCtxTime + (tm.tickToSec(line.tick) - this.startPosSec);
153 if (when < this.ctx.currentTime) continue;
154 scheduleClick(this.ctx, this.master!, when, line.isBar);
155 lastTick = line.tick;
156 }
157 }, 60);
158 }
160 const tick = () => {
161 if (this.state === 'stopped') return;
162 const now = performance.now();
163 if (now - this.lastEmit > 100) {
164 this.lastEmit = now;
165 this.emit();
166 }
167 if (this.state === 'playing' && this.getPositionSec() > this.endSec + 0.3) {
168 this.stop();
169 return;
170 }
171 this.raf = requestAnimationFrame(tick);
172 };
173 this.raf = requestAnimationFrame(tick);
174 }
176 stop(): void {
177 if (this.state === 'stopped') return;
178 this.posSec = Math.max(0, this.getPositionSec());
179 for (const src of this.sources) {
180 try { src.stop(); } catch { /* already stopped */ }
181 }
182 this.sources = [];
183 if (this.metroTimer !== null) { clearInterval(this.metroTimer); this.metroTimer = null; }
184 cancelAnimationFrame(this.raf);
185 this.state = 'stopped';
186 this.emit();
187 }
189 seek(sec: number): void {
190 this.posSec = Math.max(0, sec);
191 this.emit();
192 }
194 previewNoteOn(instrumentId: string, pitch: number, velocity127: number): LiveVoice {
195 const ctx = this.ensureCtx();
196 const ins = getInstrument(instrumentId);
197 if (ins.noteOn) return ins.noteOn(ctx, this.master!, pitch, Math.max(0, Math.min(1, velocity127 / 127)), ctx.currentTime);
198 // Fallback for instruments with no live path: a plain beep.
199 const osc = ctx.createOscillator();
200 osc.frequency.value = 440 * Math.pow(2, (pitch - 69) / 12);
201 const gain = ctx.createGain();
202 gain.gain.value = 0.2 * (velocity127 / 127);
203 osc.connect(gain);
204 gain.connect(this.master!);
205 osc.start();
206 const off = () => {
207 const t = ctx.currentTime;
208 gain.gain.setTargetAtTime(0, t, 0.03);
209 osc.stop(t + 0.3);
210 };
211 return { noteOff: off, cut: off };
212 }
214 async exportWav(song: Song): Promise<Uint8Array> {
215 const buffers = new Map<string, Float32Array>();
216 for (const track of song.tracks) {
217 buffers.set(track.id, await renderTrackBuffer(song, track, getInstrument(track.instrumentId), EXPORT_SAMPLE_RATE));
218 }
219 const mix = mixTracks(song.tracks, buffers);
220 return encodeWavMono16(mix, EXPORT_SAMPLE_RATE);
221 }
222}
224export const player = new Player();