135 lines
4.0 KiB
TypeScript
135 lines
4.0 KiB
TypeScript
import { KGCore } from '../core/KGCore';
|
|
import type { KeySignature } from '../core/KGProject';
|
|
import type { ChordGuideData, ChordGuideItem, ResolvedChordGuideItem } from '../core/ChordGuideTypes';
|
|
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
|
|
import { getRootNoteFromKeySignature, noteNameToPitchClass } from './scaleUtil';
|
|
|
|
const SHARP_NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
|
|
export type ChordGuideMode = 'ionian' | 'aeolian';
|
|
export type ChordGuideFunctionType = 'T' | 'S' | 'D';
|
|
|
|
export interface MatchingChordGuideCandidate {
|
|
item: ResolvedChordGuideItem;
|
|
matchedPitchIndex: number;
|
|
displayPitchClasses: number[];
|
|
}
|
|
|
|
function getReferenceTonic(mode: ChordGuideMode): string {
|
|
return mode === 'aeolian' ? 'A' : 'C';
|
|
}
|
|
|
|
function getChordGuideData(): ChordGuideData {
|
|
return KGCore.CHORD_GUIDE_DATA ?? {
|
|
ionian: { T: [], S: [], D: [] },
|
|
aeolian: { T: [], S: [], D: [] },
|
|
};
|
|
}
|
|
|
|
function transposePitchClass(pitchClass: number, semitones: number): number {
|
|
return (pitchClass + semitones + 120) % 12;
|
|
}
|
|
|
|
function normalizeSemitoneOffset(semitones: number): number {
|
|
return ((semitones % 12) + 12) % 12;
|
|
}
|
|
|
|
function toResolvedNoteNames(pitches: number[]): string[] {
|
|
return pitches.map((pitch) => SHARP_NOTE_NAMES[((pitch % 12) + 12) % 12]);
|
|
}
|
|
|
|
function resolveChordRootPitchClass(item: ChordGuideItem, semitones: number): number | null {
|
|
const descriptor = parseChordSymbol(item.name);
|
|
if (!descriptor) {
|
|
console.warn(`Unable to parse chord guide symbol: ${item.name}`);
|
|
return null;
|
|
}
|
|
|
|
return transposePitchClass(noteNameToPitchClass(descriptor.root), semitones);
|
|
}
|
|
|
|
export function resolveChordGuideItems(
|
|
keySignature: KeySignature,
|
|
mode: ChordGuideMode,
|
|
functionType: ChordGuideFunctionType
|
|
): ResolvedChordGuideItem[] {
|
|
const data = getChordGuideData()[mode];
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
|
|
const items = data[functionType] ?? [];
|
|
const referenceTonic = getReferenceTonic(mode);
|
|
const targetTonic = getRootNoteFromKeySignature(keySignature);
|
|
const semitoneOffset = normalizeSemitoneOffset(
|
|
noteNameToPitchClass(targetTonic) - noteNameToPitchClass(referenceTonic)
|
|
);
|
|
|
|
return items.flatMap((item) => {
|
|
const rootPitchClass = resolveChordRootPitchClass(item, semitoneOffset);
|
|
if (rootPitchClass === null) {
|
|
return [];
|
|
}
|
|
|
|
const pitchClasses = getChordMidiPitches(item.name, rootPitchClass);
|
|
if (pitchClasses.length === 0) {
|
|
console.warn(`Unable to resolve pitch classes for chord guide symbol: ${item.name}`);
|
|
return [];
|
|
}
|
|
|
|
return [{
|
|
...item,
|
|
resolvedNotes: toResolvedNoteNames(pitchClasses),
|
|
pitchClasses,
|
|
}];
|
|
});
|
|
}
|
|
|
|
export function getMatchingChordGuideChordsForPitch(
|
|
hoverPitch: number,
|
|
keySignature: KeySignature,
|
|
mode: ChordGuideMode,
|
|
functionType: ChordGuideFunctionType
|
|
): number[][] {
|
|
return getMatchingChordGuideCandidatesForPitch(hoverPitch, keySignature, mode, functionType)
|
|
.map((candidate) => candidate.displayPitchClasses);
|
|
}
|
|
|
|
export function getMatchingChordGuideCandidatesForPitch(
|
|
hoverPitch: number,
|
|
keySignature: KeySignature,
|
|
mode: ChordGuideMode,
|
|
functionType: ChordGuideFunctionType
|
|
): MatchingChordGuideCandidate[] {
|
|
const resolvedChords = resolveChordGuideItems(keySignature, mode, functionType);
|
|
if (resolvedChords.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const hoverPitchClass = ((hoverPitch % 12) + 12) % 12;
|
|
const matchesByPosition: MatchingChordGuideCandidate[][] = [];
|
|
|
|
for (const item of resolvedChords) {
|
|
for (let i = 0; i < item.pitchClasses.length; i++) {
|
|
if (item.pitchClasses[i] % 12 !== hoverPitchClass) {
|
|
continue;
|
|
}
|
|
|
|
if (!matchesByPosition[i]) {
|
|
matchesByPosition[i] = [];
|
|
}
|
|
|
|
matchesByPosition[i].push({
|
|
item,
|
|
matchedPitchIndex: i,
|
|
displayPitchClasses: item.pitchClasses[i] >= 12
|
|
? item.pitchClasses.map((pitch) => pitch - 12)
|
|
: item.pitchClasses,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
return matchesByPosition.flatMap((matches) => matches ?? []);
|
|
}
|