char-slop/chordpad.vibed.software

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

Charlotte Sominitial commit3c7bc6e

main
9.2 KiB264 linesraw
1import { describeTones } from "./describe.ts";
2import { type Key, MODES, type ModeId, modesFrom } from "./key.ts";
3import {
4  accidentalValue,
5  formatNote,
6  mod,
7  parseNote,
8  type Pc,
9  pcOf,
10  type Spelled,
11  spellDegree,
12  spellPc,
13} from "./pitch.ts";
14
15export type Chord = {
16  root: Spelled;
17  /** semitones above the root: sorted, unique, within one octave, starting with 0 */
18  tones: number[];
19  /** only present when it differs from the root */
20  bass?: Spelled;
21  suffix: string;
22};
23
24export const ROMANS = ["I", "II", "III", "IV", "V", "VI", "VII"];
25const ROMAN_RE = /^([b#♭♯]*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i)/;
26const DEGREE_RE = /^([b#♭♯]?)([1-7])(7?)$/;
27
28/** whether a symbol is written relative to the key (a roman numeral or scale degree) */
29export const isKeyRelative = (symbol: string): boolean =>
30  ROMAN_RE.test(symbol.trim()) || /^[b#♭♯]?[1-7]/.test(symbol.trim());
31
32export const normalizeTones = (tones: Iterable<number>): number[] =>
33  [...new Set([...tones].map(t => mod(t, 12)))].sort((a, b) => a - b);
34
35const sameList = (a: readonly number[], b: readonly number[]) =>
36  a.length === b.length && a.every((x, i) => x === b[i]);
37
38function makeChord(root: Spelled, tones: Iterable<number>, typedSuffix = ""): Chord {
39  const normal = normalizeTones(tones);
40  // "alt" is shorthand no spelled-out name improves on
41  const suffix = typedSuffix.includes("alt") ? typedSuffix : describeTones(normal)?.suffix;
42  return { root, tones: normal, suffix: suffix ?? typedSuffix };
43}
44
45/** a chord from its root and the semitones above it, if the shape has a name */
46export function chordFrom(root: Spelled, tones: number[], bass?: Spelled): Chord | undefined {
47  if (!describeTones(tones)) return undefined;
48  const chord = makeChord(root, tones);
49  return bass ? withBass(chord, bass) : chord;
50}
51
52function withBass(chord: Chord, bass: Spelled): Chord {
53  return pcOf(bass) === pcOf(chord.root) ? chord : { ...chord, bass };
54}
55
56/** absolute pitch classes, including the bass */
57export function chordPcs(chord: Chord): Pc[] {
58  const root = pcOf(chord.root);
59  const pcs = chord.tones.map(t => mod(root + t, 12));
60  if (chord.bass) pcs.push(pcOf(chord.bass));
61  return normalizeTones(pcs);
62}
63
64export const prettySymbol = (s: string): string =>
65  s.replace(/b(?=\d)/g, "♭").replace(/#/g, "♯");
66
67/** in a form `parseChord` reads back */
68export const chordSymbol = (chord: Chord): string =>
69  formatNote(chord.root, true) +
70  chord.suffix +
71  (chord.bass ? "/" + formatNote(chord.bass, true) : "");
72
73export const chordName = (chord: Chord): string =>
74  formatNote(chord.root) +
75  prettySymbol(chord.suffix) +
76  (chord.bass ? "/" + formatNote(chord.bass) : "");
77
78const EXTENSION_SEMIS: Record<string, number> = {
79  "2": 2,
80  "4": 5,
81  "5": 7,
82  "6": 9,
83  "9": 2,
84  "11": 5,
85  "13": 9,
86};
87
88function parseSuffix(src: string, minor: boolean): number[] | undefined {
89  let s = src.replace(/[()\s,]/g, "");
90  const eat = (re: RegExp) => {
91    const m = re.exec(s);
92    if (m) s = s.slice(m[0].length);
93    return m ?? undefined;
94  };
95
96  let third: number | undefined = minor ? 3 : 4;
97  let fifth: number | undefined = 7;
98  let seventh: number | undefined;
99  let major7 = false;
100  // a bare triangle means maj7, unlike a bare "maj" or "M"
101  let triangle = false;
102  const eatMajor = () => {
103    const m = eat(/^(maj|Maj|MAJ|M|Δ|\^)/);
104    if (m) [major7, triangle] = [true, /[Δ^]/.test(m[0])];
105  };
106  const ext = new Set<number>();
107
108  if (eat(/^ø7?/)) [third, fifth, seventh] = [3, 6, 10];
109  else if (eat(/^(dim|°|o(?!mit))/)) {
110    [third, fifth] = [3, 6];
111    if (eat(/^7/)) seventh = 9;
112  } else if (eat(/^(aug|\+)/)) {
113    fifth = 8;
114    eatMajor();
115  } else if (eat(/^(min|mi|m|-)(?!aj)/)) {
116    third = 3;
117    eatMajor();
118  } else eatMajor();
119
120  const num = eat(/^(6\/9|69|13|11|9|7|6|5)/)?.[0];
121  const extensions: Record<string, number[]> = { "9": [2], "11": [2, 5], "13": [2, 9] };
122  if (num === "5") third = undefined;
123  else if (num === "6") ext.add(9);
124  else if (num === "6/9" || num === "69") ext.add(9).add(2);
125  else if (num) {
126    seventh ??= major7 ? 11 : 10;
127    for (const t of extensions[num] ?? []) ext.add(t);
128  } else if (triangle) seventh = 11;
129
130  while (s) {
131    let m;
132    if ((m = eat(/^sus([24])?/))) third = m[1] === "2" ? 2 : 5;
133    else if ((m = eat(/^add([#♯b♭]?)(13|11|9|6|4|2)/))) {
134      ext.add(EXTENSION_SEMIS[m[2]] + accidentalValue(m[1]));
135    } else if ((m = eat(/^([#♯b♭+-])(13|11|9|5)/))) {
136      const semis = EXTENSION_SEMIS[m[2]] + ("#♯+".includes(m[1]) ? 1 : -1);
137      if (m[2] === "5") fifth = semis;
138      else {
139        ext.delete(EXTENSION_SEMIS[m[2]]);
140        ext.add(semis);
141      }
142    } else if (eat(/^alt/)) {
143      seventh ??= 10;
144      fifth = undefined;
145      for (const t of [1, 3, 6, 8]) ext.add(t);
146    } else if ((m = eat(/^(no|omit)([35])/))) {
147      if (m[2] === "3") third = undefined;
148      else fifth = undefined;
149    } else return undefined;
150  }
151
152  return normalizeTones(
153    [0, third, fifth, seventh, ...ext].filter((t): t is number => t !== undefined),
154  );
155}
156
157/** the diatonic triad (or seventh chord) of `mode` built on `degree` (0-based), spelled in `key` */
158export function degreeChord(key: Key, mode: ModeId, degree: number, seventh: boolean): Chord {
159  const steps = MODES[mode].steps;
160  const at = (k: number) => steps[(degree + k) % 7] + 12 * Math.floor((degree + k) / 7);
161  const root = spellDegree(key.tonic, degree, steps[degree]);
162  const stack = seventh ? [0, 2, 4, 6] : [0, 2, 4];
163  return makeChord(
164    root,
165    stack.map(k => at(k) - steps[degree]),
166  );
167}
168
169/**
170 * parse a chord symbol. accepts letter names (`Cm7b5`, `F#/C#`), and roman numerals (`III`,
171 * `bVI`, `V7/V`) and scale degrees (`4`, `57`, `b7`) counted in the key's mode: in A minor, `III`
172 * is C and `#vi` is F♯m. a secondary's roman counts in the major scale of its target
173 */
174export function parseChord(text: string, key: Key): Chord | undefined {
175  const src = text.trim();
176  if (!src) return undefined;
177
178  const slash = src.lastIndexOf("/");
179  if (slash > 0) {
180    const left = src.slice(0, slash);
181    const right = src.slice(slash + 1);
182    const bass = parseNote(right);
183    if (bass && bass.rest === "") {
184      const chord = parseChord(left, key);
185      return chord && withBass(chord, bass.note);
186    }
187    if (ROMAN_RE.test(right) || DEGREE_RE.test(right)) {
188      const target = parseChord(right, key);
189      return target && parseChord(left, { tonic: target.root, mode: "ionian" });
190    }
191  }
192
193  const roman = ROMAN_RE.exec(src);
194  if (roman) {
195    const degree = ROMANS.indexOf(roman[2].toUpperCase());
196    const minor = roman[2] !== roman[2].toUpperCase();
197    const semis = MODES[key.mode].steps[degree] + accidentalValue(roman[1]);
198    const root = spellDegree(key.tonic, degree, semis);
199    const rest = src.slice(roman[0].length);
200    const tones = parseSuffix(rest, minor);
201    const fallback = (minor && !/^(dim|°|o|ø)/.test(rest) ? "m" : "") + rest;
202    return tones && makeChord(root, tones, fallback);
203  }
204
205  const scaleDegree = DEGREE_RE.exec(src);
206  if (scaleDegree) {
207    const degree = Number(scaleDegree[2]) - 1;
208    const semis = MODES[key.mode].steps[degree] + accidentalValue(scaleDegree[1]);
209    // a bare degree is diatonic; an altered one borrows from whichever mode has it
210    const mode = scaleDegree[1]
211      ? modesFrom(key.mode).find(m => MODES[m].steps[degree] === semis)
212      : key.mode;
213    return mode && degreeChord(key, mode, degree, scaleDegree[3] === "7");
214  }
215
216  const letter = parseNote(src);
217  if (!letter) return undefined;
218  const tones = parseSuffix(letter.rest, false);
219  return tones && makeChord(letter.note, tones, letter.rest);
220}
221
222const INVERTED = 1;
223const FOREIGN_BASS = 1.3;
224
225/**
226 * readings of a set of midi notes, likeliest first. each note can be the root, with the lowest
227 * as an inversion's bass; or the lowest can be a separate bass under a chord made of the rest
228 */
229export function detectChords(midis: readonly number[], key: Key): Chord[] {
230  if (!midis.length) return [];
231  const sorted = [...midis].sort((a, b) => a - b);
232  const bass = mod(sorted[0], 12);
233  const pcs = normalizeTones(sorted);
234  const upper = normalizeTones(sorted.slice(1));
235
236  const readings: { cost: number; root: Pc; tones: number[] }[] = [];
237  const consider = (root: Pc, notes: Pc[], penalty: number) => {
238    const tones = notes.map(p => p - root);
239    const described = describeTones(tones);
240    if (described) readings.push({ cost: described.cost + penalty, root, tones });
241  };
242  for (const root of pcs) consider(root, pcs, root === bass ? 0 : INVERTED);
243  if (!upper.includes(bass) && upper.length >= 3) {
244    for (const root of upper) consider(root, upper, FOREIGN_BASS);
245  }
246
247  const spelledBass = spellPc(bass, key.tonic);
248  return readings
249    .sort((a, b) => a.cost - b.cost)
250    .map(r => withBass(makeChord(spellPc(r.root, key.tonic), r.tones), spelledBass));
251}
252
253export const detectChord = (midis: readonly number[], key: Key): Chord | undefined =>
254  detectChords(midis, key)[0];
255
256/** whether the notes sound the chord: same pitch classes, same bass */
257export function chordMatches(chord: Chord, midis: readonly number[]): boolean {
258  if (!midis.length) return false;
259  const bass = chord.bass ?? chord.root;
260  return (
261    sameList(chordPcs(chord), normalizeTones(midis)) &&
262    mod(Math.min(...midis), 12) === pcOf(bass)
263  );
264}