diff --git a/public/resources/modes/chord_guide.json b/public/resources/modes/chord_guide.json new file mode 100644 index 0000000..8f7081d --- /dev/null +++ b/public/resources/modes/chord_guide.json @@ -0,0 +1,340 @@ +{ + "ionian": { + "T": [ + { + "name": "C", + "roman": "I", + "notes": ["C", "E", "G"], + "source": "Diatonic", + "note": "Most stable tonic triad." + }, + { + "name": "Cmaj7", + "roman": "Imaj7", + "notes": ["C", "E", "G", "B"], + "source": "Diatonic", + "note": "Soft modern tonic color." + }, + { + "name": "Am", + "roman": "vi", + "notes": ["A", "C", "E"], + "source": "Diatonic", + "note": "Common tonic substitute." + }, + { + "name": "Am7", + "roman": "vi7", + "notes": ["A", "C", "E", "G"], + "source": "Diatonic", + "note": "Frequent pop tonic substitute." + }, + { + "name": "Em", + "roman": "iii", + "notes": ["E", "G", "B"], + "source": "Diatonic", + "note": "Weak tonic-function option." + }, + { + "name": "Em7", + "roman": "iii7", + "notes": ["E", "G", "B", "D"], + "source": "Diatonic", + "note": "Floating tonic color." + }, + { + "name": "C6", + "roman": "I6", + "notes": ["C", "E", "G", "A"], + "source": "Diatonic", + "note": "Common pop and jazz tonic sonority." + }, + { + "name": "Cadd9", + "roman": "Iadd9", + "notes": ["C", "E", "G", "D"], + "source": "Diatonic", + "note": "Bright modern tonic color used often in acoustic pop and J-pop." + } + ], + "S": [ + { + "name": "F", + "roman": "IV", + "notes": ["F", "A", "C"], + "source": "Diatonic", + "note": "Standard subdominant triad." + }, + { + "name": "Fmaj7", + "roman": "IVmaj7", + "notes": ["F", "A", "C", "E"], + "source": "Diatonic", + "note": "Lyrical subdominant color common in Japanese pop." + }, + { + "name": "Dm", + "roman": "ii", + "notes": ["D", "F", "A"], + "source": "Diatonic", + "note": "Strong predominant chord." + }, + { + "name": "Dm7", + "roman": "ii7", + "notes": ["D", "F", "A", "C"], + "source": "Diatonic", + "note": "Core ii-V-I predominant sonority." + }, + { + "name": "Bb", + "roman": "bVII", + "notes": ["Bb", "D", "F"], + "source": "Borrowed from Mixolydian", + "note": "Adds a direct rock-inflected predominant color." + }, + { + "name": "Bbmaj7", + "roman": "bVIImaj7", + "notes": ["Bb", "D", "F", "A"], + "source": "Borrowed from Mixolydian", + "note": "A lush borrowed color often heard in city pop." + }, + { + "name": "F6", + "roman": "IV6", + "notes": ["F", "A", "C", "D"], + "source": "Diatonic", + "note": "Gentle subdominant expansion." + }, + { + "name": "D7", + "roman": "V/V", + "notes": ["D", "F#", "A", "C"], + "source": "Secondary dominant", + "note": "Targets G with clear forward pull." + } + ], + "D": [ + { + "name": "G", + "roman": "V", + "notes": ["G", "B", "D"], + "source": "Diatonic", + "note": "Standard dominant triad." + }, + { + "name": "G7", + "roman": "V7", + "notes": ["G", "B", "D", "F"], + "source": "Diatonic", + "note": "Classic dominant seventh." + }, + { + "name": "Bdim", + "roman": "vii°", + "notes": ["B", "D", "F"], + "source": "Diatonic", + "note": "Leading-tone diminished triad." + }, + { + "name": "Bm7b5", + "roman": "viiø7", + "notes": ["B", "D", "F", "A"], + "source": "Diatonic", + "note": "Leading-tone half-diminished seventh." + }, + { + "name": "Ddim7", + "roman": "#ii°7", + "notes": ["D", "F", "Ab", "B"], + "source": "Passing diminished", + "note": "Creates strong chromatic tension as a passing diminished sonority." + }, + { + "name": "E7", + "roman": "V/vi", + "notes": ["E", "G#", "B", "D"], + "source": "Secondary dominant", + "note": "Targets Am directly." + }, + { + "name": "A7", + "roman": "V/ii", + "notes": ["A", "C#", "E", "G"], + "source": "Secondary dominant", + "note": "Targets Dm directly." + } + ] + }, + "aeolian": { + "T": [ + { + "name": "Am", + "roman": "i", + "notes": ["A", "C", "E"], + "source": "Diatonic", + "note": "Primary tonic triad." + }, + { + "name": "Am7", + "roman": "i7", + "notes": ["A", "C", "E", "G"], + "source": "Diatonic", + "note": "Common tonic minor sonority." + }, + { + "name": "C", + "roman": "III", + "notes": ["C", "E", "G"], + "source": "Diatonic", + "note": "A stable tonic-function substitute." + }, + { + "name": "Cmaj7", + "roman": "IIImaj7", + "notes": ["C", "E", "G", "B"], + "source": "Diatonic", + "note": "A lyrical tonic substitute common in Japanese pop writing." + }, + { + "name": "Em", + "roman": "v", + "notes": ["E", "G", "B"], + "source": "Natural minor", + "note": "A weak dominant with tonic-leaning color." + }, + { + "name": "Em7", + "roman": "v7", + "notes": ["E", "G", "B", "D"], + "source": "Natural minor", + "note": "A floating modal dominant color." + }, + { + "name": "Amadd9", + "roman": "iadd9", + "notes": ["A", "C", "E", "B"], + "source": "Diatonic", + "note": "A bright modern tonic extension." + }, + { + "name": "Am6", + "roman": "i6", + "notes": ["A", "C", "E", "F#"], + "source": "Borrowed from Dorian", + "note": "An airy tonic color with modal brightness." + } + ], + "S": [ + { + "name": "Dm", + "roman": "iv", + "notes": ["D", "F", "A"], + "source": "Diatonic", + "note": "Standard subdominant in minor." + }, + { + "name": "Dm7", + "roman": "iv7", + "notes": ["D", "F", "A", "C"], + "source": "Diatonic", + "note": "Common minor predominant sonority." + }, + { + "name": "F", + "roman": "VI", + "notes": ["F", "A", "C"], + "source": "Diatonic", + "note": "A broadening minor-mode subdominant color." + }, + { + "name": "Fmaj7", + "roman": "VImaj7", + "notes": ["F", "A", "C", "E"], + "source": "Diatonic", + "note": "Dreamy subdominant expansion." + }, + { + "name": "D", + "roman": "IV", + "notes": ["D", "F#", "A"], + "source": "Borrowed from Dorian", + "note": "Adds a bright modal predominant color." + }, + { + "name": "D7", + "roman": "IV7", + "notes": ["D", "F#", "A", "C"], + "source": "Borrowed from Dorian and jazz practice", + "note": "A brighter predominant with modal-jazz pull." + }, + { + "name": "Bb", + "roman": "bII", + "notes": ["Bb", "D", "F"], + "source": "Borrowed from Phrygian", + "note": "A heavy, dramatic borrowed predominant." + } + ], + "D": [ + { + "name": "E", + "roman": "V", + "notes": ["E", "G#", "B"], + "source": "Harmonic minor", + "note": "Primary strong dominant in minor." + }, + { + "name": "E7", + "roman": "V7", + "notes": ["E", "G#", "B", "D"], + "source": "Harmonic minor", + "note": "Classic minor-key dominant seventh." + }, + { + "name": "G#dim", + "roman": "vii°", + "notes": ["G#", "B", "D"], + "source": "Harmonic minor", + "note": "Leading-tone diminished triad." + }, + { + "name": "G#dim7", + "roman": "vii°7", + "notes": ["G#", "B", "D", "F"], + "source": "Harmonic minor", + "note": "Classical leading-tone diminished seventh with strong cadential force." + }, + { + "name": "Bdim", + "roman": "ii°", + "notes": ["B", "D", "F"], + "source": "Diatonic", + "note": "A weak dominant-leaning diminished chord." + }, + { + "name": "Bm7b5", + "roman": "iiø7", + "notes": ["B", "D", "F", "A"], + "source": "Diatonic", + "note": "Half-diminished predominant or weak dominant option." + }, + { + "name": "G", + "roman": "VII", + "notes": ["G", "B", "D"], + "source": "Natural minor", + "note": "A modal dominant color with weaker cadential pull." + }, + { + "name": "Eaug", + "roman": "V+", + "notes": ["E", "G#", "B#"], + "source": "Harmonic minor variant", + "note": "A dramatic altered dominant color." + } + ] + } +} diff --git a/src/App.tsx b/src/App.tsx index a4e4c9e..e503545 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -86,17 +86,21 @@ function App() { // Load all mode and chord data files in parallel try { - const [functionalChordsResponse] = await Promise.all([ - fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`) + const [functionalChordsResponse, chordGuideResponse] = await Promise.all([ + fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`), + fetch(`${import.meta.env.BASE_URL}resources/modes/chord_guide.json`) ]); - const [functionalChordsData] = await Promise.all([ - functionalChordsResponse.json() + const [functionalChordsData, chordGuideData] = await Promise.all([ + functionalChordsResponse.json(), + chordGuideResponse.json(), ]); // Store original functional chords data KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = functionalChordsData; console.log(`Loaded original functional chords for ${Object.keys(functionalChordsData).length} modes`); + KGCore.CHORD_GUIDE_DATA = chordGuideData; + console.log(`Loaded chord guide data for ${Object.keys(chordGuideData).length} modes`); // Check if custom chord definition exists and is valid const configManager = ConfigManager.instance(); @@ -130,6 +134,10 @@ function App() { }; KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = fallbackData; KGCore.FUNCTIONAL_CHORDS_DATA = fallbackData; + KGCore.CHORD_GUIDE_DATA = { + ionian: { T: [], S: [], D: [] }, + aeolian: { T: [], S: [], D: [] }, + }; } // Log maxBars after initialization completes diff --git a/src/components/ChordPickerPopup.test.tsx b/src/components/ChordPickerPopup.test.tsx index 734ca8d..aa7c97a 100644 --- a/src/components/ChordPickerPopup.test.tsx +++ b/src/components/ChordPickerPopup.test.tsx @@ -32,6 +32,32 @@ describe('ChordPickerPopup', () => { expect(screen.getByText('Unable to parse chord')).toBeInTheDocument(); }); + it('supports selecting dim7 from the popup controls', () => { + const onChange = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'dim7' })); + + expect(onChange).toHaveBeenCalledWith('Gdim7'); + expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected'); + expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected'); + }); + + it('syncs the dim7 button when parsing text input', () => { + const onChange = vi.fn(); + + render(); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'G#dim7' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith('G#dim7'); + expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected'); + expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected'); + }); + it('intercepts tab and delegates popup bar navigation', () => { const onTabNavigate = vi.fn(); diff --git a/src/components/ChordPickerPopup.tsx b/src/components/ChordPickerPopup.tsx index 35c08ea..5d271f5 100644 --- a/src/components/ChordPickerPopup.tsx +++ b/src/components/ChordPickerPopup.tsx @@ -34,6 +34,7 @@ const EXTENSION_LABELS: Record = { '#5': '#5', '6': '6', '7': '7', + dim7: 'dim7', maj7: 'maj7', b9: 'b9', '9': '9', @@ -51,8 +52,9 @@ const QUALITY_ROWS: ChordQuality[][] = [ const EXTENSION_ROWS: ChordExtension[][] = [ ['b5', '#5', '6', '7'], - ['maj7', 'b9', '9', '#9'], - ['11', '#11', 'b13', '13'], + ['dim7', 'maj7', 'b9', '9'], + ['#9', '11', '#11', 'b13'], + ['13'], ]; function createFallbackDescriptor(value: string): ChordDescriptor { @@ -86,13 +88,16 @@ function normalizeDescriptor(descriptor: ChordDescriptor): ChordDescriptor { removeExtensions(['#5']); } if (extensions.includes('7')) { - removeExtensions(['maj7', '6']); + removeExtensions(['dim7', 'maj7', '6']); + } + if (extensions.includes('dim7')) { + removeExtensions(['7', 'maj7', '6']); } if (extensions.includes('maj7')) { - removeExtensions(['7', '6']); + removeExtensions(['7', 'dim7', '6']); } if (extensions.includes('6')) { - removeExtensions(['7', 'maj7', '13', 'b13']); + removeExtensions(['7', 'dim7', 'maj7', '13', 'b13']); } if (extensions.includes('13') || extensions.includes('b13')) { removeExtensions(['6']); diff --git a/src/components/piano-roll/PianoGrid.tsx b/src/components/piano-roll/PianoGrid.tsx index eb2bdb8..bcb137f 100644 --- a/src/components/piano-roll/PianoGrid.tsx +++ b/src/components/piano-roll/PianoGrid.tsx @@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react'; import { Playhead } from '../common'; import SelectionBox from './SelectionBox'; import { isModifierKeyPressed } from '../../util/osUtil'; -import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil'; +import { generatePianoGridBackground } from '../../util/scaleUtil'; import type { KeySignature } from '../../core/KGProject'; import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import SpectrogramCanvas from './SpectrogramCanvas'; @@ -11,6 +11,7 @@ import AudioWaveformCanvas from './AudioWaveformCanvas'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil'; import { getNextChordCandidateIndex } from './chordGuideUtil'; +import { getMatchingChordGuideChordsForPitch } from '../../util/chordGuideDataUtil'; interface PianoGridProps { gridRef: MutableRefObject; @@ -167,7 +168,7 @@ const PianoGrid: React.FC = ({ // Use the utility function to get matching chords const functionType = chordGuide as 'T' | 'S' | 'D'; - return getMatchingChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType); + return getMatchingChordGuideChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType); }, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]); // Calculate chord highlights based on selected chord index diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 848d949..0ed991f 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -19,9 +19,9 @@ import { beatsToBar } from '../../util/midiUtil'; import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage'; -import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil'; import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil'; import { matchesKeyboardShortcut } from '../../util/osUtil'; +import { resolveChordGuideItems } from '../../util/chordGuideDataUtil'; import { normalizeSpectrogramHeightResolution, type SpectrogramHeightResolution, @@ -739,7 +739,7 @@ const PianoRoll: React.FC = ({ if (chordGuide === 'N') { // Disabled - clear chord data - pianoRollState.setCurrentSuitableChords({}); + pianoRollState.setCurrentSuitableChords([]); pianoRollState.setCurrentSuitableChordsPitchClasses({}); if (DEBUG_MODE.PIANO_ROLL) { @@ -748,28 +748,10 @@ const PianoRoll: React.FC = ({ } else { // Get suitable chords for the selected function (T/S/D) const functionType = chordGuide as 'T' | 'S' | 'D'; - const suitableChords = getSuitableChords(effectiveChordGuideKeySignature, chordGuideMode, functionType); - - // Convert note names to pitch classes (ensuring ascending order) - const chordsPitchClasses: Record = {}; - for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) { - const pitchClasses: number[] = []; - let previousPitch = -1; - - for (const noteName of noteNames) { - let pitchClass = noteNameToPitchClass(noteName); - - // If this pitch is lower than the previous one, add an octave - if (previousPitch >= 0 && pitchClass <= previousPitch) { - pitchClass += 12; - } - - pitchClasses.push(pitchClass); - previousPitch = pitchClass; - } - - chordsPitchClasses[chordSymbol] = pitchClasses; - } + const suitableChords = resolveChordGuideItems(effectiveChordGuideKeySignature, chordGuideMode, functionType); + const chordsPitchClasses = Object.fromEntries( + suitableChords.map((item) => [item.name, item.pitchClasses]) + ); // Update piano roll state pianoRollState.setCurrentSuitableChords(suitableChords); diff --git a/src/components/piano-roll/PianoRollZoomPersistence.test.tsx b/src/components/piano-roll/PianoRollZoomPersistence.test.tsx index f9df01b..d9e4382 100644 --- a/src/components/piano-roll/PianoRollZoomPersistence.test.tsx +++ b/src/components/piano-roll/PianoRollZoomPersistence.test.tsx @@ -118,6 +118,17 @@ vi.mock('./PianoRollToolbar', () => ({ }, })); +vi.mock('./chordGuideUtil', async () => { + const actual = await vi.importActual('./chordGuideUtil'); + return { + ...actual, + resolveChordGuideContext: vi.fn(() => ({ + keySignature: 'C major', + mode: 'ionian', + })), + }; +}); + describe('PianoRoll zoom persistence', () => { beforeEach(() => { latestToolbarProps = null; diff --git a/src/components/settings/sections/ChordGuideSettings.test.tsx b/src/components/settings/sections/ChordGuideSettings.test.tsx new file mode 100644 index 0000000..154b455 --- /dev/null +++ b/src/components/settings/sections/ChordGuideSettings.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ChordGuideSettings from './ChordGuideSettings'; + +const configState = new Map([ + ['chord_guide.chord_definition', ''], +]); + +const configManagerMock = { + getIsInitialized: vi.fn(() => true), + initialize: vi.fn().mockResolvedValue(undefined), + get: vi.fn((key: string) => configState.get(key)), + set: vi.fn(async (key: string, value: unknown) => { + configState.set(key, value); + }), +}; + +vi.mock('../../../core/config/ConfigManager', () => ({ + ConfigManager: { + instance: () => configManagerMock, + }, +})); + +vi.mock('../../../util/scaleUtil', async () => { + const actual = await vi.importActual('../../../util/scaleUtil'); + return { + ...actual, + validateFunctionalChordsJSON: vi.fn(() => ({ valid: true, errors: [] })), + }; +}); + +vi.mock('../../../util/dialogUtil', () => ({ + showAlert: vi.fn(), +})); + +vi.mock('../../../core/KGCore', () => ({ + KGCore: { + ORIGINAL_FUNCTIONAL_CHORDS_DATA: {}, + FUNCTIONAL_CHORDS_DATA: {}, + }, +})); + +describe('ChordGuideSettings', () => { + beforeEach(() => { + configState.set('chord_guide.chord_definition', ''); + configManagerMock.get.mockClear(); + configManagerMock.set.mockClear(); + }); + + it('renders the legacy notice for chord guide definitions', async () => { + render(); + + expect(await screen.findByText('Legacy Chord Definition')).toBeTruthy(); + expect( + screen.getByText(/no longer affects chord-guide suggestions in the piano roll/i) + ).toBeTruthy(); + }); +}); diff --git a/src/components/settings/sections/ChordGuideSettings.tsx b/src/components/settings/sections/ChordGuideSettings.tsx index 69ca7b5..97761ff 100644 --- a/src/components/settings/sections/ChordGuideSettings.tsx +++ b/src/components/settings/sections/ChordGuideSettings.tsx @@ -113,7 +113,10 @@ const ChordGuideSettings: React.FC = () => { - Chord Definition + Legacy Chord Definition + + This editor is kept for legacy mode and highlighting behavior. It no longer affects chord-guide suggestions in the piano roll. + { handleChordDefinitionChange(e.target.value)} diff --git a/src/core/ChordGuideTypes.ts b/src/core/ChordGuideTypes.ts new file mode 100644 index 0000000..19e1a28 --- /dev/null +++ b/src/core/ChordGuideTypes.ts @@ -0,0 +1,23 @@ +export interface ChordGuideItem { + name: string; + roman: string; + notes: string[]; + source: string; + note: string; +} + +export interface ChordGuideModeDefinition { + T: ChordGuideItem[]; + S: ChordGuideItem[]; + D: ChordGuideItem[]; +} + +export interface ChordGuideData { + ionian: ChordGuideModeDefinition; + aeolian: ChordGuideModeDefinition; +} + +export interface ResolvedChordGuideItem extends ChordGuideItem { + resolvedNotes: string[]; + pitchClasses: number[]; +} diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 87447ae..b77695f 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -12,6 +12,7 @@ import { KGRegion } from './region/KGRegion'; import { generateUniqueId } from '../util/miscUtil'; import { KGCommand, KGCommandHistory } from './commands'; import { getEffectiveBpmAtBeat } from '../util/globalTrackUtil'; +import type { ChordGuideData } from './ChordGuideTypes'; interface PlaybackStartOptions { preserveLoopPreroll?: boolean; @@ -28,6 +29,10 @@ export class KGCore { // Global music data resources public static ORIGINAL_FUNCTIONAL_CHORDS_DATA: Record }> = {}; // Original functional chords loaded from functional_chords.json public static FUNCTIONAL_CHORDS_DATA: Record }> = {}; // Active functional chords (either original or custom from user settings) + public static CHORD_GUIDE_DATA: ChordGuideData = { + ionian: { T: [], S: [], D: [] }, + aeolian: { T: [], S: [], D: [] }, + }; private currentProject: KGProject = new KGProject(); diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index 8d15a7c..50c3969 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -1,3 +1,5 @@ +import type { ResolvedChordGuideItem } from '../ChordGuideTypes'; + /** * KGPianoRollState - State management for the piano roll * Implements the singleton pattern for global access @@ -21,8 +23,8 @@ export class KGPianoRollState { private sheetQuantization: string = '16,48'; // Chord guide state - private currentSuitableChords: Record = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]}) - private currentSuitableChordsPitchClasses: Record = {}; // Map of chord symbols to pitch classes (e.g., {"I": [0, 4, 7]}) + private currentSuitableChords: ResolvedChordGuideItem[] = []; + private currentSuitableChordsPitchClasses: Record = {}; private currentMatchingChords: number[][] = []; private currentSelectedChordIndex: number = 0; private currentChordCursorPitch: number | null = null; @@ -119,11 +121,11 @@ export class KGPianoRollState { this.sheetQuantization = value; } - public getCurrentSuitableChords(): Record { + public getCurrentSuitableChords(): ResolvedChordGuideItem[] { return this.currentSuitableChords; } - public setCurrentSuitableChords(chords: Record): void { + public setCurrentSuitableChords(chords: ResolvedChordGuideItem[]): void { this.currentSuitableChords = chords; } diff --git a/src/util/chordGuideDataUtil.test.ts b/src/util/chordGuideDataUtil.test.ts new file mode 100644 index 0000000..ef3e0ce --- /dev/null +++ b/src/util/chordGuideDataUtil.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Note } from 'tonal'; +import { KGCore } from '../core/KGCore'; +import { getMatchingChordGuideChordsForPitch, resolveChordGuideItems } from './chordGuideDataUtil'; +import { getChordPitchClasses, parseChordSymbol } from './chordUtil'; +import chordGuideData from '../../public/resources/modes/chord_guide.json'; + +function getExpectedPitchClassesFromNotes(notes: string[]): number[] { + return notes.map((note) => { + const parsed = Note.get(note); + if (parsed.empty || parsed.chroma === undefined) { + throw new Error(`Unable to convert note "${note}" to pitch class`); + } + return parsed.chroma; + }); +} + +describe('chordGuideDataUtil', () => { + beforeEach(() => { + KGCore.CHORD_GUIDE_DATA = chordGuideData; + KGCore.FUNCTIONAL_CHORDS_DATA = { + ionian: { name: 'Broken', steps: [2, 2, 2, 2, 2, 1, 1], T: [], S: [], D: [], chords: {} }, + }; + }); + + it('parses every chord symbol used by chord_guide.json', () => { + const allItems = [ + ...chordGuideData.ionian.T, + ...chordGuideData.ionian.S, + ...chordGuideData.ionian.D, + ...chordGuideData.aeolian.T, + ...chordGuideData.aeolian.S, + ...chordGuideData.aeolian.D, + ]; + + for (const item of allItems) { + const parsed = parseChordSymbol(item.name); + expect(parsed, item.name).not.toBeNull(); + expect(parsed?.symbol).toBe(item.name); + } + }); + + it('maps every chord guide symbol to the corresponding notes declared in chord_guide.json', () => { + const allItems = [ + ...chordGuideData.ionian.T, + ...chordGuideData.ionian.S, + ...chordGuideData.ionian.D, + ...chordGuideData.aeolian.T, + ...chordGuideData.aeolian.S, + ...chordGuideData.aeolian.D, + ]; + + for (const item of allItems) { + const actualPitchClasses = getChordPitchClasses(item.name).map((pitch) => ((pitch % 12) + 12) % 12); + const expectedPitchClasses = getExpectedPitchClassesFromNotes(item.notes); + + expect( + actualPitchClasses, + `${item.name} should resolve to ${item.notes.join(' ')}` + ).toEqual(expectedPitchClasses); + } + }); + + it('resolves ionian tonic chords in C major from the new chord guide data', () => { + const result = resolveChordGuideItems('C major', 'ionian', 'T'); + + expect(result[0]).toMatchObject({ + name: 'C', + roman: 'I', + notes: ['C', 'E', 'G'], + resolvedNotes: ['C', 'E', 'G'], + pitchClasses: [0, 4, 7], + }); + expect(result.find((item) => item.name === 'Am')?.pitchClasses).toEqual([9, 12, 16]); + }); + + it('transposes ionian tonic chords away from C major', () => { + const result = resolveChordGuideItems('D major', 'ionian', 'T'); + + expect(result[0]).toMatchObject({ + name: 'C', + resolvedNotes: ['D', 'F#', 'A'], + pitchClasses: [2, 6, 9], + }); + expect(result.find((item) => item.name === 'Am')?.resolvedNotes).toEqual(['B', 'D', 'F#']); + }); + + it('uses A minor as the aeolian reference tonic', () => { + const result = resolveChordGuideItems('A minor', 'aeolian', 'T'); + + expect(result[0]).toMatchObject({ + name: 'Am', + resolvedNotes: ['A', 'C', 'E'], + pitchClasses: [9, 12, 16], + }); + expect(result.find((item) => item.name === 'Amadd9')?.resolvedNotes).toEqual(['A', 'C', 'E', 'B']); + }); + + it('transposes aeolian tonic chords away from A minor', () => { + const result = resolveChordGuideItems('E minor', 'aeolian', 'T'); + + expect(result[0]).toMatchObject({ + name: 'Am', + resolvedNotes: ['E', 'G', 'B'], + pitchClasses: [4, 7, 11], + }); + expect(result.find((item) => item.name === 'C')?.resolvedNotes).toEqual(['G', 'B', 'D']); + }); + + it('matches hover chords without depending on functional chord config', () => { + const result = getMatchingChordGuideChordsForPitch(60, 'C major', 'ionian', 'T'); + + expect(result[0]).toEqual([0, 4, 7]); + expect(result.some((chord) => chord.includes(0))).toBe(true); + }); +}); diff --git a/src/util/chordGuideDataUtil.ts b/src/util/chordGuideDataUtil.ts new file mode 100644 index 0000000..a813979 --- /dev/null +++ b/src/util/chordGuideDataUtil.ts @@ -0,0 +1,115 @@ +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'; + +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[][] { + const resolvedChords = resolveChordGuideItems(keySignature, mode, functionType); + if (resolvedChords.length === 0) { + return []; + } + + const hoverPitchClass = hoverPitch % 12; + const matchesByPosition: number[][][] = []; + + 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] = []; + } + + const chord = item.pitchClasses[i] >= 12 + ? item.pitchClasses.map((pitch) => pitch - 12) + : item.pitchClasses; + matchesByPosition[i].push(chord); + break; + } + } + + return matchesByPosition.flatMap((matches) => matches ?? []); +} diff --git a/src/util/chordUtil.test.ts b/src/util/chordUtil.test.ts index f6a9ab3..1182ef8 100644 --- a/src/util/chordUtil.test.ts +++ b/src/util/chordUtil.test.ts @@ -15,6 +15,16 @@ describe('chordUtil', () => { expect(parsed?.symbol).toBe('Bm7b5'); }); + it('parses diminished seventh chords as a distinct canonical shape', () => { + const parsed = parseChordSymbol('G#dim7'); + + expect(parsed).not.toBeNull(); + expect(parsed?.root).toBe('G#'); + expect(parsed?.quality).toBe('dim'); + expect(parsed?.extensions).toContain('dim7'); + expect(parsed?.symbol).toBe('G#dim7'); + }); + it('preserves enharmonic root spelling in the canonical symbol', () => { expect(buildChordSymbol({ root: 'Bb', @@ -37,11 +47,13 @@ describe('chordUtil', () => { it('derives stable pitch classes and midi pitches from the stored symbol', () => { expect(getChordPitchClasses('Bm7b5')).toEqual([11, 2, 5, 9]); expect(getChordMidiPitches('Bm7b5', 59)).toEqual([59, 62, 65, 69]); + expect(getChordMidiPitches('Ddim7', 50)).toEqual([50, 53, 56, 59]); }); it('formats the preview using standard chord display conventions', () => { expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)'); expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)'); + expect(formatChordSymbolForDisplay('G#dim7')).toBe('G♯dim7'); }); it('maps C-root chords into the C4-C5 range', () => { diff --git a/src/util/chordUtil.ts b/src/util/chordUtil.ts index f8607da..b0ff446 100644 --- a/src/util/chordUtil.ts +++ b/src/util/chordUtil.ts @@ -6,6 +6,7 @@ export type ChordExtension = | '#5' | '6' | '7' + | 'dim7' | 'maj7' | 'b9' | '9' @@ -23,12 +24,13 @@ export interface ChordDescriptor { } const ROOT_PATTERN = /^[A-G](?:#|b)?$/; -const EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', '6', '7', 'maj7', 'b9', '9', '#9', '11', '#11', 'b13', '13']; +const EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', '6', '7', 'dim7', 'maj7', 'b9', '9', '#9', '11', '#11', 'b13', '13']; const REMAINING_EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', 'b9', '9', '#9', '11', '#11', 'b13', '13']; const ADD_EXTENSION_ORDER: ChordExtension[] = ['b9', '9', '#9', '11', '#11', 'b13', '13']; const CUSTOM_TOKENS = [ 'maj7#5', 'm7b5', + 'dim7', 'sus2', 'sus4', 'aug', @@ -120,6 +122,9 @@ function getDescriptorIntervals(descriptor: Pick extensions.includes(extension); - const hasSeventh = has('7') || has('maj7'); + const hasSeventh = has('7') || has('dim7') || has('maj7'); const remainingExtensions = new Set(extensions); let symbol = root; - if (descriptor.quality === 'dim' && has('7')) { + if (descriptor.quality === 'dim' && has('dim7')) { + symbol += 'dim7'; + remainingExtensions.delete('dim7'); + remainingExtensions.delete('b5'); + } else if (descriptor.quality === 'dim' && has('7')) { symbol += 'm7b5'; remainingExtensions.delete('7'); remainingExtensions.delete('b5'); @@ -425,7 +442,10 @@ export function buildChordSymbol(descriptor: Pick value.replace(/b/g, '♭').replace(/#/g, '♯'); + if (quality === 'dim' && extensions.includes('dim7')) { + return `${accidentalDisplay(root)}dim7`; + } const baseQuality = (() => { switch (quality) { case 'maj': @@ -522,7 +545,7 @@ export function formatChordSymbolForDisplay(symbol: string): string { case 'aug': return 'aug'; case 'dim': - return 'm'; + return extensions.includes('dim7') || !extensions.includes('7') ? 'dim' : 'm'; } })(); @@ -530,7 +553,11 @@ export function formatChordSymbolForDisplay(symbol: string): string { const parentheticalExtensions: string[] = []; for (const extension of extensions) { - if (extension === '7' || extension === 'maj7' || extension === '6') { + if (quality === 'dim' && extension === 'b5' && !extensions.includes('7')) { + continue; + } + + if (extension === '7' || extension === 'dim7' || extension === 'maj7' || extension === '6') { inlineExtensions.push(extension); continue; }