char-slop/chordpad.vibed.software

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

Charlotte Somchord panel style tweakad1ae60

main
4.8 KiB122 linesraw
1import type { JSX } from "@char/aftercare/jsx-runtime";
2import { voiceTrack } from "../project/harmony.ts";
3import { canonBeat, insertLeadIn, projectKey } from "../project/model.ts";
4import { project } from "../project/store.ts";
5import { prettyRoman } from "../theory/analysis.ts";
6import { type Chord, chordName, chordSymbol } from "../theory/chord.ts";
7import { connections } from "../theory/connect.ts";
8import { parallel, relative, suspend, toggleSeventh, tritoneSub } from "../theory/transform.ts";
9import { invertVoicing } from "../theory/voicing.ts";
10import { leadInto, revoice, transformChord, unpin } from "./actions.ts";
11import { disclosure, watch } from "./dom.tsx";
12import { activeTrackId, chordPanelOpen, cursor } from "./state.ts";
13
14const button = (label: string, result: string | undefined, run: () => void, title = "") => (
15  <button
16    type="button"
17    class="chord-panel-action"
18    disabled={result === undefined}
19    title={title}
20    _onclick={run}
21  >
22    {label}
23    {result ? <b>{result}</b> : undefined}
24  </button>
25);
26
27const group = (label: string, buttons: JSX.Element[]) => (
28  <div class="chord-panel-group">
29    <span class="chord-panel-label">{label}</span>
30    <div class="chord-panel-buttons">{buttons}</div>
31  </div>
32);
33
34const names = (chords: Chord | Chord[] | undefined) =>
35  chords && [chords].flat().map(chordName).join(" ");
36
37/** tools for the chord at the cursor: its voicing, variations on it, and ways into it */
38export function ChordPanel(): JSX.Element {
39  const panel = (<div class="chord-panel-body" />) as HTMLElement;
40  const name = (<div class="chord-panel-name" />) as HTMLElement;
41
42  watch(panel, [project, cursor, activeTrackId, chordPanelOpen], () => {
43    if (!chordPanelOpen.get()) {
44      name.replaceChildren();
45      return panel.replaceChildren();
46    }
47    const p = project.get();
48    const track = p.tracks.find(t => t.id === activeTrackId.get());
49    const key = projectKey(p);
50    const voiced = track?.kind === "chords" ? voiceTrack(track, key) : [];
51    const v = voiced.find(v => v.event.start === cursor.get());
52    if (!v || track?.kind !== "chords") {
53      name.replaceChildren();
54      panel.replaceChildren(<span class="chord-panel-empty">select a chord to change it</span>);
55      return;
56    }
57
58    const [trackId, id, c] = [track.id, v.event.id, v.chord];
59    const voicing = (label: string, change: (notes: number[]) => number[]) =>
60      button(label, "", () => revoice(trackId, id, change));
61    const octave =
62      (by: number) =>
63      ([bass, ...upper]: number[]) => [bass, ...upper.map(n => n + by)];
64    const change = (label: string, f: (c: Chord) => Chord | undefined, title?: string) =>
65      button(label, names(c && f(c)), () => transformChord(trackId, id, f), title);
66    // the ways in from the chord before, where there's room for them before this one
67    const i = voiced.indexOf(v);
68    const prev = voiced[i - 1];
69    const before = prev && canonBeat(prev.event.start + prev.event.dur) === v.event.start;
70    const routes = c ? connections(before ? prev.chord : undefined, c) : [];
71    const fits = (chords: Chord[]) =>
72      insertLeadIn(structuredClone(track), id, chords.map(chordSymbol), p.grid);
73    const connect = routes
74      .filter(r => fits(r.chords))
75      .slice(0, 6)
76      .map(r => button(r.name, names(r.chords), () => leadInto(trackId, id, r.chords)));
77    const adds = c && (toggleSeventh(c, key)?.tones.length ?? 0) > c.tones.length;
78    const seventh = adds ? "+ 7th" : "− 7th";
79
80    name.replaceChildren(
81      <b>{v.label}</b>,
82      <span>{v.analysis ? prettyRoman(v.analysis.roman) : ""}</span>,
83    );
84    panel.replaceChildren(
85      group("voicing", [
86        voicing("inversion ↓", notes => invertVoicing(notes, -1)),
87        voicing("inversion ↑", notes => invertVoicing(notes, 1)),
88        voicing("octave ↓", octave(-12)),
89        voicing("octave ↑", octave(12)),
90        button(
91          "unpin",
92          v.pinned ? "" : undefined,
93          () => unpin(trackId, id),
94          "back to voicing it from its neighbours",
95        ),
96      ]),
97      ...(c
98        ? [
99            group("alterations", [
100              change(seventh, c => toggleSeventh(c, key)),
101              change("sus2", c => suspend(c, key, 2)),
102              change("sus4", c => suspend(c, key, 5)),
103              change("parallel", parallel, "the same chord with the other third"),
104              change("relative", relative, "the chord a minor third away, sharing most notes"),
105              change("tritone sub", tritoneSub, "a dominant a tritone away"),
106            ]),
107            ...(connect.length
108              ? [group(before ? `from ${prev.label}` : "lead in", connect)]
109              : []),
110          ]
111        : []),
112    );
113  });
114
115  return (
116    <section class="chord-panel" dataset={{ open: chordPanelOpen.derive(String) }}>
117      {disclosure(chordPanelOpen, "chord")}
118      {name}
119      {panel}
120    </section>
121  );
122}