import type { Song } from '../types.ts'; import type { LiveVoice } from '../instruments/types.ts'; import { getInstrument } from '../instruments/registry.ts'; import { TempoMap } from '../midi/time.ts'; import { audibleTracks, encodeWavMono16, mixTracks, renderTrackBuffer, trackRenderKey } from './render.ts'; // The transport. Playback is offline-rendered per track (cached, keyed by // everything that affects the audio) and played as buffer sources, so an // expensive instrument costs one render, not a realtime budget. The live // path (previewNoteOn) is for monitoring while recording and for // previewing notes; it goes through the instrument's noteOn. export type TransportState = 'stopped' | 'playing' | 'recording'; const EXPORT_SAMPLE_RATE = 44100; function scheduleClick(ctx: BaseAudioContext, dest: AudioNode, when: number, accent: boolean) { const osc = ctx.createOscillator(); osc.frequency.value = accent ? 1760 : 1175; const gain = ctx.createGain(); gain.gain.setValueAtTime(accent ? 0.5 : 0.3, when); gain.gain.exponentialRampToValueAtTime(0.001, when + 0.06); osc.connect(gain); gain.connect(dest); osc.start(when); osc.stop(when + 0.08); } class Player { private ctx: AudioContext | null = null; private master: GainNode | null = null; private sources: AudioBufferSourceNode[] = []; private cache = new Map(); private metroTimer: ReturnType | null = null; private raf = 0; private lastEmit = 0; private startCtxTime = 0; private startPosSec = 0; private endSec = 0; private listeners = new Set<() => void>(); version = 0; state: TransportState = 'stopped'; posSec = 0; metronome = true; preparing = false; subscribe = (cb: () => void): (() => void) => { this.listeners.add(cb); return () => this.listeners.delete(cb); }; private emit() { this.version++; for (const cb of this.listeners) cb(); } ensureCtx(): AudioContext { if (!this.ctx) { this.ctx = new AudioContext(); this.master = this.ctx.createGain(); this.master.gain.value = 0.9; this.master.connect(this.ctx.destination); } if (this.ctx.state === 'suspended') void this.ctx.resume(); return this.ctx; } getPositionSec(): number { if (this.state === 'stopped' || !this.ctx) return this.posSec; return this.startPosSec + (this.ctx.currentTime - this.startCtxTime); } private async prepareTracks(song: Song, sampleRate: number): Promise { const ids = new Set(song.tracks.map((t) => t.id)); for (const id of [...this.cache.keys()]) if (!ids.has(id)) this.cache.delete(id); for (const track of song.tracks) { const key = trackRenderKey(song, track) + '@' + sampleRate; const cached = this.cache.get(track.id); if (cached && cached.key === key) continue; const buffer = await renderTrackBuffer(song, track, getInstrument(track.instrumentId), sampleRate); this.cache.set(track.id, { key, buffer }); } } async play(song: Song, opts: { record?: boolean; countInBars?: number } = {}): Promise { if (this.state !== 'stopped') this.stop(); const ctx = this.ensureCtx(); await ctx.resume(); this.preparing = true; this.emit(); try { await this.prepareTracks(song, ctx.sampleRate); } finally { this.preparing = false; } const tm = new TempoMap(song.ppq, song.tempo, song.timeSig); // Count-in: one or more bars of clicks at the signature and tempo in // force at the start position. let countInSec = 0; const startTick = tm.secToTick(this.posSec); let sig = tm.timeSig[0]; for (const s of tm.timeSig) if (s.tick <= startTick) sig = s; const beatSec = (60 / tm.bpmAt(startTick)) * (4 / sig.denominator); const countBeats = (opts.countInBars ?? 0) * sig.numerator; countInSec = countBeats * beatSec; const t0 = ctx.currentTime + 0.12 + countInSec; for (let k = 0; k < countBeats; k++) { scheduleClick(ctx, this.master!, t0 - countInSec + k * beatSec, k % sig.numerator === 0); } const audible = audibleTracks(song.tracks); this.endSec = this.posSec; this.sources = []; for (const track of song.tracks) { if (!audible.has(track.id)) continue; const entry = this.cache.get(track.id); if (!entry) continue; const durSec = entry.buffer.length / ctx.sampleRate; this.endSec = Math.max(this.endSec, durSec); if (this.posSec >= durSec) continue; const buf = ctx.createBuffer(1, entry.buffer.length, ctx.sampleRate); buf.copyToChannel(entry.buffer as Float32Array, 0); const src = ctx.createBufferSource(); src.buffer = buf; const gain = ctx.createGain(); gain.gain.value = track.volume; src.connect(gain); gain.connect(this.master!); src.start(t0, this.posSec); this.sources.push(src); } this.startCtxTime = t0; this.startPosSec = this.posSec; this.state = opts.record ? 'recording' : 'playing'; this.emit(); // Metronome: schedule beat clicks a little ahead of time. if (this.metronome || opts.record) { let lastTick = Math.floor(tm.secToTick(this.posSec)) - 1; this.metroTimer = setInterval(() => { if (this.state === 'stopped' || !this.ctx) return; const now = this.getPositionSec(); const horizon = now + 0.4; if (horizon < 0) return; const t0tick = Math.max(lastTick + 1, Math.ceil(tm.secToTick(Math.max(0, now)))); const t1tick = Math.ceil(tm.secToTick(Math.max(0, horizon))) + 1; for (const line of tm.gridLines(t0tick, t1tick)) { if (line.tick <= lastTick) continue; const when = this.startCtxTime + (tm.tickToSec(line.tick) - this.startPosSec); if (when < this.ctx.currentTime) continue; scheduleClick(this.ctx, this.master!, when, line.isBar); lastTick = line.tick; } }, 60); } const tick = () => { if (this.state === 'stopped') return; const now = performance.now(); if (now - this.lastEmit > 100) { this.lastEmit = now; this.emit(); } if (this.state === 'playing' && this.getPositionSec() > this.endSec + 0.3) { this.stop(); return; } this.raf = requestAnimationFrame(tick); }; this.raf = requestAnimationFrame(tick); } stop(): void { if (this.state === 'stopped') return; this.posSec = Math.max(0, this.getPositionSec()); for (const src of this.sources) { try { src.stop(); } catch { /* already stopped */ } } this.sources = []; if (this.metroTimer !== null) { clearInterval(this.metroTimer); this.metroTimer = null; } cancelAnimationFrame(this.raf); this.state = 'stopped'; this.emit(); } seek(sec: number): void { this.posSec = Math.max(0, sec); this.emit(); } previewNoteOn(instrumentId: string, pitch: number, velocity127: number): LiveVoice { const ctx = this.ensureCtx(); const ins = getInstrument(instrumentId); if (ins.noteOn) return ins.noteOn(ctx, this.master!, pitch, Math.max(0, Math.min(1, velocity127 / 127)), ctx.currentTime); // Fallback for instruments with no live path: a plain beep. const osc = ctx.createOscillator(); osc.frequency.value = 440 * Math.pow(2, (pitch - 69) / 12); const gain = ctx.createGain(); gain.gain.value = 0.2 * (velocity127 / 127); osc.connect(gain); gain.connect(this.master!); osc.start(); const off = () => { const t = ctx.currentTime; gain.gain.setTargetAtTime(0, t, 0.03); osc.stop(t + 0.3); }; return { noteOff: off, cut: off }; } async exportWav(song: Song): Promise { const buffers = new Map(); for (const track of song.tracks) { buffers.set(track.id, await renderTrackBuffer(song, track, getInstrument(track.instrumentId), EXPORT_SAMPLE_RATE)); } const mix = mixTracks(song.tracks, buffers); return encodeWavMono16(mix, EXPORT_SAMPLE_RATE); } } export const player = new Player();