feat: implemented chord guide customization feature

This commit is contained in:
Xiaohan-Tian
2025-12-17 23:07:21 -08:00
parent 610b81e4cc
commit 5cceec0c01
10 changed files with 696 additions and 8 deletions
+3
View File
@@ -66,5 +66,8 @@
},
"templates": {
"custom_instructions": ""
},
"chord_guide": {
"chord_definition": ""
}
}
+44
View File
@@ -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;
+28 -3
View File
@@ -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)
// 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(`Loaded functional chords for ${Object.keys(functionalChordsData).length} modes`);
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
+4 -1
View File
@@ -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<SettingsPanelProps> = ({ onClose }) => {
return <BehaviorSettings />;
case 'templates':
return <TemplatesSettings />;
case 'chord_guide':
return <ChordGuideSettings />;
default:
return <GeneralSettings />;
}
+2 -1
View File
@@ -16,7 +16,8 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
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 (
@@ -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<string>('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
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 (
<div className="settings-section">
<div className="settings-section-header">
<h3>Chord Guide</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<h4>Chord Definition</h4>
<div className="settings-help-links">
<button
className="settings-help"
onClick={handleLoadDefaultTemplate}
>
Load Default Template
</button>
<button
className="settings-help"
onClick={handleClear}
>
Clear
</button>
</div>
<div className="settings-item">
<textarea
className="settings-textarea"
placeholder="Please input your chord definitions"
rows={8}
value={chordDefinition}
onChange={(e) => handleChordDefinitionChange(e.target.value)}
/>
{validationErrors.length > 0 && (
<div className="settings-validation-errors">
{validationErrors.map((error, index) => (
<div key={index} className="settings-validation-error">
{error}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default ChordGuideSettings;
+2 -1
View File
@@ -18,7 +18,8 @@ export class KGCore {
private static _instance: KGCore | null = null;
// Global music data resources
public static FUNCTIONAL_CHORDS_DATA: Record<string, { name: string; steps: number[]; T: string[]; S: string[]; D: string[]; chords: Record<string, string[]> }> = {}; // Functional chords by mode (T/S/D) with mode-specific chord notes, and mode metadata
public static ORIGINAL_FUNCTIONAL_CHORDS_DATA: Record<string, { name: string; steps: number[]; T: string[]; S: string[]; D: string[]; chords: Record<string, string[]> }> = {}; // Original functional chords loaded from functional_chords.json
public static FUNCTIONAL_CHORDS_DATA: Record<string, { name: string; steps: number[]; T: string[]; S: string[]; D: string[]; chords: Record<string, string[]> }> = {}; // Active functional chords (either original or custom from user settings)
private currentProject: KGProject = new KGProject();
+6
View File
@@ -73,6 +73,9 @@ interface AppConfig {
templates: {
custom_instructions: string;
};
chord_guide: {
chord_definition: string;
};
[key: string]: unknown;
}
@@ -228,6 +231,9 @@ export class ConfigManager {
},
templates: {
custom_instructions: ''
},
chord_guide: {
chord_definition: ''
}
};
console.log('Using fallback hardcoded config due to load error');
+320 -1
View File
@@ -7,7 +7,8 @@ import {
getSuitableChords,
getChordNotesInKey,
getMatchingChordsForPitch,
generatePianoGridBackground
generatePianoGridBackground,
validateFunctionalChordsJSON
} from './scaleUtil'
import { KGCore } from '../core/KGCore'
import type { KeySignature } from '../core/KGProject'
@@ -392,4 +393,322 @@ describe('scaleUtil', () => {
expect(ionian).not.toBe(dorian)
})
})
describe('validateFunctionalChordsJSON', () => {
const validJSON = `{
"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"],
"chords": {
"I": ["C", "E", "G"],
"vi": ["A", "C", "E"],
"iii": ["E", "G", "B"],
"I⁶": ["E", "G", "C"],
"IV": ["F", "A", "C"],
"ii": ["D", "F", "A"],
"IV⁶": ["A", "C", "F"],
"V": ["G", "B", "D"],
"V7": ["G", "B", "D", "F"],
"vii°": ["B", "D", "F"],
"♭II": ["Db", "F", "Ab"]
}
},
"aeolian": {
"name": "Aeolian",
"steps": [2, 1, 2, 2, 1, 2, 2],
"T": ["i", "VI", "III", "i⁶"],
"S": ["iv", "ii°", "VI", "iv⁶"],
"D": ["v", "♭VII"],
"chords": {
"i": ["C", "Eb", "G"],
"VI": ["Ab", "C", "Eb"],
"III": ["Eb", "G", "Bb"],
"i⁶": ["Eb", "G", "C"],
"iv": ["F", "Ab", "C"],
"ii°": ["D", "F", "Ab"],
"iv⁶": ["Ab", "C", "F"],
"v": ["G", "Bb", "D"],
"♭VII": ["Bb", "D", "F"]
}
}
}`
describe('valid JSON', () => {
it('should validate correct JSON with ionian and aeolian', () => {
const result = validateFunctionalChordsJSON(validJSON)
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
})
it('should accept empty T/S/D arrays', () => {
const json = `{
"ionian": {
"name": "Test Mode",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": [],
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(true)
})
it('should accept mode names with underscores, dashes, and spaces', () => {
const json = `{
"ionian": {
"name": "Test_Mode-123 Name",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": [],
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(true)
})
it('should accept notes with sharp and flat', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": ["I"],
"S": [],
"D": [],
"chords": {
"I": ["C#", "Eb", "F#"]
}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(true)
})
it('should accept chord symbols with special characters', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": ["I⁶", "vii°", "III+", "♭II"],
"S": [],
"D": [],
"chords": {
"I⁶": ["C", "E", "G"],
"vii°": ["B", "D", "F"],
"III+": ["E", "G#", "B"],
"♭II": ["Db", "F", "Ab"]
}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(true)
})
})
describe('invalid JSON', () => {
it('should reject malformed JSON', () => {
const result = validateFunctionalChordsJSON('{ invalid json }')
expect(result.valid).toBe(false)
expect(result.errors).toContain('Invalid JSON format')
})
it('should reject JSON array as root', () => {
const result = validateFunctionalChordsJSON('[]')
expect(result.valid).toBe(false)
expect(result.errors).toContain('Root must be an object')
})
it('should reject missing ionian mode', () => {
const json = `{
"dorian": {
"name": "Dorian",
"steps": [2, 1, 2, 2, 2, 1, 2],
"T": [],
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Missing required mode: "ionian"')
})
it('should reject invalid mode name', () => {
const json = `{
"ionian": {
"name": "Test@Mode!",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": [],
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true)
})
it('should reject steps with wrong number of elements', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 1, 2],
"T": [],
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true)
})
it('should reject steps that do not sum to 12', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 2, 2, 2, 2, 2],
"T": [],
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true)
})
it('should reject non-integer steps', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2.5, 2, 1, 2, 2, 2, 0.5],
"T": [],
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true)
})
it('should reject invalid chord symbol in T/S/D', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": ["invalid123"],
"S": [],
"D": [],
"chords": {
"invalid123": ["C", "E", "G"]
}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true)
})
it('should reject chord referenced but not defined', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": ["I"],
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true)
})
it('should reject invalid note name', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": ["I"],
"S": [],
"D": [],
"chords": {
"I": ["C", "X", "G"]
}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
})
it('should reject note with invalid accidental', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": ["I"],
"S": [],
"D": [],
"chords": {
"I": ["C##", "E", "G"]
}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
})
it('should reject lowercase note names', () => {
const json = `{
"ionian": {
"name": "Test",
"steps": [2, 2, 1, 2, 2, 2, 1],
"T": ["I"],
"S": [],
"D": [],
"chords": {
"I": ["c", "e", "g"]
}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
})
})
describe('error messages', () => {
it('should provide descriptive error messages for multiple errors', () => {
const json = `{
"dorian": {
"name": "Test@",
"steps": [2, 2, 1],
"T": "not-array",
"S": [],
"D": [],
"chords": {}
}
}`
const result = validateFunctionalChordsJSON(json)
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(1)
})
})
})
})
+131
View File
@@ -350,3 +350,134 @@ export const generatePianoGridBackground = (
linear-gradient(to bottom, ${horizontalLines})
`;
};
/**
* Validation result for functional chords JSON
*/
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* Validates functional chords JSON structure
* @param jsonString - JSON string to validate
* @returns Validation result with errors if any
*/
export const validateFunctionalChordsJSON = (jsonString: string): ValidationResult => {
const errors: string[] = [];
// Try to parse JSON
let data: unknown;
try {
data = JSON.parse(jsonString);
} catch (error) {
return { valid: false, errors: ['Invalid JSON format'] };
}
// Check if data is an object
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
return { valid: false, errors: ['Root must be an object'] };
}
// Type guard to treat data as a record
const dataRecord = data as Record<string, unknown>;
// Check if ionian mode exists
if (!dataRecord.ionian) {
errors.push('Missing required mode: "ionian"');
}
// Regex patterns
const namePattern = /^[A-Za-z0-9_\- ]+$/;
const romanNumeralPattern = /^♭?[ivIV]+[⁶°+0-9]*$/;
const notePattern = /^[A-G][b#]?$/;
// Validate each mode
for (const [modeId, modeData] of Object.entries(dataRecord)) {
const modePrefix = `Mode "${modeId}"`;
// Validate mode structure
if (typeof modeData !== 'object' || modeData === null || Array.isArray(modeData)) {
errors.push(`${modePrefix}: must be an object`);
continue;
}
// Type guard for mode object
const mode = modeData as Record<string, unknown>;
// Validate name
if (typeof mode.name !== 'string' || !namePattern.test(mode.name)) {
errors.push(`${modePrefix}: "name" must be a string with letters, numbers, underscores, dashes, and spaces`);
}
// Validate steps
if (!Array.isArray(mode.steps)) {
errors.push(`${modePrefix}: "steps" must be an array`);
} else {
if (mode.steps.length !== 7) {
errors.push(`${modePrefix}: "steps" must contain exactly 7 integers`);
}
if (!mode.steps.every((step: unknown) => Number.isInteger(step))) {
errors.push(`${modePrefix}: "steps" must contain only integers`);
}
const sum = mode.steps.reduce((acc: number, val: unknown) => acc + (typeof val === 'number' ? val : 0), 0);
if (sum !== 12) {
errors.push(`${modePrefix}: "steps" must sum to 12 (got ${sum})`);
}
}
// Collect all chord symbols from T, S, D
const allChordSymbols = new Set<string>();
// Validate T, S, D arrays
for (const functionType of ['T', 'S', 'D']) {
if (!Array.isArray(mode[functionType])) {
errors.push(`${modePrefix}: "${functionType}" must be an array`);
continue;
}
for (const chordSymbol of mode[functionType]) {
if (typeof chordSymbol !== 'string' || !romanNumeralPattern.test(chordSymbol)) {
errors.push(`${modePrefix}: Invalid chord symbol "${chordSymbol}" in "${functionType}" (must be Roman numeral I-VII with optional ♭ prefix and/or ⁶°+digit suffixes)`);
}
allChordSymbols.add(chordSymbol);
}
}
// Validate chords object
if (typeof mode.chords !== 'object' || mode.chords === null || Array.isArray(mode.chords)) {
errors.push(`${modePrefix}: "chords" must be an object`);
continue;
}
// Type guard for chords object
const chords = mode.chords as Record<string, unknown>;
// Check if all chord symbols are defined in chords
for (const chordSymbol of allChordSymbols) {
if (!(chordSymbol in chords)) {
errors.push(`${modePrefix}: Chord "${chordSymbol}" referenced in T/S/D but not defined in "chords"`);
}
}
// Validate each chord definition
for (const [chordSymbol, chordNotes] of Object.entries(chords)) {
if (!Array.isArray(chordNotes)) {
errors.push(`${modePrefix}: Chord "${chordSymbol}" must be an array of notes`);
continue;
}
for (const note of chordNotes) {
if (typeof note !== 'string' || !notePattern.test(note)) {
errors.push(`${modePrefix}: Invalid note "${note}" in chord "${chordSymbol}" (must be A-G with optional b or #)`);
}
}
}
}
return {
valid: errors.length === 0,
errors
};
};