import { analyze, type Analysis, romanOf } from "../theory/analysis.ts"; import { type Chord, chordMatches, chordName, chordPcs, chordSymbol, detectChord, detectChords, isKeyRelative, parseChord, } from "../theory/chord.ts"; import type { Key } from "../theory/key.ts"; import { formatNote, midiName, pcOf } from "../theory/pitch.ts"; import { voiceChord } from "../theory/voicing.ts"; import { type ChordEvent, type ChordTrack, type Project, projectKey } from "./model.ts"; export type VoicedChord = { event: ChordEvent; notes: number[]; /** what's sounding: the typed chord, unless hand-voiced notes say otherwise */ chord?: Chord; analysis?: Analysis; label: string; /** other readings of hand-voiced notes, likeliest first */ alternatives: string[]; pinned: boolean; }; const cache = new WeakMap(); /** the notes of a chord that a track plays: all of them, or without the lowest when its bass is off */ export const sounding = (track: ChordTrack, notes: readonly number[]): number[] => track.bass ? [...notes] : notes.filter(n => n !== Math.min(...notes)); /** resolve every chord to notes, auto-voicing each unpinned chord from the one before it */ export function voiceTrack(track: ChordTrack, key: Key): VoicedChord[] { const keyId = JSON.stringify(key); const cached = cache.get(track); if (cached?.key === keyId) return cached.voiced; let prev: number[] | undefined; const voiced = track.chords.map((event): VoicedChord => { const typed = event.symbol !== undefined ? parseChord(event.symbol, key) : undefined; const notes = event.notes ?? (typed ? voiceChord(typed, prev) : []); const readings = event.notes && !(typed && chordMatches(typed, notes)) ? detectChords(notes, key) : undefined; const chord = readings ? readings[0] : typed; if (notes.length) prev = notes; const label = chord ? chordName(chord) : event.notes ? notes.map(n => midiName(n, key.tonic)).join(" ") : (event.symbol ?? ""); return { event, notes, chord, label, alternatives: (readings ?? []).slice(1, 4).map(chordName), pinned: !!event.notes, }; }); // with what follows each chord, to tell where a dominant leads voiced.forEach( (v, i) => (v.analysis = v.chord && analyze(v.chord, key, voiced[i + 1]?.chord)), ); cache.set(track, { key: keyId, voiced }); return voiced; } const sameChord = (a: Chord, b: Chord) => pcOf(a.root) === pcOf(b.root) && pcOf(a.bass ?? a.root) === pcOf(b.bass ?? b.root) && chordPcs(a).join() === chordPcs(b).join(); /** * how to write a chord relative to a key: as a scale degree if it was one and there is one for * it, otherwise as a roman numeral, and failing that by its letter name */ function relativeSymbol(chord: Chord, key: Key, wasDegree: boolean): string { const degrees = [1, 2, 3, 4, 5, 6, 7].flatMap(d => [`${d}`, `${d}7`]); const bass = chord.bass ? "/" + formatNote(chord.bass, true) : ""; const candidates = [...(wasDegree ? degrees : []), romanOf(chord, key) + bass]; const reads = (s: string) => { const back = parseChord(s, key); return back !== undefined && sameChord(back, chord); }; return candidates.find(reads) ?? chordSymbol(chord); } /** * change a project's key without changing what it plays: chords written relative to the key are * rewritten to name the same chords in the new one, so every voicing stays as it was */ export function rekey(p: Project, key: Project["key"]): void { const from = projectKey(p); p.key = key; const to = projectKey(p); for (const track of p.tracks) { if (track.kind !== "chords") continue; for (const c of track.chords) if (c.symbol !== undefined) c.symbol = respell(c.symbol, from, to); } } /** a symbol written relative to one key, rewritten to name the same chord in another */ export function respell(symbol: string, from: Key, to: Key): string { if (!isKeyRelative(symbol)) return symbol; const chord = parseChord(symbol, from); return chord ? relativeSymbol(chord, to, /^[b#♭♯]?[1-7]/.test(symbol.trim())) : symbol; } const sameNotes = (a: readonly number[], b: readonly number[]) => a.length === b.length && [...a].sort((x, y) => x - y).join() === [...b].sort((x, y) => x - y).join(); /** * unpin every chord whose hand-set notes are just what it'd be voiced as anyway, so it goes back * to following its neighbours. a chord with no symbol takes the name its notes are read as. this * changes nothing that sounds: an unpinned chord gets the same notes, so the one after it is voiced * the same too. tracks are only replaced where something changed, keeping their voicing caches */ export function unpinRedundant(p: Project): Project { const key = projectKey(p); let changed = false; const tracks = p.tracks.map(track => { if (track.kind !== "chords") return track; let prev: number[] | undefined; let trackChanged = false; const chords = track.chords.map(c => { const typed = c.symbol !== undefined ? parseChord(c.symbol, key) : undefined; if (!c.notes) { const notes = typed ? voiceChord(typed, prev) : []; if (notes.length) prev = notes; return c; } const before = prev; if (c.notes.length) prev = c.notes; const chord = typed && chordMatches(typed, c.notes) ? typed : detectChord(c.notes, key); if (!chord || !sameNotes(voiceChord(chord, before), c.notes)) return c; trackChanged = true; const { notes: _, ...unpinned } = c; return typed === chord ? unpinned : { ...unpinned, symbol: chordSymbol(chord) }; }); if (!trackChanged) return track; changed = true; return { ...track, chords }; }); return changed ? { ...p, tracks } : p; }