char-slop/chordpad.vibed.software

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

Charlotte Somallow midi import as chords040fec5

main
14.7 KiB409 linesraw
1import { Signal } from "@char/aftercare";
2import { audition, play, playing, stop } from "../audio/transport.ts";
3import { fetchTheoryTab, theoryTabPath, theoryTabProject } from "../import/theorytab.ts";
4import { sounding, voiceTrack } from "../project/harmony.ts";
5import {
6  type ChordTrack,
7  type ChordClip,
8  findTrack,
9  fitView,
10  insertLeadIn,
11  type NoteClip,
12  pasteChords,
13  pasteNotes,
14  newChordTrack,
15  newId,
16  newNoteTrack,
17  canonBeat,
18  placeChord,
19  type Project,
20  type Track,
21  projectKey,
22} from "../project/model.ts";
23import { commit, openNewProject, project } from "../project/store.ts";
24import {
25  type Chord,
26  chordMatches,
27  chordSymbol,
28  isKeyRelative,
29  parseChord,
30} from "../theory/chord.ts";
31import { romanOf } from "../theory/analysis.ts";
32import { type Key, stepInScale } from "../theory/key.ts";
33import { voiceChord } from "../theory/voicing.ts";
34import { projectMidi } from "../midi/export.ts";
35import { parseMidi } from "../midi/smf.ts";
36import { chordsOf } from "../midi/chords.ts";
37import { activeTrackId, cursor, selectedIds, selection } from "./state.ts";
38import { chooseSections } from "./theorytab-dialog.tsx";
39import { choosePartKinds } from "./midi-dialog.tsx";
40import { saveFile } from "./dom.tsx";
41import { projectFromFile, projectToFile } from "../project/persist.ts";
42
43export const togglePlay = (): void => (playing.get() ? stop() : play(cursor.get()));
44
45const chordTrack = (p: Project, id: string) => findTrack(p, id, "chords");
46
47const voiced = (trackId: string, id: string) => {
48  const p = project.get();
49  const track = chordTrack(p, trackId);
50  return track && voiceTrack(track, projectKey(p)).find(v => v.event.id === id);
51};
52
53/** hear some of a chord track's notes as the track would play them */
54const auditionIn = (trackId: string, notes: readonly number[], dur: number, delay = 0) => {
55  const track = chordTrack(project.get(), trackId);
56  audition(track ? sounding(track, notes) : notes, dur, delay);
57};
58
59const auditionChord = (trackId: string, id: string) =>
60  auditionIn(trackId, voiced(trackId, id)?.notes ?? [], 0.9);
61
62/** length of a new chord at `start`: the default, unless it'd run into the next chord */
63function newChordLength(p: Project, track: ChordTrack, start: number) {
64  const next = track.chords.find(c => c.start > start)?.start ?? Infinity;
65  return Math.min(p.chordLength, next - start);
66}
67
68/** write a chord symbol at the cursor, replacing the chord there, then step past it */
69export function writeChord(trackId: string, symbol: string, { quiet = false } = {}): void {
70  const p = project.get();
71  const track = chordTrack(p, trackId);
72  if (!track) return;
73  const start = cursor.get();
74  const existing = track.chords.find(c => c.start === start);
75  const dur = existing?.dur ?? newChordLength(p, track, start);
76  const id = existing?.id ?? newId();
77  commit(d => placeChord(chordTrack(d, trackId)!, { id, start, dur, symbol }));
78  cursor.set(start + dur);
79  if (!quiet) auditionChord(trackId, id);
80}
81
82/** the chord track the palette writes to: the active one, or else the first */
83export function paletteTrack(p: Project): ChordTrack | undefined {
84  const active = p.tracks.find(t => t.id === activeTrackId.get());
85  return active?.kind === "chords" ? active : p.tracks.find(t => t.kind === "chords");
86}
87
88/**
89 * where to write a chord that follows on from the current one: just after the chord at the
90 * cursor, if nothing's there yet; or else at the cursor, as the palette does
91 */
92export function nextChordAt(): number {
93  const track = paletteTrack(project.get());
94  const here = track?.chords.find(c => c.start === cursor.get());
95  if (!here) return cursor.get();
96  const end = canonBeat(here.start + here.dur);
97  return track!.chords.some(c => c.start === end) ? here.start : end;
98}
99
100/** hear a chord as it would sound written at a beat, led on from the chord before it */
101export function previewChord(symbol: string, at = cursor.get()): void {
102  const p = project.get();
103  const key = projectKey(p);
104  const chord = parseChord(symbol, key);
105  if (!chord) return;
106  const track = paletteTrack(p);
107  const before = track && voiceTrack(track, key).findLast(v => v.event.start < at);
108  const notes = voiceChord(chord, before?.notes);
109  audition(track ? sounding(track, notes) : notes, 0.9);
110}
111
112export function writeChordFromPalette(symbol: string, at = cursor.get()): void {
113  cursor.set(at);
114  let track = paletteTrack(project.get());
115  if (!track) {
116    track = newChordTrack();
117    const added = track;
118    commit(d => void d.tracks.unshift(added));
119  }
120  activeTrackId.set(track.id);
121  // quiet: double-clicking to get here already previewed it
122  writeChord(track.id, symbol, { quiet: true });
123}
124
125export function createChordWithNote(trackId: string, beat: number, pitch: number): void {
126  const p = project.get();
127  const track = chordTrack(p, trackId);
128  if (!track) return;
129  const start = Math.floor(beat / p.grid) * p.grid;
130  const chord = { id: newId(), start, dur: newChordLength(p, track, start), notes: [pitch] };
131  commit(d => placeChord(chordTrack(d, trackId)!, chord));
132  cursor.set(start);
133  audition([pitch]);
134}
135
136/** hand-voice a chord; its symbol is kept only while it still describes the notes */
137function setChordNotes(track: ChordTrack, id: string, notes: number[], key: Key) {
138  const c = track.chords.find(c => c.id === id);
139  if (!c) return;
140  if (!notes.length) {
141    track.chords = track.chords.filter(x => x !== c);
142    return;
143  }
144  c.notes = [...new Set(notes)].sort((a, b) => a - b);
145  const typed = c.symbol !== undefined ? parseChord(c.symbol, key) : undefined;
146  if (!(typed && chordMatches(typed, c.notes))) delete c.symbol;
147}
148
149export function revoice(
150  trackId: string,
151  id: string,
152  change: (notes: number[]) => number[],
153): void {
154  const v = voiced(trackId, id);
155  if (!v) return;
156  const notes = change(v.notes).filter(n => n >= 0 && n < 128);
157  const key = projectKey(project.get());
158  commit(d => setChordNotes(chordTrack(d, trackId)!, id, notes, key));
159  auditionIn(trackId, notes, 0.9);
160}
161
162export const toggleChordNote = (trackId: string, id: string, pitch: number): void =>
163  revoice(trackId, id, notes =>
164    notes.includes(pitch) ? notes.filter(n => n !== pitch) : [...notes, pitch],
165  );
166
167/** throw away a chord's hand-set notes, so it's voiced from its neighbours again */
168export function unpin(trackId: string, id: string): void {
169  const v = voiced(trackId, id);
170  if (!v?.pinned) return;
171  const symbol = v.event.symbol ?? (v.chord && chordSymbol(v.chord));
172  if (symbol === undefined) return;
173  commit(d => {
174    const c = chordTrack(d, trackId)!.chords.find(c => c.id === id)!;
175    c.symbol = symbol;
176    delete c.notes;
177  });
178  auditionChord(trackId, id);
179}
180
181export function resizeChord(trackId: string, id: string, delta: number): void {
182  commit(d => {
183    const track = chordTrack(d, trackId)!;
184    const c = track.chords.find(c => c.id === id);
185    if (c) placeChord(track, { ...c, dur: Math.max(d.grid, c.dur + delta) });
186  });
187}
188
189/** step the cursor to the neighbouring chord boundary */
190export function moveChordCursor(track: ChordTrack, direction: 1 | -1): void {
191  const at = cursor.get();
192  const step = project.get().chordLength;
193  const bounds = [
194    ...new Set([0, ...track.chords.flatMap(c => [c.start, canonBeat(c.start + c.dur)])]),
195  ].sort((a, b) => a - b);
196  cursor.set(
197    direction > 0
198      ? (bounds.find(b => b > at) ?? at + step)
199      : (bounds.findLast(b => b < at) ?? Math.max(0, at - step)),
200  );
201  const here = track.chords.find(c => c.start === cursor.get());
202  selection.set(here ? { trackId: track.id, ids: [here.id] } : null);
203}
204
205export function deleteNotes(trackId: string, ids: readonly string[]): void {
206  commit(d => {
207    const track = findTrack(d, trackId, "notes")!;
208    track.notes = track.notes.filter(n => !ids.includes(n.id));
209  });
210  selection.set(null);
211}
212
213export function nudgeNotes(
214  trackId: string,
215  ids: readonly string[],
216  by: { beats?: number; pitch?: number; degrees?: number; dur?: number },
217): void {
218  const p = project.get();
219  const key = projectKey(p);
220  const pitched = (pitch: number) =>
221    (by.degrees ? stepInScale(pitch, key, by.degrees) : pitch) + (by.pitch ?? 0);
222  commit(d => {
223    for (const n of findTrack(d, trackId, "notes")!.notes) {
224      if (!ids.includes(n.id)) continue;
225      n.start = Math.max(0, n.start + (by.beats ?? 0));
226      n.pitch = Math.min(127, Math.max(0, pitched(n.pitch)));
227      n.dur = Math.max(p.grid, n.dur + (by.dur ?? 0));
228    }
229  });
230  if (by.pitch || by.degrees) {
231    const track = findTrack(project.get(), trackId, "notes")!;
232    audition(
233      track.notes.filter(n => ids.includes(n.id)).map(n => n.pitch),
234      0.4,
235    );
236  }
237}
238
239export function addTrack(kind: "chords" | "notes"): void {
240  const track = kind === "chords" ? newChordTrack() : newNoteTrack();
241  commit(d => void d.tracks.push(track));
242  activeTrackId.set(track.id);
243}
244
245export function removeTrack(id: string): void {
246  commit(d => void (d.tracks = d.tracks.filter(t => t.id !== id)));
247  if (activeTrackId.get() === id) activeTrackId.set(project.get().tracks[0]?.id ?? null);
248}
249
250/** add every part of a midi file as a track of chords or of melody, starting from beat 0 */
251export async function importMidi(file: File): Promise<void> {
252  let parts;
253  try {
254    parts = parseMidi(new Uint8Array(await file.arrayBuffer()));
255  } catch (e) {
256    alert(`couldn't import ${file.name}: ${(e as Error).message}`);
257    return;
258  }
259  if (!parts.length) {
260    alert(`${file.name} has no (non-drum) notes in it`);
261    return;
262  }
263
264  const kinds = await choosePartKinds(file.name, parts);
265  if (!kinds) return;
266
267  const base = file.name.replace(/\.midi?$/i, "");
268  const tracks = parts.map((part, i): Track => {
269    const name = parts.length > 1 ? `${base}: ${part.name}` : base;
270    const view = fitView(part.notes.map(n => n.pitch));
271    if (kinds[i] === "chords") {
272      const chords = chordsOf(part.notes).map(c => ({ id: newId(), ...c }));
273      return { ...newChordTrack(name), view, chords };
274    }
275    return { ...newNoteTrack(name), view, notes: part.notes.map(n => ({ id: newId(), ...n })) };
276  });
277  commit(d => void d.tracks.push(...tracks));
278  activeTrackId.set(tracks[0].id);
279  // the header, not the lane: it's pinned to the left, so this won't scroll sideways
280  requestAnimationFrame(() =>
281    document
282      .querySelector(`.track-head[data-track="${tracks[0].id}"]`)
283      ?.scrollIntoView({ block: "nearest" }),
284  );
285}
286
287export const importingTheoryTab = new Signal(false);
288
289/** start a new project from a TheoryTab: every section's chords and melody */
290export async function importTheoryTab(url: string): Promise<void> {
291  const path = theoryTabPath(url);
292  if (!path) {
293    alert("that doesn't look like a TheoryTab link (hooktheory.com/theorytab/view/…)");
294    return;
295  }
296  if (importingTheoryTab.get()) return;
297  importingTheoryTab.set(true);
298  let tab;
299  try {
300    tab = await fetchTheoryTab(path);
301  } catch (e) {
302    alert(`couldn't import that TheoryTab: ${(e as Error).message}`);
303    return;
304  } finally {
305    importingTheoryTab.set(false);
306  }
307
308  const sections = tab.sections.length > 1 ? await chooseSections(tab) : tab.sections;
309  if (!sections) return;
310  const part = sections.length === 1 && tab.sections.length > 1 ? ` (${sections[0].name})` : "";
311  try {
312    openNewProject(theoryTabProject({ title: tab.title + part, sections }));
313  } catch (e) {
314    alert(`couldn't import that TheoryTab: ${(e as Error).message}`);
315  }
316}
317
318/** a new chord written the way `like` was: roman if it was, so it keeps following the key */
319const writtenLike = (like: string | undefined, chord: Chord, key: Key) =>
320  like !== undefined && isKeyRelative(like) ? romanOf(chord, key) : chordSymbol(chord);
321
322/** replace a chord with a variation of it, auto-voiced */
323export function transformChord(
324  trackId: string,
325  id: string,
326  change: (chord: Chord, key: Key) => Chord | undefined,
327): void {
328  const v = voiced(trackId, id);
329  const key = projectKey(project.get());
330  const next = v?.chord && change(v.chord, key);
331  if (!next) return;
332  const symbol = writtenLike(v.event.symbol, next, key);
333  commit(d => {
334    const c = chordTrack(d, trackId)!.chords.find(c => c.id === id)!;
335    c.symbol = symbol;
336    delete c.notes;
337  });
338  auditionChord(trackId, id);
339}
340
341/** put chords before this one that lead into it, then play them into it */
342export function leadInto(trackId: string, id: string, chords: Chord[]): void {
343  const v = voiced(trackId, id);
344  const p = project.get();
345  const key = projectKey(p);
346  if (!v) return;
347  const symbols = chords.map(c => writtenLike(v.event.symbol, c, key));
348  if (!insertLeadIn(structuredClone(chordTrack(p, trackId)!), id, symbols, p.grid)) return;
349  commit(d => void insertLeadIn(chordTrack(d, trackId)!, id, symbols, p.grid));
350  const after = voiceTrack(chordTrack(project.get(), trackId)!, key);
351  const target = after.findIndex(c => c.event.id === id);
352  const sequence = after.slice(target - chords.length, target + 1);
353  sequence.forEach((c, i) => auditionIn(trackId, c.notes, 0.5, i * 0.45));
354  cursor.set(after[target].event.start);
355}
356
357/** download the project as a midi file */
358export function exportMidi(): void {
359  const p = project.get();
360  saveFile(p.name, ".mid", projectMidi(p), "audio/midi");
361}
362
363export function exportProject(): void {
364  const p = project.get();
365  saveFile(p.name, ".chordpad", projectToFile(p), "application/cbor");
366}
367
368export async function importProject(file: File): Promise<void> {
369  try {
370    openNewProject(projectFromFile(new Uint8Array(await file.arrayBuffer())));
371  } catch (e) {
372    alert(`couldn't open ${file.name}: ${(e as Error).message}`);
373  }
374}
375
376/** the chords an edit applies to: those selected in the track, or else the one at the cursor */
377export function chordsToEdit(track: ChordTrack): string[] {
378  const selected = selectedIds(track.id).filter(id => track.chords.some(c => c.id === id));
379  if (selected.length) return selected;
380  const here = track.chords.find(c => c.start === cursor.get());
381  return here ? [here.id] : [];
382}
383
384export function deleteChords(trackId: string, ids: readonly string[]): void {
385  if (!ids.length) return;
386  commit(d => {
387    const track = chordTrack(d, trackId)!;
388    track.chords = track.chords.filter(c => !ids.includes(c.id));
389  });
390  selection.set(null);
391}
392
393/** put chords in at the cursor, over whatever's there, and select them */
394export function pasteClip(trackId: string, clip: ChordClip): void {
395  const at = cursor.get();
396  let ids: string[] = [];
397  commit(d => void (ids = pasteChords(chordTrack(d, trackId)!, clip, at)));
398  selection.set({ trackId, ids });
399  cursor.set(at + clip.span);
400}
401
402/** add notes at the cursor and select them, moving the cursor past them to paste again after */
403export function pasteNoteClip(trackId: string, clip: NoteClip): void {
404  const at = cursor.get();
405  let ids: string[] = [];
406  commit(d => void (ids = pasteNotes(findTrack(d, trackId, "notes")!, clip, at)));
407  selection.set({ trackId, ids });
408  cursor.set(at + clip.span);
409}