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. +