char-slop/chordpad.vibed.software

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

Charlotte Somwheel of fifths mate844055b

main
4.8 KiB111 linesraw
1import type { JSX } from "@char/aftercare/jsx-runtime";
2import { voiceTrack } from "../project/harmony.ts";
3import { projectKey } from "../project/model.ts";
4import { project } from "../project/store.ts";
5import { analyze, prettyRoman, romanOf } from "../theory/analysis.ts";
6import { type Chord, chordFrom, chordName } from "../theory/chord.ts";
7import { MODES } from "../theory/key.ts";
8import { mod, pcOf, spellPc } from "../theory/pitch.ts";
9import { toggleSeventh } from "../theory/transform.ts";
10import { nextChordAt, paletteTrack, previewChord, writeChordFromPalette } from "./actions.ts";
11import { watch } from "./dom.tsx";
12import { originClass } from "./origin.ts";
13import { activeTrackId, cursor, paletteSevenths } from "./state.ts";
14
15const SVG = "http://www.w3.org/2000/svg";
16const svg = (tag: string, attrs: Record<string, string | number>, ...children: Node[]) => {
17  const el = document.createElementNS(SVG, tag);
18  for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
19  el.append(...children);
20  return el;
21};
22
23const RINGS = { major: [72, 118], minor: [38, 72] } as const;
24
25/** a twelfth of a ring, `step` fifths clockwise from the top */
26function wedge(step: number, [inner, outer]: readonly [number, number]) {
27  const at = (r: number, deg: number) => {
28    const a = ((deg - 90) * Math.PI) / 180;
29    return `${(r * Math.cos(a)).toFixed(2)} ${(r * Math.sin(a)).toFixed(2)}`;
30  };
31  const [from, to] = [step * 30 - 15, step * 30 + 15];
32  return (
33    `M${at(outer, from)} A${outer} ${outer} 0 0 1 ${at(outer, to)} ` +
34    `L${at(inner, to)} A${inner} ${inner} 0 0 0 ${at(inner, from)} Z`
35  );
36}
37
38/**
39 * the circle of fifths, turned so the current chord (the one at the cursor, or else the one
40 * before it) is at the top: what's a fifth either way of it, and the relative minors inside
41 */
42export function Circle(): JSX.Element {
43  const circle = svg("svg", { class: "circle", viewBox: "-120 -120 240 240" });
44
45  watch(circle, [project, cursor, activeTrackId, paletteSevenths], () => {
46    const p = project.get();
47    const key = projectKey(p);
48    const track = paletteTrack(p);
49    const voiced = track ? voiceTrack(track, key) : [];
50    const at = cursor.get();
51    const current = (
52      voiced.find(v => v.event.start === at) ?? voiced.findLast(v => v.event.start < at)
53    )?.chord;
54
55    // the major key at the top: the chord's own, or for a minor chord its relative major, and
56    // for a diminished one the key it's the leading-tone chord of
57    const tones = current?.tones ?? (MODES[key.mode].steps.includes(3) ? [0, 3, 7] : [0, 4, 7]);
58    const root = current ? pcOf(current.root) : pcOf(key.tonic);
59    const minor = tones.includes(3) && tones.includes(7) && !tones.includes(4);
60    const diminished = tones.includes(3) && tones.includes(6);
61    const top = mod(root + (minor ? 3 : diminished ? 1 : 0), 12);
62    const here = minor ? RINGS.minor : diminished ? undefined : RINGS.major;
63
64    const chip = (triad: Chord, step: number, ring: readonly [number, number]) => {
65      // majors take whichever seventh the key has (Cmaj7, but G7 and D7 in C); minors always the
66      // minor one, as a borrowed Fm in C is Fm7 rather than the Fm(maj7) the key would make it
67      const seventh =
68        paletteSevenths.get() &&
69        (ring === RINGS.minor
70          ? chordFrom(triad.root, [0, 3, 7, 10])
71          : toggleSeventh(triad, key));
72      const chord = seventh || triad;
73      const roman = romanOf(chord, key);
74      const middle = (ring[0] + ring[1]) / 2;
75      const a = ((step * 30 - 90) * Math.PI) / 180;
76      const [x, y] = [middle * Math.cos(a), middle * Math.sin(a)];
77      const cls = `circle-chord ${ring === RINGS.minor ? "minor " : ""}${originClass(analyze(chord, key))}`;
78      const g = svg(
79        "g",
80        { class: `${cls}${!step && ring === here ? " here" : ""}` },
81        svg("path", { d: wedge(step, ring) }),
82        svg("text", { x, y: y - 1, class: "name" }, document.createTextNode(chordName(chord))),
83        svg(
84          "text",
85          { x, y: y + 10, class: "roman" },
86          document.createTextNode(prettyRoman(roman)),
87        ),
88      );
89      // the second click of a double-click would only preview on top of the write
90      g.addEventListener(
91        "click",
92        e => (e as MouseEvent).detail < 2 && previewChord(roman, nextChordAt()),
93      );
94      g.addEventListener("dblclick", () => writeChordFromPalette(roman, nextChordAt()));
95      return g;
96    };
97
98    const chips = [];
99    for (let step = 0; step < 12; step++) {
100      const major = mod(top + 7 * step, 12);
101      const relative = mod(major + 9, 12);
102      chips.push(
103        chip(chordFrom(spellPc(major, key.tonic), [0, 4, 7])!, step, RINGS.major),
104        chip(chordFrom(spellPc(relative, key.tonic), [0, 3, 7])!, step, RINGS.minor),
105      );
106    }
107    circle.replaceChildren(...chips);
108  });
109
110  return circle as JSX.Element;
111}