char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Somtrack reordering + chord bass togglingee3b50a

main
5.2 KiB158 linesraw
1import { Signal } from "@char/aftercare";
2import { sounding, voiceTrack } from "../project/harmony.ts";
3import { type Project, projectEnd, projectKey, type Track } from "../project/model.ts";
4import { project } from "../project/store.ts";
5import { audio, playNote, silence, type Voice } from "./synth.ts";
6
7export const playing = new Signal(false);
8/** the beat currently sounding, while playing */
9export const playhead = new Signal<number | null>(null);
10
11const LOOKAHEAD = 0.15;
12
13/** a stretch of the timeline played at a fixed tempo, starting at `time` (audio clock) */
14type Segment = { time: number; beat: number; spb: number };
15let segments: Segment[] = [];
16let scheduledTo = 0;
17let timer: ReturnType<typeof setInterval> | undefined;
18/** set when playback starts past the loop: it then plays on to the end, as if loop were off */
19let pastLoop = false;
20const looping = (p: Project) => p.loop && !pastLoop;
21let frame: number | undefined;
22
23const songBeats = (p: Project): number => {
24  const bar = p.beatsPerBar;
25  return Math.max(bar, Math.ceil(projectEnd(p) / bar) * bar);
26};
27
28export const loopRange = (p: Project): { start: number; end: number } =>
29  p.loopRegion ?? { start: 0, end: songBeats(p) };
30
31export function play(at: number): void {
32  stop();
33  const { ctx } = audio();
34  const p = project.get();
35  pastLoop = at >= loopRange(p).end;
36  segments = [{ time: ctx.currentTime + 0.05, beat: at, spb: 60 / p.bpm }];
37  scheduledTo = at;
38  playing.set(true);
39  timer = setInterval(tick, 25);
40  tick();
41  frame = requestAnimationFrame(animate);
42}
43
44export function stop(): void {
45  if (!playing.get()) return;
46  clearInterval(timer);
47  if (frame !== undefined) cancelAnimationFrame(frame);
48  silence();
49  playing.set(false);
50  playhead.set(null);
51}
52
53function tick() {
54  const p = project.get();
55  const { ctx } = audio();
56  const spb = 60 / p.bpm;
57  let seg = segments.at(-1)!;
58  if (seg.spb !== spb) {
59    seg = { time: seg.time + (scheduledTo - seg.beat) * seg.spb, beat: scheduledTo, spb };
60    segments.push(seg);
61  }
62
63  const horizon = ctx.currentTime + LOOKAHEAD;
64  const { start, end } = loopRange(p);
65  for (;;) {
66    const until = seg.beat + (horizon - seg.time) / spb;
67    if (!looping(p) || until < end) {
68      schedule(p, scheduledTo, until, seg, looping(p) ? end : Infinity);
69      scheduledTo = until;
70      break;
71    }
72    schedule(p, scheduledTo, end, seg, end);
73    // if the loop was moved to behind the playhead, wrap from here rather than from the past
74    const wrap = seg.time + (Math.max(end, scheduledTo) - seg.beat) * spb;
75    seg = { time: wrap, beat: start, spb };
76    segments.push(seg);
77    scheduledTo = start;
78  }
79  segments = segments.filter(
80    (_, i) => i === segments.length - 1 || segments[i + 1].time > ctx.currentTime - 1,
81  );
82}
83
84/** where each track's notes go, at its volume: changes to it are heard at once, even mid-note */
85const buses = new Map<string, GainNode>();
86const level = (track: Track) => 10 ** (track.gain / 20);
87
88function bus(track: Track): GainNode {
89  let node = buses.get(track.id);
90  if (!node) {
91    const { ctx, out } = audio();
92    node = ctx.createGain();
93    node.gain.value = level(track);
94    node.connect(out);
95    buses.set(track.id, node);
96  }
97  return node;
98}
99
100project.subscribe(p => {
101  for (const track of p.tracks) {
102    const node = buses.get(track.id);
103    // eased rather than stepped, which would click
104    node?.gain.setTargetAtTime(level(track), node.context.currentTime, 0.01);
105  }
106});
107
108function schedule(p: Project, from: number, to: number, seg: Segment, cutoff: number) {
109  const key = projectKey(p);
110  const at = (beat: number) => seg.time + (beat - seg.beat) * seg.spb;
111  const length = (start: number, dur: number) =>
112    (Math.min(start + dur, cutoff) - start) * seg.spb;
113  const inWindow = (start: number) => start >= from && start < to;
114
115  for (const track of p.tracks) {
116    if (track.muted) continue;
117    const out = bus(track);
118    if (track.kind === "notes") {
119      for (const n of track.notes) {
120        if (inWindow(n.start))
121          playNote(n.pitch, at(n.start), length(n.start, n.dur), n.vel, out);
122      }
123    } else {
124      for (const { event, notes } of voiceTrack(track, key)) {
125        if (!inWindow(event.start)) continue;
126        for (const n of sounding(track, notes))
127          playNote(n, at(event.start), length(event.start, event.dur), 0.55, out);
128      }
129    }
130  }
131}
132
133function animate() {
134  const now = audio().ctx.currentTime;
135  const seg = segments.findLast(s => s.time <= now) ?? segments[0];
136  const beat = Math.max(seg.beat, seg.beat + (now - seg.time) / seg.spb);
137  const p = project.get();
138  if (!looping(p) && beat > songBeats(p) + 1) return stop();
139  playhead.set(beat);
140  frame = requestAnimationFrame(animate);
141}
142
143/** hear some notes now, unless the transport is already making noise */
144let auditioning: Voice[] = [];
145
146/**
147 * hear some notes now (or after `delay` seconds), unless the transport is already making noise.
148 * like hands leaving the keys for the next chord, each audition cuts off the one before it
149 */
150export function audition(pitches: readonly number[], dur = 0.7, delay = 0): void {
151  if (playing.get()) return;
152  const at = audio().ctx.currentTime + delay;
153  for (const voice of auditioning) voice.stop(at);
154  auditioning = [
155    ...auditioning.filter(v => !v.done),
156    ...pitches.map(p => playNote(p, at, dur, 0.6)),
157  ];
158}