feat: implemented chord guide customization feature
This commit is contained in:
@@ -66,5 +66,8 @@
|
|||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"custom_instructions": ""
|
"custom_instructions": ""
|
||||||
|
},
|
||||||
|
"chord_guide": {
|
||||||
|
"chord_definition": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+44
@@ -1751,6 +1751,50 @@ textarea {
|
|||||||
font-size: 12px;
|
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 */
|
||||||
.templates-list {
|
.templates-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+29
-4
@@ -13,6 +13,8 @@ import LoadingOverlay from './components/common/LoadingOverlay';
|
|||||||
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
||||||
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
||||||
import { KGCore } from './core/KGCore';
|
import { KGCore } from './core/KGCore';
|
||||||
|
import { ConfigManager } from './core/config/ConfigManager';
|
||||||
|
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Enable global keyboard handler for copy/paste and undo/redo
|
// Enable global keyboard handler for copy/paste and undo/redo
|
||||||
@@ -40,6 +42,9 @@ function App() {
|
|||||||
// Load the current project from KGCore
|
// Load the current project from KGCore
|
||||||
loadProject(null);
|
loadProject(null);
|
||||||
|
|
||||||
|
// Initialize ConfigManager first to load config.json and user settings
|
||||||
|
await ConfigManager.instance().initialize();
|
||||||
|
|
||||||
// Initialize store from config after ConfigManager is ready
|
// Initialize store from config after ConfigManager is ready
|
||||||
await initializeFromConfig();
|
await initializeFromConfig();
|
||||||
|
|
||||||
@@ -53,13 +58,31 @@ function App() {
|
|||||||
functionalChordsResponse.json()
|
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.FUNCTIONAL_CHORDS_DATA = functionalChordsData;
|
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = functionalChordsData;
|
||||||
console.log(`Loaded functional chords for ${Object.keys(functionalChordsData).length} modes`);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load mode/chord data:', error);
|
console.error('Failed to load mode/chord data:', error);
|
||||||
// Fallback to defaults
|
// Fallback to defaults
|
||||||
KGCore.FUNCTIONAL_CHORDS_DATA = {
|
const fallbackData = {
|
||||||
ionian: {
|
ionian: {
|
||||||
name: 'Ionian',
|
name: 'Ionian',
|
||||||
steps: [2, 2, 1, 2, 2, 2, 1],
|
steps: [2, 2, 1, 2, 2, 2, 1],
|
||||||
@@ -69,6 +92,8 @@ function App() {
|
|||||||
chords: {}
|
chords: {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = fallbackData;
|
||||||
|
KGCore.FUNCTIONAL_CHORDS_DATA = fallbackData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log maxBars after initialization completes
|
// Log maxBars after initialization completes
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import SettingsSidebar from './SettingsSidebar';
|
|||||||
import GeneralSettings from './sections/GeneralSettings';
|
import GeneralSettings from './sections/GeneralSettings';
|
||||||
import BehaviorSettings from './sections/BehaviorSettings';
|
import BehaviorSettings from './sections/BehaviorSettings';
|
||||||
import TemplatesSettings from './sections/TemplatesSettings';
|
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 {
|
interface SettingsPanelProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -21,6 +22,8 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
|
|||||||
return <BehaviorSettings />;
|
return <BehaviorSettings />;
|
||||||
case 'templates':
|
case 'templates':
|
||||||
return <TemplatesSettings />;
|
return <TemplatesSettings />;
|
||||||
|
case 'chord_guide':
|
||||||
|
return <ChordGuideSettings />;
|
||||||
default:
|
default:
|
||||||
return <GeneralSettings />;
|
return <GeneralSettings />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
|
|||||||
const sections = [
|
const sections = [
|
||||||
{ id: 'general' as SettingsSection, label: 'General' },
|
{ id: 'general' as SettingsSection, label: 'General' },
|
||||||
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
|
{ 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 (
|
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
@@ -18,7 +18,8 @@ export class KGCore {
|
|||||||
private static _instance: KGCore | null = null;
|
private static _instance: KGCore | null = null;
|
||||||
|
|
||||||
// Global music data resources
|
// 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();
|
private currentProject: KGProject = new KGProject();
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ interface AppConfig {
|
|||||||
templates: {
|
templates: {
|
||||||
custom_instructions: string;
|
custom_instructions: string;
|
||||||
};
|
};
|
||||||
|
chord_guide: {
|
||||||
|
chord_definition: string;
|
||||||
|
};
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +231,9 @@ export class ConfigManager {
|
|||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
custom_instructions: ''
|
custom_instructions: ''
|
||||||
|
},
|
||||||
|
chord_guide: {
|
||||||
|
chord_definition: ''
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
console.log('Using fallback hardcoded config due to load error');
|
console.log('Using fallback hardcoded config due to load error');
|
||||||
|
|||||||
+320
-1
@@ -7,7 +7,8 @@ import {
|
|||||||
getSuitableChords,
|
getSuitableChords,
|
||||||
getChordNotesInKey,
|
getChordNotesInKey,
|
||||||
getMatchingChordsForPitch,
|
getMatchingChordsForPitch,
|
||||||
generatePianoGridBackground
|
generatePianoGridBackground,
|
||||||
|
validateFunctionalChordsJSON
|
||||||
} from './scaleUtil'
|
} from './scaleUtil'
|
||||||
import { KGCore } from '../core/KGCore'
|
import { KGCore } from '../core/KGCore'
|
||||||
import type { KeySignature } from '../core/KGProject'
|
import type { KeySignature } from '../core/KGProject'
|
||||||
@@ -392,4 +393,322 @@ describe('scaleUtil', () => {
|
|||||||
expect(ionian).not.toBe(dorian)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -350,3 +350,134 @@ export const generatePianoGridBackground = (
|
|||||||
linear-gradient(to bottom, ${horizontalLines})
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user