From 8512fb42040e70391445d88604c51112ff7a2a8a Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:52:23 -0800 Subject: [PATCH] feat: added logic to get suitable chords (console log only) --- src/App.tsx | 49 +++++-- src/components/piano-roll/PianoRoll.tsx | 63 +++++++- .../piano-roll/PianoRollToolbar.tsx | 22 ++- src/core/KGCore.ts | 4 + src/core/state/KGPianoRollState.ts | 21 ++- src/util/scaleUtil.ts | 135 +++++++++++++++++- 6 files changed, 274 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index cec4e17..845296a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,21 +12,30 @@ import { SettingsPanel } from './components/settings'; import LoadingOverlay from './components/common/LoadingOverlay'; import { useEffect as useEffectReact, useState, useRef } from 'react'; import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool'; -import { KGPianoRollState } from './core/state/KGPianoRollState'; +import { KGCore } from './core/KGCore'; function App() { // Enable global keyboard handler for copy/paste and undo/redo useGlobalKeyboardHandler(); // Use project store instead of local state for project name and tracks - const { + const { refreshStatus, - loadProject, maxBars, showChatBox, showSettings, setShowSettings, initializeFromConfig, + loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig, showInstrumentSelection } = useProjectStore(); + // Track if app has been initialized to prevent multiple initializations + const hasInitialized = useRef(false); + // Load project when component mounts useEffect(() => { + // Guard against multiple initializations (can happen due to React Strict Mode or rerenders) + if (hasInitialized.current) { + return; + } + hasInitialized.current = true; + const initializeApp = async () => { // Load the current project from KGCore loadProject(null); @@ -34,27 +43,39 @@ function App() { // Initialize store from config after ConfigManager is ready await initializeFromConfig(); - // Load mode list from JSON + // Load all mode and chord data files in parallel try { - const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`); - const data = await response.json(); + const [modeListResponse, functionalChordsResponse] = await Promise.all([ + fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`), + fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`) + ]); + + const [modeListData, functionalChordsData] = await Promise.all([ + modeListResponse.json(), + functionalChordsResponse.json() + ]); // Store mode data with id, name, and steps - KGPianoRollState.MODE_DATA = data.modes; + KGCore.MODE_DATA = modeListData.modes; + console.log(`Loaded ${modeListData.modes.length} modes:`, modeListData.modes.map((m: { name: string }) => m.name)); - console.log(`Loaded ${data.modes.length} modes:`, data.modes.map((m: { name: string }) => m.name)); + // Store functional chords data (T/S/D by mode, including mode-specific chord notes) + KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData; + console.log(`Loaded functional chords for ${Object.keys(functionalChordsData).length} modes`); } catch (error) { - console.error('Failed to load mode list:', error); - // Fallback to default mode - KGPianoRollState.MODE_DATA = [{ id: 'ionian', name: 'Ionian', steps: [2, 2, 1, 2, 2, 2, 1] }]; + console.error('Failed to load mode/chord data:', error); + // Fallback to defaults + KGCore.MODE_DATA = [{ id: 'ionian', name: 'Ionian', steps: [2, 2, 1, 2, 2, 2, 1] }]; + KGCore.FUNCTIONAL_CHORDS_DATA = {}; } - // Log maxBars to console - console.log(`Project max bars: ${maxBars}`); + // Log maxBars after initialization completes + const currentMaxBars = useProjectStore.getState().maxBars; + console.log(`Project max bars: ${currentMaxBars}`); }; initializeApp(); - }, [loadProject, maxBars, initializeFromConfig]); + }, [loadProject, initializeFromConfig]); // Refresh status periodically to ensure UI is in sync with KGCore useEffect(() => { diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 8f88505..96ad3e5 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -13,6 +13,7 @@ import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { ConfigManager } from '../../core/config/ConfigManager'; import { beatsToBar } from '../../util/midiUtil'; import { UpdateRegionCommand } from '../../core/commands'; +import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil'; interface PianoRollProps { onClose: () => void; @@ -39,6 +40,9 @@ const PianoRoll: React.FC = ({ // Snapping state const [snapping, setSnapping] = useState('NO SNAP'); + // Chord guide state + const [chordGuide, setChordGuide] = useState('N'); + // Piano roll state with temporary initial values const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 }); @@ -303,7 +307,62 @@ const PianoRoll: React.FC = ({ console.log(`Selected mode: ${value}`); } }, [setSelectedMode]); - + + // Handle chord guide selection + const handleChordGuideSelect = useCallback((value: string) => { + setChordGuide(value); + }, []); + + // Update suitable chords whenever chord guide, key signature, or mode changes + useEffect(() => { + const pianoRollState = KGPianoRollState.instance(); + + if (chordGuide === 'N') { + // Disabled - clear chord data + pianoRollState.setCurrentSuitableChords({}); + pianoRollState.setCurrentSuitableChordsPitchClasses({}); + + if (DEBUG_MODE.PIANO_ROLL) { + console.log(`Chord guide disabled - cleared suitable chords`); + } + } else { + // Get suitable chords for the selected function (T/S/D) + const functionType = chordGuide as 'T' | 'S' | 'D'; + const suitableChords = getSuitableChords(keySignature, selectedMode, 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; + } + + // Update piano roll state + pianoRollState.setCurrentSuitableChords(suitableChords); + pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses); + + if (DEBUG_MODE.PIANO_ROLL) { + console.log(`Chord guide updated: ${chordGuide} (${functionType})`); + console.log(`Suitable chords for ${keySignature} in ${selectedMode} mode:`, suitableChords); + console.log(`Pitch classes:`, chordsPitchClasses); + } + } + }, [chordGuide, keySignature, selectedMode]); + // Handler for receiving the setNoteUpdateCounter function from PianoRollContent const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch>) => { triggerNoteUpdateRef.current = setNoteFn; @@ -775,6 +834,8 @@ const PianoRoll: React.FC = ({ onSnappingSelect={handleSnappingSelect} selectedMode={selectedMode} onModeChange={handleModeSelect} + chordGuide={chordGuide} + onChordGuideChange={handleChordGuideSelect} blinkButton={blinkButton} /> diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 92b3d42..02eda12 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { FaMousePointer, FaPencilAlt } from 'react-icons/fa'; import { KGDropdown } from '../common'; import { KGPianoRollState } from '../../core/state/KGPianoRollState'; +import { KGCore } from '../../core/KGCore'; interface PianoRollToolbarProps { activeTool: 'pointer' | 'pencil'; @@ -13,6 +14,8 @@ interface PianoRollToolbarProps { onSnappingSelect: (value: string) => void; selectedMode: string; onModeChange: (value: string) => void; + chordGuide: string; + onChordGuideChange: (value: string) => void; blinkButton?: string | null; } @@ -26,20 +29,35 @@ const PianoRollToolbar: React.FC = ({ onSnappingSelect, selectedMode, onModeChange, + chordGuide, + onChordGuideChange, blinkButton = null }) => { return (
- {/* Left section with mode dropdown */} + {/* Left section with mode and chord guide dropdowns */} ({ label: m.name, value: m.id }))} + options={KGCore.MODE_DATA.map(m => ({ label: m.name, value: m.id }))} value={selectedMode} onChange={(value) => onModeChange(value)} label="Mode" buttonClassName="mode-dropdown" showValueAsLabel={true} /> + onChordGuideChange(value)} + label="Chord" + buttonClassName="chord-guide-dropdown" + showValueAsLabel={true} + />
diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index aee612c..5a07788 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -17,6 +17,10 @@ export class KGCore { // Private static instance for singleton pattern private static _instance: KGCore | null = null; + // Global music data resources + public static MODE_DATA: Array<{ id: string; name: string; steps: number[] }> = []; // Modes with id, display name, and interval steps + public static FUNCTIONAL_CHORDS_DATA: Record }> = {}; // Functional chords by mode (T/S/D) with mode-specific chord notes + private currentProject: KGProject = new KGProject(); private status: string = "Ready"; diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index 1f1ca97..c2ca883 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -8,13 +8,16 @@ export class KGPianoRollState { public static SNAP_OPTIONS: string[] = ['NO SNAP', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; public static QUANT_LEN_OPTIONS: string[] = ['1/1', '1/2', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; - public static MODE_DATA: Array<{ id: string; name: string; steps: number[] }> = []; // Modes with id, display name, and interval steps private activeTool: string = "pointer"; private currentSnap: string = "NO SNAP"; private lastEditedNoteLength: number = 1; // Default to 1 beat private currentMode: string = "ionian"; // Default mode + // 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 constructor() { console.log("KGPianoRollState initialized"); } @@ -58,4 +61,20 @@ export class KGPianoRollState { public setCurrentMode(mode: string): void { this.currentMode = mode; } + + public getCurrentSuitableChords(): Record { + return this.currentSuitableChords; + } + + public setCurrentSuitableChords(chords: Record): void { + this.currentSuitableChords = chords; + } + + public getCurrentSuitableChordsPitchClasses(): Record { + return this.currentSuitableChordsPitchClasses; + } + + public setCurrentSuitableChordsPitchClasses(chordsPitchClasses: Record): void { + this.currentSuitableChordsPitchClasses = chordsPitchClasses; + } } \ No newline at end of file diff --git a/src/util/scaleUtil.ts b/src/util/scaleUtil.ts index 3030705..fa0982d 100644 --- a/src/util/scaleUtil.ts +++ b/src/util/scaleUtil.ts @@ -1,4 +1,4 @@ -import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import { KGCore } from '../core/KGCore'; import { pianoRollIndexToPitch } from './midiUtil'; import type { KeySignature } from '../core/KGProject'; @@ -66,7 +66,7 @@ export const getScalePitchClasses = (rootNote: string, modeSteps: number[]): num * @returns Array of interval steps, or default ionian if not found */ export const getModeSteps = (modeId: string): number[] => { - const modeData = KGPianoRollState.MODE_DATA.find(m => m.id === modeId); + const modeData = KGCore.MODE_DATA.find(m => m.id === modeId); if (!modeData) { console.warn(`Mode not found: ${modeId}, defaulting to ionian`); return [2, 2, 1, 2, 2, 2, 1]; // Default to ionian (major scale) @@ -74,6 +74,137 @@ export const getModeSteps = (modeId: string): number[] => { return modeData.steps; }; +/** + * Transposes a note name by a given number of semitones + * @param noteName - Note name like "C", "C#", "Db" + * @param semitones - Number of semitones to transpose (positive or negative) + * @returns Transposed note name + */ +const transposeNote = (noteName: string, semitones: number): string => { + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const pitchClass = noteNameToPitchClass(noteName); + const newPitchClass = (pitchClass + semitones + 12) % 12; + return noteNames[newPitchClass]; +}; + +/** + * Gets the list of suitable chords for a given key, mode, and functional harmony group + * @param keySignature - Key signature like "C major", "F# minor", "Bb major" + * @param modeId - Mode ID (e.g., "ionian", "aeolian", "dorian") + * @param functionType - Functional harmony group: "T" (Tonic), "S" (Subdominant), or "D" (Dominant) + * @returns Map of chord symbols to their transposed note arrays, e.g., { "I": ["C", "E", "G"], "vi": ["A", "C", "E"] } + * Returns empty object if mode or function not found + * + * Example: + * - getSuitableChords("C major", "ionian", "T") returns { "I": ["C", "E", "G"], "vi": ["A", "C", "E"], ... } + * - getSuitableChords("D major", "ionian", "T") returns { "I": ["D", "F#", "A"], "vi": ["B", "D", "F#"], ... } + */ +export const getSuitableChords = ( + keySignature: KeySignature, + modeId: string, + functionType: 'T' | 'S' | 'D' +): Record => { + // Get the functional chords for this mode + const functionalChords = KGCore.FUNCTIONAL_CHORDS_DATA[modeId]; + if (!functionalChords) { + console.warn(`No functional chords found for mode: ${modeId}`); + return {}; + } + + // Get the chord symbols for the specified function (T/S/D) + const chordSymbols = functionalChords[functionType]; + if (!chordSymbols || chordSymbols.length === 0) { + console.warn(`No chords found for function ${functionType} in mode ${modeId}`); + return {}; + } + + // Get the mode-specific chords data + const modeChords = functionalChords.chords; + if (!modeChords) { + console.warn(`No chords data found for mode: ${modeId}`); + return {}; + } + + // Get the root note from key signature + const rootNote = getRootNoteFromKeySignature(keySignature); + + // Calculate the transposition interval from C to the root note + const transposeSemitones = noteNameToPitchClass(rootNote); + + // Build a map of chord symbols to transposed notes + const chordMap: Record = {}; + + for (const chordSymbol of chordSymbols) { + // Get the chord notes from mode-specific chords data (in C key) + const chordNotes = modeChords[chordSymbol]; + if (!chordNotes) { + console.warn(`Chord symbol not found in mode ${modeId} chords: ${chordSymbol}`); + continue; // Skip this chord if not found + } + + // If the key is C, use notes as-is; otherwise transpose + if (rootNote === 'C') { + chordMap[chordSymbol] = chordNotes; + } else { + // Transpose each note in the chord + const transposedNotes = chordNotes.map(note => transposeNote(note, transposeSemitones)); + chordMap[chordSymbol] = transposedNotes; + } + } + + return chordMap; +}; + +/** + * Gets the transposed notes for a specific chord in a given key and mode + * @param chordSymbol - Chord symbol (e.g., "I", "V7", "ii") + * @param keySignature - Key signature like "C major", "F# minor" + * @param modeId - Mode ID (e.g., "ionian", "aeolian") + * @returns Array of note names for the chord in the specified key, or empty array if not found + */ +export const getChordNotesInKey = ( + chordSymbol: string, + keySignature: KeySignature, + modeId: string +): string[] => { + // Get the functional chords for this mode + const functionalChords = KGCore.FUNCTIONAL_CHORDS_DATA[modeId]; + if (!functionalChords) { + console.warn(`No functional chords found for mode: ${modeId}`); + return []; + } + + // Get the mode-specific chords data + const modeChords = functionalChords.chords; + if (!modeChords) { + console.warn(`No chords data found for mode: ${modeId}`); + return []; + } + + // Get the root note from key signature + const rootNote = getRootNoteFromKeySignature(keySignature); + + // Get the chord notes from mode-specific chords data (in C key) + const chordNotes = modeChords[chordSymbol]; + if (!chordNotes) { + console.warn(`Chord symbol not found in mode ${modeId} chords: ${chordSymbol}`); + return []; + } + + // If the key is C, return the notes as-is + if (rootNote === 'C') { + return chordNotes; + } + + // Calculate the transposition interval from C to the root note + const transposeSemitones = noteNameToPitchClass(rootNote); + + // Transpose each note in the chord + const transposedNotes = chordNotes.map(note => transposeNote(note, transposeSemitones)); + + return transposedNotes; +}; + /** * Generates the CSS background-image string for the piano grid with scale highlighting * @param selectedMode - Current mode ID (e.g., "ionian", "dorian")