char-slop/chordpad.vibed.software

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

Charlotte Somimprove interchange detection66d16f5

main
5.7 KiB154 linesraw
1import { analyze, type Analysis, romanOf } from "../theory/analysis.ts";
2import {
3  type Chord,
4  chordMatches,
5  chordName,
6  chordPcs,
7  chordSymbol,
8  detectChord,
9  detectChords,
10  isKeyRelative,
11  parseChord,
12} from "../theory/chord.ts";
13import type { Key } from "../theory/key.ts";
14import { formatNote, midiName, pcOf } from "../theory/pitch.ts";
15import { voiceChord } from "../theory/voicing.ts";
16import { type ChordEvent, type ChordTrack, type Project, projectKey } from "./model.ts";
17
18export type VoicedChord = {
19  event: ChordEvent;
20  notes: number[];
21  /** what's sounding: the typed chord, unless hand-voiced notes say otherwise */
22  chord?: Chord;
23  analysis?: Analysis;
24  label: string;
25  /** other readings of hand-voiced notes, likeliest first */
26  alternatives: string[];
27  pinned: boolean;
28};
29
30const cache = new WeakMap<ChordTrack, { key: string; voiced: VoicedChord[] }>();
31
32/** the notes of a chord that a track plays: all of them, or without the lowest when its bass is off */
33export const sounding = (track: ChordTrack, notes: readonly number[]): number[] =>
34  track.bass ? [...notes] : notes.filter(n => n !== Math.min(...notes));
35
36/** resolve every chord to notes, auto-voicing each unpinned chord from the one before it */
37export function voiceTrack(track: ChordTrack, key: Key): VoicedChord[] {
38  const keyId = JSON.stringify(key);
39  const cached = cache.get(track);
40  if (cached?.key === keyId) return cached.voiced;
41
42  let prev: number[] | undefined;
43  const voiced = track.chords.map((event): VoicedChord => {
44    const typed = event.symbol !== undefined ? parseChord(event.symbol, key) : undefined;
45    const notes = event.notes ?? (typed ? voiceChord(typed, prev) : []);
46    const readings =
47      event.notes && !(typed && chordMatches(typed, notes))
48        ? detectChords(notes, key)
49        : undefined;
50    const chord = readings ? readings[0] : typed;
51    if (notes.length) prev = notes;
52    const label = chord
53      ? chordName(chord)
54      : event.notes
55        ? notes.map(n => midiName(n, key.tonic)).join(" ")
56        : (event.symbol ?? "");
57    return {
58      event,
59      notes,
60      chord,
61      label,
62      alternatives: (readings ?? []).slice(1, 4).map(chordName),
63      pinned: !!event.notes,
64    };
65  });
66
67  // with what follows each chord, to tell where a dominant leads
68  voiced.forEach(
69    (v, i) => (v.analysis = v.chord && analyze(v.chord, key, voiced[i + 1]?.chord)),
70  );
71  cache.set(track, { key: keyId, voiced });
72  return voiced;
73}
74
75const sameChord = (a: Chord, b: Chord) =>
76  pcOf(a.root) === pcOf(b.root) &&
77  pcOf(a.bass ?? a.root) === pcOf(b.bass ?? b.root) &&
78  chordPcs(a).join() === chordPcs(b).join();
79
80/**
81 * how to write a chord relative to a key: as a scale degree if it was one and there is one for
82 * it, otherwise as a roman numeral, and failing that by its letter name
83 */
84function relativeSymbol(chord: Chord, key: Key, wasDegree: boolean): string {
85  const degrees = [1, 2, 3, 4, 5, 6, 7].flatMap(d => [`${d}`, `${d}7`]);
86  const bass = chord.bass ? "/" + formatNote(chord.bass, true) : "";
87  const candidates = [...(wasDegree ? degrees : []), romanOf(chord, key) + bass];
88  const reads = (s: string) => {
89    const back = parseChord(s, key);
90    return back !== undefined && sameChord(back, chord);
91  };
92  return candidates.find(reads) ?? chordSymbol(chord);
93}
94
95/**
96 * change a project's key without changing what it plays: chords written relative to the key are
97 * rewritten to name the same chords in the new one, so every voicing stays as it was
98 */
99export function rekey(p: Project, key: Project["key"]): void {
100  const from = projectKey(p);
101  p.key = key;
102  const to = projectKey(p);
103  for (const track of p.tracks) {
104    if (track.kind !== "chords") continue;
105    for (const c of track.chords)
106      if (c.symbol !== undefined) c.symbol = respell(c.symbol, from, to);
107  }
108}
109
110/** a symbol written relative to one key, rewritten to name the same chord in another */
111export function respell(symbol: string, from: Key, to: Key): string {
112  if (!isKeyRelative(symbol)) return symbol;
113  const chord = parseChord(symbol, from);
114  return chord ? relativeSymbol(chord, to, /^[b#♭♯]?[1-7]/.test(symbol.trim())) : symbol;
115}
116
117const sameNotes = (a: readonly number[], b: readonly number[]) =>
118  a.length === b.length &&
119  [...a].sort((x, y) => x - y).join() === [...b].sort((x, y) => x - y).join();
120
121/**
122 * unpin every chord whose hand-set notes are just what it'd be voiced as anyway, so it goes back
123 * to following its neighbours. a chord with no symbol takes the name its notes are read as. this
124 * changes nothing that sounds: an unpinned chord gets the same notes, so the one after it is voiced
125 * the same too. tracks are only replaced where something changed, keeping their voicing caches
126 */
127export function unpinRedundant(p: Project): Project {
128  const key = projectKey(p);
129  let changed = false;
130  const tracks = p.tracks.map(track => {
131    if (track.kind !== "chords") return track;
132    let prev: number[] | undefined;
133    let trackChanged = false;
134    const chords = track.chords.map(c => {
135      const typed = c.symbol !== undefined ? parseChord(c.symbol, key) : undefined;
136      if (!c.notes) {
137        const notes = typed ? voiceChord(typed, prev) : [];
138        if (notes.length) prev = notes;
139        return c;
140      }
141      const before = prev;
142      if (c.notes.length) prev = c.notes;
143      const chord = typed && chordMatches(typed, c.notes) ? typed : detectChord(c.notes, key);
144      if (!chord || !sameNotes(voiceChord(chord, before), c.notes)) return c;
145      trackChanged = true;
146      const { notes: _, ...unpinned } = c;
147      return typed === chord ? unpinned : { ...unpinned, symbol: chordSymbol(chord) };
148    });
149    if (!trackChanged) return track;
150    changed = true;
151    return { ...track, chords };
152  });
153  return changed ? { ...p, tracks } : p;
154}