char-slop/chordpad.vibed.software

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

Charlotte Somcbor project file formata5e999d

main
6.2 KiB210 linesraw
1import { assertEquals, assertNotEquals, assertThrows } from "@std/assert";
2import { encodeCBOR } from "@char/cbor";
3import { voiceTrack } from "./harmony.ts";
4import {
5  type ChordTrack,
6  newChordTrack,
7  newNoteTrack,
8  newProject,
9  NOTE_VEL,
10  placeChord,
11  type Project,
12  projectKey,
13} from "./model.ts";
14import {
15  fromStored,
16  projectFromFile,
17  projectToFile,
18  type SavedProject,
19  type StoredChord,
20  type StoredProject,
21  toStored,
22} from "./persist.ts";
23
24function sketch(): Project {
25  const p = newProject();
26  p.grid = 1 / 3;
27  p.loopRegion = { start: 2 / 3, end: 8 };
28  const chords = newChordTrack();
29  for (const [start, dur, symbol] of [
30    [0, 4, "C"],
31    [4, 4 / 3, "bVI"],
32    [16 / 3, 8 / 3, "iv"],
33    [8, 4, "V7"],
34  ] as const) {
35    placeChord(chords, { id: symbol, start, dur, symbol });
36  }
37  chords.chords[2].notes = [41, 56, 60, 65]; // iv, hand-voiced
38  chords.bass = false;
39  const melody = newNoteTrack();
40  melody.gain = -4.5;
41  melody.notes.push(
42    { id: "n", start: 0, dur: 1, pitch: 72, vel: NOTE_VEL },
43    { id: "m", start: 1 / 3, dur: 1 / 6, pitch: 74, vel: 101 / 127 },
44  );
45  p.tracks = [chords, melody];
46  return p;
47}
48const chordsOf = (p: Project) => (p.tracks[0] as ChordTrack).chords;
49const storedChords = (s: StoredProject) =>
50  s.tracks[0].kind === "chords" ? s.tracks[0].chords : [];
51const sounding = (p: Project) =>
52  voiceTrack(p.tracks[0] as ChordTrack, projectKey(p)).map(v => v.notes);
53// ids only tell things apart while editing, so they're made anew on loading
54const withoutIds = (p: Project) =>
55  JSON.parse(JSON.stringify(p, (k, v) => (k === "id" ? undefined : v)));
56
57Deno.test("saved projects keep every chord's voicing, and which are pinned", () => {
58  const p = sketch();
59  const stored = toStored(p);
60  assertEquals(
61    storedChords(stored).map(c => c[4]),
62    [false, false, true, false],
63  );
64  assertEquals(
65    storedChords(stored).map(c => c[3]),
66    sounding(p),
67  );
68});
69
70Deno.test("a project comes back as it was saved, triplets and all", () => {
71  const p = sketch();
72  const loaded = fromStored(JSON.parse(JSON.stringify(toStored(p))));
73  assertEquals(withoutIds(loaded), withoutIds(p));
74  assertEquals(sounding(loaded), sounding(p));
75});
76
77Deno.test("a chord that would now be voiced differently comes back pinned as it was", () => {
78  const stored = toStored(sketch());
79  // as if the voicing of ♭VI had changed since it was saved
80  (storedChords(stored)[1] as StoredChord)[3] = [44, 72, 75, 80];
81  const p = fromStored(stored);
82  assertEquals(
83    chordsOf(p).map(c => c.notes !== undefined),
84    [false, true, true, false],
85  );
86  const { id: _, ...flat } = chordsOf(p)[1];
87  assertEquals(flat, { start: 4, dur: 4 / 3, symbol: "bVI", notes: [44, 72, 75, 80] });
88  // and it's what plays, now and after (voicings are cached)
89  assertEquals(sounding(p)[1], [44, 72, 75, 80]);
90});
91
92/** a project as it was saved before events were tuples */
93function savedAsVersion2(): SavedProject {
94  const c = voiceTrack(
95    { ...newChordTrack(), chords: [{ id: "c", start: 0, dur: 4, symbol: "C" }] },
96    projectKey(newProject()),
97  )[0].notes;
98  return {
99    version: 2,
100    id: "old",
101    name: "from before",
102    modified: 1,
103    bpm: 96,
104    beatsPerBar: 4,
105    key: { tonic: "C", mode: "ionian" },
106    chordLength: 4,
107    grid: 0.5,
108    loop: true,
109    // and before tracks had a volume trim, or a bass to turn off
110    tracks: [
111      {
112        id: "t",
113        name: "chords",
114        kind: "chords",
115        muted: false,
116        view: { low: 38, high: 77 },
117        chords: [
118          { id: "c", start: 0, dur: 4, symbol: "C", notes: c, pinned: false },
119          { id: "p", start: 4, dur: 4, notes: [41, 56, 60, 65], pinned: true },
120        ],
121      },
122      {
123        id: "m",
124        name: "melody",
125        kind: "notes",
126        muted: true,
127        view: { low: 55, high: 88 },
128        notes: [{ id: "n", start: 1.5, dur: 0.5, pitch: 72, vel: 0.8 }],
129      },
130    ],
131  } as SavedProject;
132}
133
134Deno.test("projects saved with objects rather than tuples still load", () => {
135  const p = fromStored(savedAsVersion2());
136  assertEquals(withoutIds(p), {
137    name: "from before",
138    modified: 1,
139    bpm: 96,
140    beatsPerBar: 4,
141    key: { tonic: "C", mode: "ionian" },
142    chordLength: 4,
143    grid: 0.5,
144    loop: true,
145    tracks: [
146      {
147        name: "chords",
148        kind: "chords",
149        muted: false,
150        gain: 0,
151        bass: true,
152        view: { low: 38, high: 77 },
153        chords: [
154          { start: 0, dur: 4, symbol: "C" },
155          { start: 4, dur: 4, notes: [41, 56, 60, 65] },
156        ],
157      },
158      {
159        name: "melody",
160        kind: "notes",
161        muted: true,
162        gain: 0,
163        view: { low: 55, high: 88 },
164        // the nearest midi velocity
165        notes: [{ start: 1.5, dur: 0.5, pitch: 72, vel: 102 / 127 }],
166      },
167    ],
168  });
169  assertEquals(p.id, "old");
170});
171
172Deno.test("projects from before voicings were kept aren't loaded", () => {
173  const legacy = { ...toStored(sketch()), version: 1 } as unknown as StoredProject;
174  assertThrows(() => fromStored(legacy), Error, "version 1");
175});
176
177Deno.test("a project file loads as a new project, the same as the one it came from", () => {
178  const p = { ...sketch(), name: "for a friend", bpm: 132 };
179  const loaded = projectFromFile(projectToFile(p));
180  // so loading it never takes the place of a project already here, even the one it came from
181  assertNotEquals(loaded.id, p.id);
182  assertEquals(withoutIds({ ...loaded, modified: 0 }), withoutIds({ ...p, modified: 0 }));
183  assertEquals(sounding(loaded), sounding(p));
184
185  const bytes = (s: string) => new TextEncoder().encode(s);
186  assertThrows(
187    () => projectFromFile(bytes("not a project")),
188    Error,
189    "isn't a chordpad project",
190  );
191  assertThrows(
192    () => projectFromFile(encodeCBOR({ hello: 1 })),
193    Error,
194    "isn't a chordpad project",
195  );
196  assertThrows(
197    () => projectFromFile(encodeCBOR({ ...toStored(p), version: 9 })),
198    Error,
199    "version 9",
200  );
201});
202
203Deno.test("project files from when they were json still open", () => {
204  const file = new TextEncoder().encode(JSON.stringify(savedAsVersion2()));
205  const opened = projectFromFile(file);
206  assertEquals(
207    withoutIds({ ...opened, modified: 0 }),
208    withoutIds({ ...fromStored(savedAsVersion2()), modified: 0 }),
209  );
210});