From 610b81e4cc82142fe98d4b495790aec67e33daf4 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:10:37 -0800 Subject: [PATCH 1/2] feat: merge `mode_list.json` into `functional_chords.json` --- public/resources/modes/functional_chords.json | 22 +++++++ public/resources/modes/mode_list.json | 59 ------------------- src/App.tsx | 24 ++++---- .../piano-roll/PianoRollToolbar.tsx | 2 +- src/core/KGCore.ts | 3 +- src/util/scaleUtil.test.ts | 10 +--- src/util/scaleUtil.ts | 6 +- 7 files changed, 41 insertions(+), 85 deletions(-) delete mode 100644 public/resources/modes/mode_list.json diff --git a/public/resources/modes/functional_chords.json b/public/resources/modes/functional_chords.json index e0092ca..0324683 100644 --- a/public/resources/modes/functional_chords.json +++ b/public/resources/modes/functional_chords.json @@ -1,5 +1,7 @@ { "ionian": { + "name": "Ionian", + "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["I", "vi", "iii", "I⁶"], "S": ["IV", "ii", "vi", "IV⁶"], "D": ["V", "V7", "vii°", "♭II"], @@ -18,6 +20,8 @@ } }, "aeolian": { + "name": "Aeolian", + "steps": [2, 1, 2, 2, 1, 2, 2], "T": ["i", "VI", "III", "i⁶"], "S": ["iv", "ii°", "VI", "iv⁶"], "D": ["v", "♭VII"], @@ -34,6 +38,8 @@ } }, "harmonic_minor": { + "name": "Harmonic Minor", + "steps": [2, 1, 2, 2, 1, 3, 1], "T": ["i", "VI", "III+", "i⁶"], "S": ["iv", "ii°", "VI", "iv⁶"], "D": ["V", "V7", "vii°", "♭II"], @@ -52,6 +58,8 @@ } }, "melodic_minor": { + "name": "Melodic Minor", + "steps": [2, 1, 2, 2, 2, 2, 1], "T": ["i", "III+", "vi", "i⁶"], "S": ["IV", "ii"], "D": ["V", "vii°"], @@ -67,6 +75,8 @@ } }, "dorian": { + "name": "Dorian", + "steps": [2, 1, 2, 2, 2, 1, 2], "T": ["i", "III", "i⁶"], "S": ["IV", "ii"], "D": ["v", "♭VII"], @@ -81,6 +91,8 @@ } }, "phrygian": { + "name": "Phrygian", + "steps": [1, 2, 2, 2, 1, 2, 2], "T": ["i", "i⁶"], "S": ["♭II", "iv"], "D": [], @@ -92,6 +104,8 @@ } }, "lydian": { + "name": "Lydian", + "steps": [2, 2, 2, 1, 2, 2, 1], "T": ["I", "I⁶"], "S": ["II"], "D": ["V"], @@ -103,6 +117,8 @@ } }, "mixolydian": { + "name": "Mixolydian", + "steps": [2, 2, 1, 2, 2, 1, 2], "T": ["I", "vi"], "S": ["IV", "ii"], "D": ["v", "♭VII"], @@ -116,6 +132,8 @@ } }, "locrian": { + "name": "Locrian", + "steps": [1, 2, 2, 1, 2, 2, 2], "T": ["i°"], "S": ["♭II", "iv"], "D": [], @@ -126,6 +144,8 @@ } }, "phrygian_dominant": { + "name": "Phrygian Dominant", + "steps": [1, 3, 1, 2, 1, 2, 2], "T": ["I", "I⁶"], "S": ["♭II", "iv"], "D": ["v°", "vii"], @@ -139,6 +159,8 @@ } }, "harmonic_major": { + "name": "Harmonic Major", + "steps": [2, 2, 1, 2, 1, 3, 1], "T": ["I", "VI", "iii"], "S": ["IV", "ii"], "D": ["V", "V7", "vii°", "♭II"], diff --git a/public/resources/modes/mode_list.json b/public/resources/modes/mode_list.json deleted file mode 100644 index e255879..0000000 --- a/public/resources/modes/mode_list.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "modes": [ - { - "id": "ionian", - "name": "Ionian", - "steps": [2, 2, 1, 2, 2, 2, 1] - }, - { - "id": "aeolian", - "name": "Aeolian", - "steps": [2, 1, 2, 2, 1, 2, 2] - }, - { - "id": "harmonic_minor", - "name": "Harmonic Minor", - "steps": [2, 1, 2, 2, 1, 3, 1] - }, - { - "id": "melodic_minor", - "name": "Melodic Minor", - "steps": [2, 1, 2, 2, 2, 2, 1] - }, - { - "id": "dorian", - "name": "Dorian", - "steps": [2, 1, 2, 2, 2, 1, 2] - }, - { - "id": "phrygian", - "name": "Phrygian", - "steps": [1, 2, 2, 2, 1, 2, 2] - }, - { - "id": "lydian", - "name": "Lydian", - "steps": [2, 2, 2, 1, 2, 2, 1] - }, - { - "id": "mixolydian", - "name": "Mixolydian", - "steps": [2, 2, 1, 2, 2, 1, 2] - }, - { - "id": "locrian", - "name": "Locrian", - "steps": [1, 2, 2, 1, 2, 2, 2] - }, - { - "id": "phrygian_dominant", - "name": "Phrygian Dominant", - "steps": [1, 3, 1, 2, 1, 2, 2] - }, - { - "id": "harmonic_major", - "name": "Harmonic Major", - "steps": [2, 2, 1, 2, 1, 3, 1] - } - ] -} diff --git a/src/App.tsx b/src/App.tsx index 845296a..9a037a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,28 +45,30 @@ function App() { // Load all mode and chord data files in parallel try { - const [modeListResponse, functionalChordsResponse] = await Promise.all([ - fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`), + const [functionalChordsResponse] = await Promise.all([ fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`) ]); - const [modeListData, functionalChordsData] = await Promise.all([ - modeListResponse.json(), + const [functionalChordsData] = await Promise.all([ functionalChordsResponse.json() ]); - // Store mode data with id, name, and steps - KGCore.MODE_DATA = modeListData.modes; - console.log(`Loaded ${modeListData.modes.length} modes:`, modeListData.modes.map((m: { name: string }) => m.name)); - - // Store functional chords data (T/S/D by mode, including mode-specific chord notes) + // Store functional chords data (includes name, steps, T/S/D groups, and chord notes for each mode) KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData; console.log(`Loaded functional chords for ${Object.keys(functionalChordsData).length} modes`); } catch (error) { 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 = {}; + KGCore.FUNCTIONAL_CHORDS_DATA = { + ionian: { + name: 'Ionian', + steps: [2, 2, 1, 2, 2, 2, 1], + T: [], + S: [], + D: [], + chords: {} + } + }; } // Log maxBars after initialization completes diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 02eda12..ed12ea0 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -38,7 +38,7 @@ const PianoRollToolbar: React.FC = ({
{/* Left section with mode and chord guide dropdowns */} ({ label: m.name, value: m.id }))} + options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))} value={selectedMode} onChange={(value) => onModeChange(value)} label="Mode" diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 5a07788..ddb09e7 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -18,8 +18,7 @@ export class KGCore { 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 + public static FUNCTIONAL_CHORDS_DATA: Record }> = {}; // Functional chords by mode (T/S/D) with mode-specific chord notes, and mode metadata private currentProject: KGProject = new KGProject(); diff --git a/src/util/scaleUtil.test.ts b/src/util/scaleUtil.test.ts index 8dcb2c3..fc089cc 100644 --- a/src/util/scaleUtil.test.ts +++ b/src/util/scaleUtil.test.ts @@ -16,15 +16,7 @@ import functionalChordsData from '../../public/resources/modes/functional_chords describe('scaleUtil', () => { // Setup: Mock KGCore with real chord data beforeEach(() => { - // Mock MODE_DATA with ionian for basic tests - KGCore.MODE_DATA = [ - { id: 'ionian', name: 'Ionian', steps: [2, 2, 1, 2, 2, 2, 1] }, - { id: 'dorian', name: 'Dorian', steps: [2, 1, 2, 2, 2, 1, 2] }, - { id: 'aeolian', name: 'Aeolian', steps: [2, 1, 2, 2, 1, 2, 2] }, - { id: 'mixolydian', name: 'Mixolydian', steps: [2, 2, 1, 2, 2, 1, 2] } - ] - - // Use real functional chords data from JSON file + // Use real functional chords data from JSON file (includes name, steps, and chord data) KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData }) diff --git a/src/util/scaleUtil.ts b/src/util/scaleUtil.ts index 5157220..492b275 100644 --- a/src/util/scaleUtil.ts +++ b/src/util/scaleUtil.ts @@ -66,12 +66,12 @@ 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 = KGCore.MODE_DATA.find(m => m.id === modeId); - if (!modeData) { + const functionalChords = KGCore.FUNCTIONAL_CHORDS_DATA[modeId]; + if (!functionalChords || !functionalChords.steps) { console.warn(`Mode not found: ${modeId}, defaulting to ionian`); return [2, 2, 1, 2, 2, 2, 1]; // Default to ionian (major scale) } - return modeData.steps; + return functionalChords.steps; }; /** From 5cceec0c01cbbbf8dcbcbc9c3125a0b4380c226f Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 17 Dec 2025 23:07:21 -0800 Subject: [PATCH 2/2] feat: implemented chord guide customization feature --- public/config.json | 3 + src/App.css | 44 +++ src/App.tsx | 33 +- src/components/settings/SettingsPanel.tsx | 5 +- src/components/settings/SettingsSidebar.tsx | 3 +- .../settings/sections/ChordGuideSettings.tsx | 155 +++++++++ src/core/KGCore.ts | 3 +- src/core/config/ConfigManager.ts | 6 + src/util/scaleUtil.test.ts | 321 +++++++++++++++++- src/util/scaleUtil.ts | 131 +++++++ 10 files changed, 696 insertions(+), 8 deletions(-) create mode 100644 src/components/settings/sections/ChordGuideSettings.tsx diff --git a/public/config.json b/public/config.json index 91ea264..2c60bbc 100644 --- a/public/config.json +++ b/public/config.json @@ -66,5 +66,8 @@ }, "templates": { "custom_instructions": "" + }, + "chord_guide": { + "chord_definition": "" } } \ No newline at end of file diff --git a/src/App.css b/src/App.css index 271bcfd..6b00ca2 100644 --- a/src/App.css +++ b/src/App.css @@ -1751,6 +1751,50 @@ textarea { font-size: 12px; } +/* Settings Help Links */ +.settings-help-links { + display: flex; + gap: 16px; + margin-bottom: 8px; +} + +button.settings-help { + color: #5a9fd4; + text-decoration: underline; + cursor: pointer; + font-size: 14px; + background: none; + border: none; + padding: 0; + font-family: inherit; +} + +button.settings-help:hover { + color: #7bbfef; +} + +/* Settings Validation Errors */ +.settings-validation-errors { + margin-top: 8px; + padding: 8px; + background-color: rgba(211, 90, 90, 0.1); + border: 1px solid #d35a5a; + border-radius: 4px; + max-height: 200px; + overflow-y: auto; +} + +.settings-validation-error { + color: #ff6b6b; + font-size: 12px; + line-height: 1.4; + margin-bottom: 4px; +} + +.settings-validation-error:last-child { + margin-bottom: 0; +} + /* Templates List */ .templates-list { display: flex; diff --git a/src/App.tsx b/src/App.tsx index 9a037a0..feb3e0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,8 @@ import LoadingOverlay from './components/common/LoadingOverlay'; import { useEffect as useEffectReact, useState, useRef } from 'react'; import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool'; import { KGCore } from './core/KGCore'; +import { ConfigManager } from './core/config/ConfigManager'; +import { validateFunctionalChordsJSON } from './util/scaleUtil'; function App() { // Enable global keyboard handler for copy/paste and undo/redo @@ -40,6 +42,9 @@ function App() { // Load the current project from KGCore loadProject(null); + // Initialize ConfigManager first to load config.json and user settings + await ConfigManager.instance().initialize(); + // Initialize store from config after ConfigManager is ready await initializeFromConfig(); @@ -53,13 +58,31 @@ function App() { functionalChordsResponse.json() ]); - // Store functional chords data (includes name, steps, T/S/D groups, and chord notes for each mode) - KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData; - console.log(`Loaded functional chords for ${Object.keys(functionalChordsData).length} modes`); + // Store original functional chords data + KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = functionalChordsData; + console.log(`Loaded original functional chords for ${Object.keys(functionalChordsData).length} modes`); + + // Check if custom chord definition exists and is valid + const configManager = ConfigManager.instance(); + const customDefinition = configManager.get('chord_guide.chord_definition') as string; + + if (customDefinition && customDefinition.trim()) { + const validationResult = validateFunctionalChordsJSON(customDefinition); + if (validationResult.valid) { + KGCore.FUNCTIONAL_CHORDS_DATA = JSON.parse(customDefinition); + console.log('Using custom chord definition from settings'); + } else { + KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData; + console.log('Custom chord definition invalid, using original'); + } + } else { + KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData; + console.log('No custom chord definition, using original'); + } } catch (error) { console.error('Failed to load mode/chord data:', error); // Fallback to defaults - KGCore.FUNCTIONAL_CHORDS_DATA = { + const fallbackData = { ionian: { name: 'Ionian', steps: [2, 2, 1, 2, 2, 2, 1], @@ -69,6 +92,8 @@ function App() { chords: {} } }; + KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = fallbackData; + KGCore.FUNCTIONAL_CHORDS_DATA = fallbackData; } // Log maxBars after initialization completes diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index ead5619..599a1ba 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -3,8 +3,9 @@ import SettingsSidebar from './SettingsSidebar'; import GeneralSettings from './sections/GeneralSettings'; import BehaviorSettings from './sections/BehaviorSettings'; import TemplatesSettings from './sections/TemplatesSettings'; +import ChordGuideSettings from './sections/ChordGuideSettings'; -export type SettingsSection = 'general' | 'behavior' | 'templates'; +export type SettingsSection = 'general' | 'behavior' | 'templates' | 'chord_guide'; interface SettingsPanelProps { onClose: () => void; @@ -21,6 +22,8 @@ const SettingsPanel: React.FC = ({ onClose }) => { return ; case 'templates': return ; + case 'chord_guide': + return ; default: return ; } diff --git a/src/components/settings/SettingsSidebar.tsx b/src/components/settings/SettingsSidebar.tsx index 3cfc896..d85f1c1 100644 --- a/src/components/settings/SettingsSidebar.tsx +++ b/src/components/settings/SettingsSidebar.tsx @@ -16,7 +16,8 @@ const SettingsSidebar: React.FC = ({ const sections = [ { id: 'general' as SettingsSection, label: 'General' }, { id: 'behavior' as SettingsSection, label: 'Behavior' }, - { id: 'templates' as SettingsSection, label: 'Templates' } + { id: 'templates' as SettingsSection, label: 'Templates' }, + { id: 'chord_guide' as SettingsSection, label: 'Chord Guide' } ]; return ( diff --git a/src/components/settings/sections/ChordGuideSettings.tsx b/src/components/settings/sections/ChordGuideSettings.tsx new file mode 100644 index 0000000..cb3eefb --- /dev/null +++ b/src/components/settings/sections/ChordGuideSettings.tsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { ConfigManager } from '../../../core/config/ConfigManager'; +import { validateFunctionalChordsJSON } from '../../../util/scaleUtil'; +import { KGCore } from '../../../core/KGCore'; + +const ChordGuideSettings: React.FC = () => { + const [chordDefinition, setChordDefinition] = useState(''); + const [validationErrors, setValidationErrors] = useState([]); + + const configManager = ConfigManager.instance(); + + // Load configuration values on component mount + useEffect(() => { + const loadConfig = async () => { + if (!configManager.getIsInitialized()) { + await configManager.initialize(); + } + + setChordDefinition((configManager.get('chord_guide.chord_definition') as string) || ''); + }; + + loadConfig(); + }, [configManager]); + + // Debounced save function for textarea + const debouncedSave = useCallback((value: string) => { + const timeoutId = setTimeout(async () => { + try { + await configManager.set('chord_guide.chord_definition', value); + console.log('Chord definition saved'); + } catch (error) { + console.error('Failed to save chord definition:', error); + } + }, 1000); // 1 second debounce for longer text + + return () => clearTimeout(timeoutId); + }, [configManager]); + + // Save configuration when value changes + const handleChordDefinitionChange = (value: string) => { + setChordDefinition(value); + + // Validate the JSON + if (value.trim()) { + const validationResult = validateFunctionalChordsJSON(value); + setValidationErrors(validationResult.valid ? [] : validationResult.errors); + + // Update FUNCTIONAL_CHORDS_DATA if valid and non-empty + if (validationResult.valid) { + try { + KGCore.FUNCTIONAL_CHORDS_DATA = JSON.parse(value); + console.log('Applied custom chord definition'); + } catch (error) { + console.error('Failed to parse chord definition:', error); + KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; + } + } else { + // Revert to original if invalid + KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; + console.log('Invalid chord definition, reverted to original'); + } + } else { + setValidationErrors([]); + // Revert to original if empty + KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; + console.log('Chord definition cleared, reverted to original'); + } + + debouncedSave(value); + }; + + // Load default template from functional_chords.json (preserving original formatting) + const handleLoadDefaultTemplate = async () => { + try { + const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`); + if (!response.ok) { + throw new Error(`Failed to fetch functional_chords.json: ${response.status}`); + } + // Get the raw text to preserve original formatting + const rawText = await response.text(); + setChordDefinition(rawText); + setValidationErrors([]); // Clear errors when loading valid template + await configManager.set('chord_guide.chord_definition', rawText); + + // Revert to original if empty + KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; + + console.log('Loaded default chord template'); + } catch (error) { + console.error('Failed to load default template:', error); + alert('Failed to load default template. Please check the console for details.'); + } + }; + + // Clear the chord definition + const handleClear = async () => { + setChordDefinition(''); + setValidationErrors([]); // Clear errors when clearing + await configManager.set('chord_guide.chord_definition', ''); + + // Revert to original if empty + KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; + + console.log('Chord definition cleared'); + }; + + return ( +
+
+

Chord Guide

+
+ +
+
+

Chord Definition

+
+ + +
+ +
+