char-slop/chordpad.vibed.software

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

Charlotte Somcbor project file formata5e999d

main
6.5 KiB174 linesraw
1import { decodeCBOR, encodeCBOR } from "@char/cbor";
2import { unpinRedundant, voiceTrack } from "./harmony.ts";
3import type { ChordEvent, ChordTrack, NoteEvent, NoteTrack, Project } from "./model.ts";
4import { newId, projectKey } from "./model.ts";
5
6/**
7 * how projects are saved: as json in the browser, and as cbor in files. every chord keeps the
8 * notes it sounded with, pinned or not: an unpinned chord's notes are worked out from its symbol,
9 * and if the way that's done ever changes, loading notices and pins the chord to what it was,
10 * rather than it quietly sounding different
11 *
12 * there are a lot of chords and notes, so they're tuples rather than objects. times are in ticks,
13 * which every grid (and the usual midi resolutions) divide into exactly, and velocities are midi's.
14 * ids aren't kept: they only tell things apart while editing, and are made anew on loading
15 */
16export type StoredProject = Omit<Project, "tracks"> & { version: 3; tracks: StoredTrack[] };
17type StoredTrack =
18  | (Omit<ChordTrack, "id" | "chords"> & { chords: StoredChord[] })
19  | (Omit<NoteTrack, "id" | "notes"> & { notes: StoredNote[] });
20export type StoredChord = [
21  start: number,
22  dur: number,
23  symbol: string | null,
24  notes: number[],
25  pinned: boolean,
26];
27type StoredNote = [start: number, dur: number, pitch: number, vel: number];
28
29/** as projects were saved before: objects throughout, times in beats */
30type StoredProjectV2 = Omit<Project, "tracks"> & {
31  version: 2;
32  tracks: (
33    | (Omit<ChordTrack, "chords" | "gain" | "bass"> & {
34        gain?: number;
35        bass?: boolean;
36        chords: (Omit<ChordEvent, "notes"> & { notes: number[]; pinned: boolean })[];
37      })
38    | (Omit<NoteTrack, "gain"> & { gain?: number })
39  )[];
40};
41export type SavedProject = StoredProject | StoredProjectV2;
42
43const TICKS_PER_BEAT = 1920;
44const toTicks = (beats: number) => Math.round(beats * TICKS_PER_BEAT);
45const toBeats = (ticks: number) => ticks / TICKS_PER_BEAT;
46
47/** a project's own lengths (as opposed to its events'), converted between beats and ticks */
48const retimed = <P extends Pick<Project, "chordLength" | "grid" | "loopRegion">>(
49  p: P,
50  f: (n: number) => number,
51): P => ({
52  ...p,
53  chordLength: f(p.chordLength),
54  grid: f(p.grid),
55  loopRegion: p.loopRegion && { start: f(p.loopRegion.start), end: f(p.loopRegion.end) },
56});
57
58const noteTuple = (n: Omit<NoteEvent, "id">): StoredNote => [
59  toTicks(n.start),
60  toTicks(n.dur),
61  n.pitch,
62  Math.round(n.vel * 127),
63];
64const chordTuple = (
65  c: Omit<ChordEvent, "id" | "notes">,
66  notes: number[],
67  pinned: boolean,
68): StoredChord => [toTicks(c.start), toTicks(c.dur), c.symbol ?? null, notes, pinned];
69
70const sorted = (notes: readonly number[]) => [...notes].sort((a, b) => a - b);
71const sameNotes = (a: readonly number[], b: readonly number[]) =>
72  a.length === b.length && sorted(a).every((n, i) => n === sorted(b)[i]);
73
74export function toStored(p: Project): StoredProject {
75  const key = projectKey(p);
76  return {
77    ...retimed(p, toTicks),
78    version: 3,
79    tracks: p.tracks.map((track): StoredTrack => {
80      if (track.kind === "notes") {
81        const { id: _, notes, ...rest } = track;
82        return { ...rest, notes: notes.map(noteTuple) };
83      }
84      const voiced = voiceTrack(track, key);
85      const { id: _, chords, ...rest } = track;
86      return {
87        ...rest,
88        chords: chords.map((c, i) => chordTuple(c, voiced[i].notes, !!c.notes)),
89      };
90    }),
91  };
92}
93
94function fromVersion2({ version: _, tracks, ...p }: StoredProjectV2): StoredProject {
95  return {
96    ...retimed(p, toTicks),
97    version: 3,
98    // tracks from before they had a volume trim, or a bass to turn off
99    tracks: tracks.map(({ id: _, gain = 0, ...track }): StoredTrack => {
100      if (track.kind === "notes") return { ...track, gain, notes: track.notes.map(noteTuple) };
101      const chords = track.chords.map(c => chordTuple(c, c.notes, c.pinned));
102      return { ...track, gain, bass: track.bass ?? true, chords };
103    }),
104  };
105}
106
107export function fromStored(saved: SavedProject): Project {
108  const stored = saved.version === 2 ? fromVersion2(saved) : saved;
109  if (stored.version !== 3) {
110    throw new Error(`can't load a version ${stored.version} project: it's too old, or too new`);
111  }
112
113  const { version: _, ...rest } = stored;
114  const unpinned = new Map<string, number[]>();
115  const p: Project = {
116    ...retimed(rest, toBeats),
117    tracks: stored.tracks.map(track => {
118      if (track.kind === "notes") {
119        const notes = track.notes.map(([start, dur, pitch, vel]) => ({
120          id: newId(),
121          start: toBeats(start),
122          dur: toBeats(dur),
123          pitch,
124          vel: vel / 127,
125        }));
126        return { ...track, id: newId(), notes };
127      }
128      const chords = track.chords.map(([start, dur, symbol, notes, pinned]): ChordEvent => {
129        const c: ChordEvent = { id: newId(), start: toBeats(start), dur: toBeats(dur) };
130        if (symbol !== null) c.symbol = symbol;
131        if (pinned) return { ...c, notes };
132        unpinned.set(c.id, notes);
133        return c;
134      });
135      return { ...track, id: newId(), chords };
136    }),
137  };
138
139  // pin whatever would come out differently now. new tracks rather than changed ones: voicings
140  // are cached against track objects
141  const key = projectKey(p);
142  const tracks = p.tracks.map(track => {
143    if (track.kind !== "chords") return track;
144    const voiced = voiceTrack(track, key);
145    const chords = track.chords.map((c, i) => {
146      const was = unpinned.get(c.id);
147      return was && !sameNotes(was, voiced[i].notes) ? { ...c, notes: was } : c;
148    });
149    return { ...track, chords };
150  });
151  return unpinRedundant({ ...p, tracks });
152}
153
154/** a project as a file to give someone: as it's saved, but without where or when */
155export function projectToFile(p: Project): Uint8Array<ArrayBuffer> {
156  const { id: _, modified: __, ...stored } = toStored(p);
157  return encodeCBOR(stored) as Uint8Array<ArrayBuffer>;
158}
159
160/** a project from a file, as a new one: it never takes the place of one already here */
161export function projectFromFile(bytes: Uint8Array): Project {
162  let stored;
163  try {
164    // files from before projects were cbor are json, and start with its brace
165    stored =
166      bytes[0] === 0x7b ? JSON.parse(new TextDecoder().decode(bytes)) : decodeCBOR(bytes);
167  } catch {
168    throw new Error("it isn't a chordpad project");
169  }
170  if (typeof stored?.version !== "number" || !Array.isArray(stored.tracks)) {
171    throw new Error("it isn't a chordpad project");
172  }
173  return fromStored({ ...stored, id: newId(), modified: Date.now() });
174}