char-slop/chordpad.vibed.software

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

Charlotte Sominitial commit3c7bc6e

main
3.2 KiB86 linesraw
1/** a note to write, timed in beats */
2export type OutNote = { start: number; dur: number; pitch: number; vel: number };
3export type OutTrack = { name: string; channel: number; program: number; notes: OutNote[] };
4export type OutSong = {
5  bpm: number;
6  beatsPerBar: number;
7  /** sharps (+) or flats (−) in the key signature, and whether it's minor */
8  key?: { accidentals: number; minor: boolean };
9  tracks: OutTrack[];
10};
11
12const PPQ = 480;
13
14const u16 = (n: number) => [(n >> 8) & 0xff, n & 0xff];
15const u32 = (n: number) => [(n >>> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
16const ascii = (s: string) => [...s].map(c => c.charCodeAt(0));
17function varInt(n: number) {
18  const out = [n & 0x7f];
19  while ((n >>>= 7)) out.unshift((n & 0x7f) | 0x80);
20  return out;
21}
22const meta = (type: number, data: number[]) => [0xff, type, ...varInt(data.length), ...data];
23
24/** events at absolute ticks, as a track chunk */
25function chunk(events: [tick: number, bytes: number[]][]): number[] {
26  let last = 0;
27  const body = events.flatMap(([tick, bytes]) => {
28    const delta = tick - last;
29    last = tick;
30    return [...varInt(delta), ...bytes];
31  });
32  body.push(0, ...meta(0x2f, []));
33  return [...ascii("MTrk"), ...u32(body.length), ...body];
34}
35
36/** a standard midi file (format 1): a conductor track, then one per track */
37export function writeMidi(song: OutSong): Uint8Array<ArrayBuffer> {
38  const tick = (beats: number) => Math.round(beats * PPQ);
39
40  const conductor: [number, number[]][] = [
41    [0, meta(0x51, u32(Math.round(60_000_000 / song.bpm)).slice(1))],
42    [0, meta(0x58, [song.beatsPerBar, 2, 24, 8])],
43  ];
44  if (song.key && Math.abs(song.key.accidentals) <= 7) {
45    conductor.push([0, meta(0x59, [song.key.accidentals & 0xff, song.key.minor ? 1 : 0])]);
46  }
47
48  const tracks = song.tracks.map(track => {
49    const status = (kind: number) => kind | (track.channel & 0x0f);
50    const events: [number, number[]][] = [
51      [0, meta(0x03, [...new TextEncoder().encode(track.name)])],
52      [0, [status(0xc0), track.program & 0x7f]],
53    ];
54
55    // a repeated pitch cuts the one before it short: overlapping on/offs of one pitch pair up
56    // differently in different software
57    const byPitch = Map.groupBy(track.notes, n => n.pitch);
58    for (const [pitch, notes] of byPitch) {
59      notes.sort((a, b) => a.start - b.start);
60      notes.forEach((n, i) => {
61        const on = tick(n.start);
62        const off = Math.min(
63          tick(n.start + n.dur),
64          notes[i + 1] ? tick(notes[i + 1].start) : Infinity,
65        );
66        if (off <= on) return;
67        const vel = Math.min(127, Math.max(1, Math.round(n.vel * 127)));
68        events.push([on, [status(0x90), pitch, vel]], [off, [status(0x80), pitch, 0]]);
69      });
70    }
71    // at any given tick: setup, then note-offs, then note-ons, so a note ending as another
72    // begins can't cut it off
73    const rank = ([status]: number[]) => ({ 0x80: 1, 0x90: 2 })[status & 0xf0] ?? 0;
74    events.sort((a, b) => a[0] - b[0] || rank(a[1]) - rank(b[1]));
75    return chunk(events);
76  });
77
78  const header = [
79    ...ascii("MThd"),
80    ...u32(6),
81    ...u16(1),
82    ...u16(tracks.length + 1),
83    ...u16(PPQ),
84  ];
85  return new Uint8Array([...header, ...chunk(conductor), ...tracks.flat()]);
86}