char-slop/chordpad.vibed.software

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

Charlotte Somcbor project file formata5e999d

main
8.7 KiB279 linesraw
1import type { Key, ModeId } from "../theory/key.ts";
2import { parseNote, pcOf } from "../theory/pitch.ts";
3
4export type NoteEvent = { id: string; start: number; dur: number; pitch: number; vel: number };
5/** how hard new notes are played: a midi velocity, as they're saved */
6export const NOTE_VEL = 96 / 127;
7
8/** a chord is what was typed, what was voiced by hand, or both. times are in beats */
9export type ChordEvent = {
10  id: string;
11  start: number;
12  dur: number;
13  symbol?: string;
14  notes?: number[];
15};
16
17type TrackBase = {
18  id: string;
19  name: string;
20  muted: boolean;
21  /** volume trim, in dB */
22  gain: number;
23  /** visible pitch range of the piano roll */
24  view: { low: number; high: number };
25};
26export type ChordTrack = TrackBase & {
27  kind: "chords";
28  chords: ChordEvent[];
29  /** whether each chord's lowest note sounds: off when another track carries the bass */
30  bass: boolean;
31};
32export type NoteTrack = TrackBase & { kind: "notes"; notes: NoteEvent[] };
33export type Track = ChordTrack | NoteTrack;
34
35export type Project = {
36  id: string;
37  name: string;
38  modified: number;
39  bpm: number;
40  beatsPerBar: number;
41  key: { tonic: string; mode: ModeId };
42  /** default length of newly typed chords, in beats */
43  chordLength: number;
44  /** note grid, in beats */
45  grid: number;
46  loop: boolean;
47  /** what gets looped, in beats; the whole song when absent */
48  loopRegion?: { start: number; end: number };
49  tracks: Track[];
50};
51
52// not randomUUID: that only exists in secure contexts, and this may be served over plain http
53export const newId = (): string =>
54  Array.from(crypto.getRandomValues(new Uint8Array(4)), b =>
55    b.toString(16).padStart(2, "0"),
56  ).join("");
57
58export const newChordTrack = (name = "chords"): ChordTrack => ({
59  id: newId(),
60  name,
61  kind: "chords",
62  muted: false,
63  gain: 0,
64  bass: true,
65  view: { low: 38, high: 77 },
66  chords: [],
67});
68
69export const newNoteTrack = (name = "melody"): NoteTrack => ({
70  id: newId(),
71  name,
72  kind: "notes",
73  muted: false,
74  gain: 0,
75  view: { low: 57, high: 88 },
76  notes: [],
77});
78
79export const UNTITLED = "untitled";
80
81type Timed = { start: number; dur: number };
82const events = (t: Track): readonly Timed[] => (t.kind === "chords" ? t.chords : t.notes);
83
84/** nothing worth keeping: never named, and nothing written in it */
85export const isBlank = (p: Project): boolean =>
86  p.name === UNTITLED && p.tracks.every(t => events(t).length === 0);
87
88export const newProject = (): Project => ({
89  id: newId(),
90  name: UNTITLED,
91  modified: Date.now(),
92  bpm: 96,
93  beatsPerBar: 4,
94  key: { tonic: "C", mode: "ionian" },
95  chordLength: 1,
96  grid: 0.25,
97  loop: true,
98  tracks: [newChordTrack(), newNoteTrack()],
99});
100
101/** the tonics the key picker offers */
102export const TONICS = [
103  "C",
104  "C#",
105  "Db",
106  "D",
107  "D#",
108  "Eb",
109  "E",
110  "F",
111  "F#",
112  "Gb",
113  "G",
114  "G#",
115  "Ab",
116  "A",
117  "A#",
118  "Bb",
119  "B",
120];
121
122/** a tonic as the key picker spells it (D♯ → E♭, C♭ → B) */
123export function pickableTonic(tonic: string): string {
124  const note = parseNote(tonic)?.note;
125  if (!note || TONICS.includes(tonic)) return note ? tonic : "C";
126  return TONICS.find(t => pcOf(parseNote(t)!.note) === pcOf(note))!;
127}
128
129/** a piano roll range that fits these pitches, at least two and a half octaves tall */
130export function fitView(pitches: readonly number[]): { low: number; high: number } {
131  const [lo, hi] = [Math.min(...pitches), Math.max(...pitches)];
132  const span = Math.max(31, hi - lo + 6);
133  const low = Math.min(127 - span, Math.max(0, Math.round((lo + hi - span) / 2)));
134  return { low, high: low + span };
135}
136
137/** a key as projects store it, for working with */
138export const keyOf = (key: Project["key"]): Key => ({
139  tonic: parseNote(key.tonic)!.note,
140  mode: key.mode,
141});
142
143export const projectKey = (p: Project): Key => keyOf(p.key);
144
145export const projectEnd = (p: Project): number =>
146  Math.max(0, ...p.tracks.flatMap(t => events(t).map(e => e.start + e.dur)));
147
148export function findTrack<K extends Track["kind"]>(
149  p: Project,
150  id: string,
151  kind: K,
152): Extract<Track, { kind: K }> | undefined {
153  const t = p.tracks.find(t => t.id === id);
154  return t?.kind === kind ? (t as Extract<Track, { kind: K }>) : undefined;
155}
156
157/**
158 * chord boundaries are compared exactly, but sums of triplet positions drift in floating point
159 * (4/3 + 1/3 falls just short of 5/3). rounding every boundary onto a fixed grid that holds both
160 * 64ths and triplets keeps equal positions equal
161 */
162export const canonBeat = (beats: number): number => Math.round(beats * 192) / 192;
163
164/** put a chord in place, cutting away whatever it overlaps */
165export function placeChord(track: ChordTrack, placed: ChordEvent): void {
166  const chord = { ...placed, start: canonBeat(placed.start), dur: canonBeat(placed.dur) };
167  const end = canonBeat(chord.start + chord.dur);
168  track.chords = track.chords.flatMap(c => {
169    if (c.id === chord.id) return [];
170    const cEnd = canonBeat(c.start + c.dur);
171    if (cEnd <= chord.start || c.start >= end) return [c];
172    const kept: ChordEvent[] = [];
173    if (c.start < chord.start) kept.push({ ...c, dur: canonBeat(chord.start - c.start) });
174    if (cEnd > end) {
175      const id = kept.length ? newId() : c.id;
176      kept.push({ ...c, id, start: end, dur: canonBeat(cEnd - end) });
177    }
178    return kept;
179  });
180  track.chords.push(chord);
181  track.chords.sort((a, b) => a.start - b.start);
182}
183
184/**
185 * put chords just before a target chord, to lead into it: in the back half of the chord before
186 * it, so the target still lands where it was; or, with nothing right before, in the target's own
187 * front half. false if there isn't room for them on the grid
188 */
189export function insertLeadIn(
190  track: ChordTrack,
191  targetId: string,
192  symbols: readonly string[],
193  grid: number,
194): boolean {
195  const target = track.chords.find(c => c.id === targetId);
196  if (!target) return false;
197  const before = track.chords.find(c => canonBeat(c.start + c.dur) === target.start);
198  const half = (dur: number) => Math.floor(dur / 2 / grid) * grid;
199  const needed = symbols.length * grid;
200
201  let start: number;
202  let span: number;
203  if (before && half(before.dur) >= needed) {
204    span = half(before.dur);
205    before.dur = canonBeat(before.dur - span);
206    start = canonBeat(target.start - span);
207  } else if (half(target.dur) >= needed) {
208    span = half(target.dur);
209    start = target.start;
210    target.start = canonBeat(target.start + span);
211    target.dur = canonBeat(target.dur - span);
212  } else return false;
213
214  const each = span / symbols.length;
215  symbols.forEach((symbol, i) => {
216    const at = canonBeat(start + i * each);
217    track.chords.push({
218      id: newId(),
219      start: at,
220      dur: canonBeat(start + (i + 1) * each) - at,
221      symbol,
222    });
223  });
224  track.chords.sort((a, b) => a.start - b.start);
225  return true;
226}
227
228/** chords taken out of a track, their starts relative to the first */
229export type ChordClip = { chords: Omit<ChordEvent, "id">[]; span: number };
230
231export function copyChords(track: ChordTrack, ids: readonly string[]): ChordClip | undefined {
232  const chosen = track.chords.filter(c => ids.includes(c.id));
233  if (!chosen.length) return undefined;
234  const first = chosen[0].start;
235  return {
236    chords: chosen.map(({ id: _, ...c }) => ({ ...c, start: canonBeat(c.start - first) })),
237    span: canonBeat(Math.max(...chosen.map(c => c.start + c.dur)) - first),
238  };
239}
240
241/** put copied chords in at a beat, over whatever's there; gives the new chords' ids */
242export function pasteChords(track: ChordTrack, clip: ChordClip, at: number): string[] {
243  return clip.chords.map(c => {
244    const id = newId();
245    placeChord(track, { ...structuredClone(c), id, start: at + c.start });
246    return id;
247  });
248}
249
250/** notes taken out of a track, their starts relative to the first */
251export type NoteClip = { notes: Omit<NoteEvent, "id">[]; span: number };
252
253export function copyNotes(track: NoteTrack, ids: readonly string[]): NoteClip | undefined {
254  const chosen = track.notes.filter(n => ids.includes(n.id));
255  if (!chosen.length) return undefined;
256  const first = Math.min(...chosen.map(n => n.start));
257  return {
258    notes: chosen
259      .map(({ id: _, ...n }) => ({ ...n, start: n.start - first }))
260      .sort((a, b) => a.start - b.start || a.pitch - b.pitch),
261    span: Math.max(...chosen.map(n => n.start + n.dur)) - first,
262  };
263}
264
265/** add copied notes at a beat, alongside what's there; gives the new notes' ids */
266export function pasteNotes(track: NoteTrack, clip: NoteClip, at: number): string[] {
267  return clip.notes.map(n => {
268    const id = newId();
269    track.notes.push({ ...n, id, start: at + n.start });
270    return id;
271  });
272}
273
274/** move a track to before the one at `to` (or to the end, at the track count) */
275export function moveTrack(p: Project, id: string, to: number): void {
276  const from = p.tracks.findIndex(t => t.id === id);
277  const [track] = p.tracks.splice(from, 1);
278  p.tracks.splice(from < to ? to - 1 : to, 0, track);
279}