import { Signal } from "@char/aftercare"; import { sounding, voiceTrack } from "../project/harmony.ts"; import { type Project, projectEnd, projectKey, type Track } from "../project/model.ts"; import { project } from "../project/store.ts"; import { audio, playNote, silence, type Voice } from "./synth.ts"; export const playing = new Signal(false); /** the beat currently sounding, while playing */ export const playhead = new Signal(null); const LOOKAHEAD = 0.15; /** a stretch of the timeline played at a fixed tempo, starting at `time` (audio clock) */ type Segment = { time: number; beat: number; spb: number }; let segments: Segment[] = []; let scheduledTo = 0; let timer: ReturnType | undefined; /** set when playback starts past the loop: it then plays on to the end, as if loop were off */ let pastLoop = false; const looping = (p: Project) => p.loop && !pastLoop; let frame: number | undefined; const songBeats = (p: Project): number => { const bar = p.beatsPerBar; return Math.max(bar, Math.ceil(projectEnd(p) / bar) * bar); }; export const loopRange = (p: Project): { start: number; end: number } => p.loopRegion ?? { start: 0, end: songBeats(p) }; export function play(at: number): void { stop(); const { ctx } = audio(); const p = project.get(); pastLoop = at >= loopRange(p).end; segments = [{ time: ctx.currentTime + 0.05, beat: at, spb: 60 / p.bpm }]; scheduledTo = at; playing.set(true); timer = setInterval(tick, 25); tick(); frame = requestAnimationFrame(animate); } export function stop(): void { if (!playing.get()) return; clearInterval(timer); if (frame !== undefined) cancelAnimationFrame(frame); silence(); playing.set(false); playhead.set(null); } function tick() { const p = project.get(); const { ctx } = audio(); const spb = 60 / p.bpm; let seg = segments.at(-1)!; if (seg.spb !== spb) { seg = { time: seg.time + (scheduledTo - seg.beat) * seg.spb, beat: scheduledTo, spb }; segments.push(seg); } const horizon = ctx.currentTime + LOOKAHEAD; const { start, end } = loopRange(p); for (;;) { const until = seg.beat + (horizon - seg.time) / spb; if (!looping(p) || until < end) { schedule(p, scheduledTo, until, seg, looping(p) ? end : Infinity); scheduledTo = until; break; } schedule(p, scheduledTo, end, seg, end); // if the loop was moved to behind the playhead, wrap from here rather than from the past const wrap = seg.time + (Math.max(end, scheduledTo) - seg.beat) * spb; seg = { time: wrap, beat: start, spb }; segments.push(seg); scheduledTo = start; } segments = segments.filter( (_, i) => i === segments.length - 1 || segments[i + 1].time > ctx.currentTime - 1, ); } /** where each track's notes go, at its volume: changes to it are heard at once, even mid-note */ const buses = new Map(); const level = (track: Track) => 10 ** (track.gain / 20); function bus(track: Track): GainNode { let node = buses.get(track.id); if (!node) { const { ctx, out } = audio(); node = ctx.createGain(); node.gain.value = level(track); node.connect(out); buses.set(track.id, node); } return node; } project.subscribe(p => { for (const track of p.tracks) { const node = buses.get(track.id); // eased rather than stepped, which would click node?.gain.setTargetAtTime(level(track), node.context.currentTime, 0.01); } }); function schedule(p: Project, from: number, to: number, seg: Segment, cutoff: number) { const key = projectKey(p); const at = (beat: number) => seg.time + (beat - seg.beat) * seg.spb; const length = (start: number, dur: number) => (Math.min(start + dur, cutoff) - start) * seg.spb; const inWindow = (start: number) => start >= from && start < to; for (const track of p.tracks) { if (track.muted) continue; const out = bus(track); if (track.kind === "notes") { for (const n of track.notes) { if (inWindow(n.start)) playNote(n.pitch, at(n.start), length(n.start, n.dur), n.vel, out); } } else { for (const { event, notes } of voiceTrack(track, key)) { if (!inWindow(event.start)) continue; for (const n of sounding(track, notes)) playNote(n, at(event.start), length(event.start, event.dur), 0.55, out); } } } } function animate() { const now = audio().ctx.currentTime; const seg = segments.findLast(s => s.time <= now) ?? segments[0]; const beat = Math.max(seg.beat, seg.beat + (now - seg.time) / seg.spb); const p = project.get(); if (!looping(p) && beat > songBeats(p) + 1) return stop(); playhead.set(beat); frame = requestAnimationFrame(animate); } /** hear some notes now, unless the transport is already making noise */ let auditioning: Voice[] = []; /** * hear some notes now (or after `delay` seconds), unless the transport is already making noise. * like hands leaving the keys for the next chord, each audition cuts off the one before it */ export function audition(pitches: readonly number[], dur = 0.7, delay = 0): void { if (playing.get()) return; const at = audio().ctx.currentTime + delay; for (const voice of auditioning) voice.stop(at); auditioning = [ ...auditioning.filter(v => !v.done), ...pitches.map(p => playNote(p, at, dur, 0.6)), ]; }