import type { JSX } from "@char/aftercare/jsx-runtime"; import { voiceTrack } from "../project/harmony.ts"; import { canonBeat, insertLeadIn, projectKey } from "../project/model.ts"; import { project } from "../project/store.ts"; import { prettyRoman } from "../theory/analysis.ts"; import { type Chord, chordName, chordSymbol } from "../theory/chord.ts"; import { connections } from "../theory/connect.ts"; import { parallel, relative, suspend, toggleSeventh, tritoneSub } from "../theory/transform.ts"; import { invertVoicing } from "../theory/voicing.ts"; import { leadInto, revoice, transformChord, unpin } from "./actions.ts"; import { disclosure, watch } from "./dom.tsx"; import { activeTrackId, chordPanelOpen, cursor } from "./state.ts"; const button = (label: string, result: string | undefined, run: () => void, title = "") => ( ); const group = (label: string, buttons: JSX.Element[]) => (
{label}
{buttons}
); const names = (chords: Chord | Chord[] | undefined) => chords && [chords].flat().map(chordName).join(" "); /** tools for the chord at the cursor: its voicing, variations on it, and ways into it */ export function ChordPanel(): JSX.Element { const panel = (
) as HTMLElement; const name = (
) as HTMLElement; watch(panel, [project, cursor, activeTrackId, chordPanelOpen], () => { if (!chordPanelOpen.get()) { name.replaceChildren(); return panel.replaceChildren(); } const p = project.get(); const track = p.tracks.find(t => t.id === activeTrackId.get()); const key = projectKey(p); const voiced = track?.kind === "chords" ? voiceTrack(track, key) : []; const v = voiced.find(v => v.event.start === cursor.get()); if (!v || track?.kind !== "chords") { name.replaceChildren(); panel.replaceChildren(select a chord to change it); return; } const [trackId, id, c] = [track.id, v.event.id, v.chord]; const voicing = (label: string, change: (notes: number[]) => number[]) => button(label, "", () => revoice(trackId, id, change)); const octave = (by: number) => ([bass, ...upper]: number[]) => [bass, ...upper.map(n => n + by)]; const change = (label: string, f: (c: Chord) => Chord | undefined, title?: string) => button(label, names(c && f(c)), () => transformChord(trackId, id, f), title); // the ways in from the chord before, where there's room for them before this one const i = voiced.indexOf(v); const prev = voiced[i - 1]; const before = prev && canonBeat(prev.event.start + prev.event.dur) === v.event.start; const routes = c ? connections(before ? prev.chord : undefined, c) : []; const fits = (chords: Chord[]) => insertLeadIn(structuredClone(track), id, chords.map(chordSymbol), p.grid); const connect = routes .filter(r => fits(r.chords)) .slice(0, 6) .map(r => button(r.name, names(r.chords), () => leadInto(trackId, id, r.chords))); const adds = c && (toggleSeventh(c, key)?.tones.length ?? 0) > c.tones.length; const seventh = adds ? "+ 7th" : "− 7th"; name.replaceChildren( {v.label}, {v.analysis ? prettyRoman(v.analysis.roman) : ""}, ); panel.replaceChildren( group("voicing", [ voicing("inversion ↓", notes => invertVoicing(notes, -1)), voicing("inversion ↑", notes => invertVoicing(notes, 1)), voicing("octave ↓", octave(-12)), voicing("octave ↑", octave(12)), button( "unpin", v.pinned ? "" : undefined, () => unpin(trackId, id), "back to voicing it from its neighbours", ), ]), ...(c ? [ group("alterations", [ change(seventh, c => toggleSeventh(c, key)), change("sus2", c => suspend(c, key, 2)), change("sus4", c => suspend(c, key, 5)), change("parallel", parallel, "the same chord with the other third"), change("relative", relative, "the chord a minor third away, sharing most notes"), change("tritone sub", tritoneSub, "a dominant a tritone away"), ]), ...(connect.length ? [group(before ? `from ${prev.label}` : "lead in", connect)] : []), ] : []), ); }); return (
{disclosure(chordPanelOpen, "chord")} {name} {panel}
); }