Merge pull request #22 from KGAudioLab/feat/2025-12-08-chord-assistant

Feat/2025 12 08 chord assistant
This commit is contained in:
Xiaohan-Tian
2025-12-17 23:08:01 -08:00
committed by GitHub
13 changed files with 733 additions and 89 deletions
+3
View File
@@ -66,5 +66,8 @@
},
"templates": {
"custom_instructions": ""
},
"chord_guide": {
"chord_definition": ""
}
}
@@ -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"],
-59
View File
@@ -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]
}
]
}
+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;
+39 -12
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,33 +42,58 @@ 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();
// 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 original functional chords data
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = functionalChordsData;
console.log(`Loaded original functional chords for ${Object.keys(functionalChordsData).length} modes`);
// 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`);
// 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.MODE_DATA = [{ id: 'ionian', name: 'Ionian', steps: [2, 2, 1, 2, 2, 2, 1] }];
KGCore.FUNCTIONAL_CHORDS_DATA = {};
const fallbackData = {
ionian: {
name: 'Ionian',
steps: [2, 2, 1, 2, 2, 2, 1],
T: [],
S: [],
D: [],
chords: {}
}
};
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = fallbackData;
KGCore.FUNCTIONAL_CHORDS_DATA = fallbackData;
}
// Log maxBars after initialization completes
@@ -38,7 +38,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<div className="toolbar-left">
{/* Left section with mode and chord guide dropdowns */}
<KGDropdown
options={KGCore.MODE_DATA.map(m => ({ 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"
+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 -2
View File
@@ -18,8 +18,8 @@ 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<string, { T: string[]; S: string[]; D: string[]; chords: Record<string, string[]> }> = {}; // Functional chords by mode (T/S/D) with mode-specific chord notes
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');
+321 -10
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'
@@ -16,15 +17,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
})
@@ -400,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)
})
})
})
})
+134 -3
View File
@@ -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;
};
/**
@@ -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
};
};