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

Feat/2025 12 08 chord assistant
This commit is contained in:
Xiaohan-Tian
2025-12-15 22:19:07 -08:00
committed by GitHub
21 changed files with 1514 additions and 60 deletions
+18 -3
View File
@@ -8,6 +8,10 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
**K.G.Studio Musician Assistant** is an AI assistance agent for harmony, arrangement, and note editing — but not full autocomposition. **K.G.Studio Musician Assistant** is an AI assistance agent for harmony, arrangement, and note editing — but not full autocomposition.
## Latest Updates
- **2025.12.15**: Added Intelligent Chord Assistant with functional harmony guidance (T/S/D). Hover over piano keys to see context-aware chord suggestions and create full chords with one click!
## Project Status ## Project Status
**K.G.Studio is an experimental project in early development.** We're exploring the possibilities of integrating AI agents and LLMs into music production workflows — essentially building a "Cursor or Claude Code for DAW" experience. **K.G.Studio is an experimental project in early development.** We're exploring the possibilities of integrating AI agents and LLMs into music production workflows — essentially building a "Cursor or Claude Code for DAW" experience.
@@ -70,12 +74,13 @@ You can find the detailed user guide [here](./docs/USER_GUIDE.md).
### Highlights ### Highlights
- **K.G.Studio Musician Assistant**: Chat with the LLMpowered K.G.Studio Musician Assistant AI Agent; it can automatically execute tools to make music edits. - **K.G.Studio Musician Assistant**: Chat with the LLMpowered K.G.Studio Musician Assistant AI Agent; it can automatically execute tools to make music edits.
- **Intelligent Chord Assistant**: Real-time chord suggestions based on functional harmony (Tonic/Subdominant/Dominant) with visual preview and one-click chord creation.
- **Multiple LLM providers**: OpenAI, Claude (via OpenRouter), Gemini (via OpenRouter), or OpenAIcompatible (e.g., Ollama, OpenRouter). - **Multiple LLM providers**: OpenAI, Claude (via OpenRouter), Gemini (via OpenRouter), or OpenAIcompatible (e.g., Ollama, OpenRouter).
- **Track & Region editing**: Add/reorder tracks, create/move/resize regions, copy/paste regions, and more. - **Track & Region editing**: Add/reorder tracks, create/move/resize regions, copy/paste regions, and more.
- **Piano roll**: Create and edit notes with snapping/quantization support. - **Piano roll**: Create and edit notes with snapping/quantization support.
- **Real instruments**: Tone.jsbased Sampler with highquality FluidR3 soundfonts. - **Real instruments**: Tone.jsbased Sampler with highquality FluidR3 soundfonts.
- **Undo/Redo everywhere**: Command pattern for tracks, regions, notes, and project properties - **Undo/Redo everywhere**: Command pattern for tracks, regions, notes, and project properties
- **Persistence with privacy**: Save/load projects and configuration in your browsers IndexedDB (on your device). - **Persistence with privacy**: Save/load projects and configuration in your browser's IndexedDB (on your device).
- **Export/Import**: Export your project as a MIDI file, or import a MIDI file into your project. - **Export/Import**: Export your project as a MIDI file, or import a MIDI file into your project.
- **Settings**: LLM provider, AI agent custom instructions, app behavior, and more. - **Settings**: LLM provider, AI agent custom instructions, app behavior, and more.
@@ -142,6 +147,13 @@ You can find the detailed user guide [here](./docs/USER_GUIDE.md).
- Move/resize: drag note body to move selected notes; drag edges to resize. - Move/resize: drag note body to move selected notes; drag edges to resize.
- Close the piano roll with X or ESC. - Close the piano roll with X or ESC.
- Intelligent Chord Assistant (Added 2025-12-15)
- Enable chord guide from the piano roll toolbar: select T (Tonic), S (Subdominant), or D (Dominant) function.
- Hover over any key to see context-aware chord suggestions highlighted in red, matching your selected key signature and mode.
- Press Tab to cycle through different chord voicings for the same harmonic function.
- Double-click (or Ctrl/Cmd+click) on a highlighted chord to create all notes at once.
- Chord length automatically matches your last edited note for consistent rhythm.
- Snapping and Quantize - Snapping and Quantize
- Set snapping from the NO SNAP menu (topright). - Set snapping from the NO SNAP menu (topright).
- Quantize timing with Qua. Pos. (start) and Qua. Len. (length). - Quantize timing with Qua. Pos. (start) and Qua. Len. (length).
@@ -216,8 +228,11 @@ K.G.Studio does not provide or host any of the models listed above, nor is it af
## Upcoming Features ## Upcoming Features
- [ ] More instruments Feature priorities might change.
- [ ] Automated testing (unit tests, integration tests, etc.)
- [X] More instruments
- [X] Automated testing (unit tests, integration tests, etc.)
- [X] Intelligent Chord Assistant with functional harmony guidance (T/S/D)
- [ ] Support track control automations (e.g. sustain, volume, pan, etc.) - [ ] Support track control automations (e.g. sustain, volume, pan, etc.)
- [ ] Support MIDI control events (e.g. CC, pitch bend, etc.) - [ ] Support MIDI control events (e.g. CC, pitch bend, etc.)
- [ ] Support WAV audio tracks - [ ] Support WAV audio tracks
+1
View File
@@ -42,6 +42,7 @@
"save": "ctrl+s" "save": "ctrl+s"
}, },
"piano_roll": { "piano_roll": {
"switch": "tab",
"select": "q", "select": "q",
"pencil": "w", "pencil": "w",
"hold_to_create_note": "ctrl", "hold_to_create_note": "ctrl",
@@ -0,0 +1,157 @@
{
"ionian": {
"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": {
"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"]
}
},
"harmonic_minor": {
"T": ["i", "VI", "III+", "i⁶"],
"S": ["iv", "ii°", "VI", "iv⁶"],
"D": ["V", "V7", "vii°", "♭II"],
"chords": {
"i": ["C", "Eb", "G"],
"VI": ["Ab", "C", "Eb"],
"III+": ["Eb", "G", "B"],
"i⁶": ["Eb", "G", "C"],
"iv": ["F", "Ab", "C"],
"ii°": ["D", "F", "Ab"],
"iv⁶": ["Ab", "C", "F"],
"V": ["G", "B", "D"],
"V7": ["G", "B", "D", "F"],
"vii°": ["B", "D", "F"],
"♭II": ["Db", "F", "Ab"]
}
},
"melodic_minor": {
"T": ["i", "III+", "vi", "i⁶"],
"S": ["IV", "ii"],
"D": ["V", "vii°"],
"chords": {
"i": ["C", "Eb", "G"],
"III+": ["Eb", "G", "B"],
"vi": ["A", "C", "E"],
"i⁶": ["Eb", "G", "C"],
"IV": ["F", "A", "C"],
"ii": ["D", "F", "A"],
"V": ["G", "B", "D"],
"vii°": ["B", "D", "F"]
}
},
"dorian": {
"T": ["i", "III", "i⁶"],
"S": ["IV", "ii"],
"D": ["v", "♭VII"],
"chords": {
"i": ["C", "Eb", "G"],
"III": ["Eb", "G", "Bb"],
"i⁶": ["Eb", "G", "C"],
"IV": ["F", "A", "C"],
"ii": ["D", "F", "A"],
"v": ["G", "Bb", "D"],
"♭VII": ["Bb", "D", "F"]
}
},
"phrygian": {
"T": ["i", "i⁶"],
"S": ["♭II", "iv"],
"D": [],
"chords": {
"i": ["C", "Eb", "G"],
"i⁶": ["Eb", "G", "C"],
"♭II": ["Db", "F", "Ab"],
"iv": ["F", "Ab", "C"]
}
},
"lydian": {
"T": ["I", "I⁶"],
"S": ["II"],
"D": ["V"],
"chords": {
"I": ["C", "E", "G"],
"I⁶": ["E", "G", "C"],
"II": ["D", "F#", "A"],
"V": ["G", "B", "D"]
}
},
"mixolydian": {
"T": ["I", "vi"],
"S": ["IV", "ii"],
"D": ["v", "♭VII"],
"chords": {
"I": ["C", "E", "G"],
"vi": ["A", "C", "E"],
"IV": ["F", "A", "C"],
"ii": ["D", "F", "A"],
"v": ["G", "Bb", "D"],
"♭VII": ["Bb", "D", "F"]
}
},
"locrian": {
"T": ["i°"],
"S": ["♭II", "iv"],
"D": [],
"chords": {
"i°": ["C", "Eb", "Gb"],
"♭II": ["Db", "F", "Ab"],
"iv": ["F", "Ab", "C"]
}
},
"phrygian_dominant": {
"T": ["I", "I⁶"],
"S": ["♭II", "iv"],
"D": ["v°", "vii"],
"chords": {
"I": ["C", "E", "G"],
"I⁶": ["E", "G", "C"],
"♭II": ["Db", "F", "Ab"],
"iv": ["F", "Ab", "C"],
"v°": ["G", "Bb", "Db"],
"vii": ["Bb", "Db", "F"]
}
},
"harmonic_major": {
"T": ["I", "VI", "iii"],
"S": ["IV", "ii"],
"D": ["V", "V7", "vii°", "♭II"],
"chords": {
"I": ["C", "E", "G"],
"VI": ["Ab", "C", "Eb"],
"iii": ["E", "G", "B"],
"IV": ["F", "A", "C"],
"ii": ["D", "F", "A"],
"V": ["G", "B", "D"],
"V7": ["G", "B", "D", "F"],
"vii°": ["B", "D", "F"],
"♭II": ["Db", "F", "Ab"]
}
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"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]
}
]
}
+21 -13
View File
@@ -597,6 +597,7 @@ body {
padding: 0 10px; padding: 0 10px;
user-select: none; user-select: none;
position: relative; position: relative;
z-index: 100; /* Ensure toolbar and its dropdowns appear above piano roll content */
} }
/* Override pointer-events for piano roll toolbar sections */ /* Override pointer-events for piano roll toolbar sections */
@@ -609,6 +610,15 @@ body {
font-size: 10px; font-size: 10px;
} }
.piano-roll-toolbar .toolbar-left .quant-button {
margin-left: 0px;
margin-right: 5px;
}
.piano-roll-toolbar .toolbar-left .quant-dropdown {
left: 0;
}
/* Generic button blink effect */ /* Generic button blink effect */
.quant-button.button-blink { .quant-button.button-blink {
animation: buttonBlink 0.2s ease-in-out; animation: buttonBlink 0.2s ease-in-out;
@@ -853,19 +863,8 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
background-size: var(--region-grid-beat-width) var(--region-piano-key-height), var(--region-grid-bar-width) var(--region-piano-key-height); background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%;
background-image: /* background-image is now set dynamically via React inline styles in PianoGrid component */
/* Vertical lines for beats */
linear-gradient(to right,
transparent calc(var(--region-grid-beat-width) - 1px), #444 calc(var(--region-grid-beat-width) - 1px), #444 var(--region-grid-beat-width),
transparent calc(var(--region-grid-beat-width)), transparent calc(var(--region-grid-beat-width) * 2 - 1px), #444 calc(var(--region-grid-beat-width) * 2 - 1px), #444 calc(var(--region-grid-beat-width) * 2),
transparent calc(var(--region-grid-beat-width) * 2), transparent calc(var(--region-grid-beat-width) * 3 - 1px), #444 calc(var(--region-grid-beat-width) * 3 - 1px), #444 calc(var(--region-grid-beat-width) * 3),
transparent calc(var(--region-grid-beat-width) * 3), transparent calc(var(--region-grid-beat-width) * 4 - 1px), #3a3a3a calc(var(--region-grid-beat-width) * 4 - 1px), #3a3a3a calc(var(--region-grid-beat-width) * 4)
),
/* Horizontal lines for notes */
linear-gradient(to bottom,
transparent calc(var(--region-piano-key-height) - 1px), #3a3a3a calc(var(--region-piano-key-height) - 1px), #3a3a3a var(--region-piano-key-height)
);
} }
.piano-grid.pencil-cursor { .piano-grid.pencil-cursor {
@@ -897,6 +896,15 @@ body {
transition: opacity 0.1s ease; transition: opacity 0.1s ease;
} }
.piano-grid-chord-highlight {
position: absolute;
background-color: rgba(255, 77, 77, 0.25);
border: 1px solid rgba(255, 77, 77, 0.8);
pointer-events: none;
z-index: 2;
transition: opacity 0.1s ease;
}
.resize-handle { .resize-handle {
position: absolute; position: absolute;
right: 5px; right: 5px;
+45 -8
View File
@@ -12,33 +12,70 @@ import { SettingsPanel } from './components/settings';
import LoadingOverlay from './components/common/LoadingOverlay'; 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';
function App() { function App() {
// Enable global keyboard handler for copy/paste and undo/redo // Enable global keyboard handler for copy/paste and undo/redo
useGlobalKeyboardHandler(); useGlobalKeyboardHandler();
// Use project store instead of local state for project name and tracks // Use project store instead of local state for project name and tracks
const { const {
refreshStatus, refreshStatus,
loadProject, maxBars, showChatBox, showSettings, setShowSettings, initializeFromConfig, loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig,
showInstrumentSelection showInstrumentSelection
} = useProjectStore(); } = useProjectStore();
// Track if app has been initialized to prevent multiple initializations
const hasInitialized = useRef(false);
// Load project when component mounts // Load project when component mounts
useEffect(() => { useEffect(() => {
// Guard against multiple initializations (can happen due to React Strict Mode or rerenders)
if (hasInitialized.current) {
return;
}
hasInitialized.current = true;
const initializeApp = async () => { const initializeApp = async () => {
// Load the current project from KGCore // Load the current project from KGCore
loadProject(null); loadProject(null);
// Initialize store from config after ConfigManager is ready // Initialize store from config after ConfigManager is ready
await initializeFromConfig(); await initializeFromConfig();
// Log maxBars to console // Load all mode and chord data files in parallel
console.log(`Project max bars: ${maxBars}`); try {
const [modeListResponse, functionalChordsResponse] = await Promise.all([
fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`),
fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`)
]);
const [modeListData, functionalChordsData] = await Promise.all([
modeListResponse.json(),
functionalChordsResponse.json()
]);
// Store mode data with id, name, and steps
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)
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 = {};
}
// Log maxBars after initialization completes
const currentMaxBars = useProjectStore.getState().maxBars;
console.log(`Project max bars: ${currentMaxBars}`);
}; };
initializeApp(); initializeApp();
}, [loadProject, maxBars, initializeFromConfig]); }, [loadProject, initializeFromConfig]);
// Refresh status periodically to ensure UI is in sync with KGCore // Refresh status periodically to ensure UI is in sync with KGCore
useEffect(() => { useEffect(() => {
+115 -4
View File
@@ -1,8 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { Playhead } from '../common'; import { Playhead } from '../common';
import SelectionBox from './SelectionBox'; import SelectionBox from './SelectionBox';
import { isModifierKeyPressed } from '../../util/osUtil'; import { isModifierKeyPressed } from '../../util/osUtil';
import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil';
import type { KeySignature } from '../../core/KGProject';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
interface PianoGridProps { interface PianoGridProps {
gridRef: MutableRefObject<HTMLDivElement | null>; gridRef: MutableRefObject<HTMLDivElement | null>;
@@ -18,6 +21,9 @@ interface PianoGridProps {
endY: number; endY: number;
}; };
regionStartBeat?: number; regionStartBeat?: number;
selectedMode: string;
keySignature: KeySignature;
chordGuide: string;
} }
interface CursorPosition { interface CursorPosition {
@@ -35,10 +41,19 @@ const PianoGrid: React.FC<PianoGridProps> = ({
onMouseDown, onMouseDown,
isBoxSelecting, isBoxSelecting,
selectionBox, selectionBox,
regionStartBeat = 0 regionStartBeat = 0,
selectedMode,
keySignature,
chordGuide
}) => { }) => {
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null); const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
const [isModifierPressed, setIsModifierPressed] = useState(false); const [isModifierPressed, setIsModifierPressed] = useState(false);
const [selectedChordIndex, setSelectedChordIndex] = useState(0);
// Generate background with scale highlighting - only regenerate when mode or key changes
const backgroundImage = useMemo(() => {
return generatePianoGridBackground(selectedMode, keySignature);
}, [selectedMode, keySignature]);
// Track modifier key state // Track modifier key state
useEffect(() => { useEffect(() => {
@@ -114,11 +129,87 @@ const PianoGrid: React.FC<PianoGridProps> = ({
setCursorPosition(null); setCursorPosition(null);
}; };
// Get all matching chords for the current hover position
const matchingChords = useMemo(() => {
if (!cursorPosition) return [];
// If chord guide is disabled, return empty array
if (chordGuide === 'N') return [];
// Use the utility function to get matching chords
const functionType = chordGuide as 'T' | 'S' | 'D';
return getMatchingChordsForPitch(cursorPosition.pitch, keySignature, selectedMode, functionType);
}, [cursorPosition, chordGuide, keySignature, selectedMode]);
// Calculate chord highlights based on selected chord index
const chordHighlights = useMemo(() => {
if (matchingChords.length === 0) return [];
// Use the selected chord index (wrap around if needed)
const chordIndex = selectedChordIndex % matchingChords.length;
const matchedChordPitches = matchingChords[chordIndex];
// Convert pitch classes to actual pitches in the same octave as cursor
const highlights: Array<{ pitch: number; beat: number }> = [];
const cursorOctave = Math.floor(cursorPosition!.pitch / 12);
// For each pitch class in the chord, create highlights
for (const pitchClass of matchedChordPitches) {
// Calculate the actual pitch in the cursor's octave
const actualPitch = cursorOctave * 12 + pitchClass;
// Ensure the pitch is in valid MIDI range (0-127)
if (actualPitch >= 0 && actualPitch <= 127) {
highlights.push({ pitch: actualPitch, beat: cursorPosition!.beat });
}
}
return highlights;
}, [cursorPosition, matchingChords, selectedChordIndex]);
const cursorPitch = cursorPosition?.pitch ?? null;
useEffect(() => {
const pianoRollState = KGPianoRollState.instance();
pianoRollState.setCurrentMatchingChords(matchingChords);
pianoRollState.setCurrentChordCursorPitch(cursorPitch);
}, [matchingChords, cursorPitch]);
useEffect(() => {
KGPianoRollState.instance().setCurrentSelectedChordIndex(selectedChordIndex);
}, [selectedChordIndex]);
const lastEditedNoteLength = KGPianoRollState.instance().getLastEditedNoteLength();
// Reset selected chord index when cursor moves to a different pitch or beat
useEffect(() => {
setSelectedChordIndex(0);
}, [cursorPosition?.pitch, cursorPosition?.beat]);
// Expose switchChord function via window for hotkey handler
useEffect(() => {
const switchChord = () => {
if (matchingChords.length > 1) {
setSelectedChordIndex(prev => (prev + 1) % matchingChords.length);
}
};
// Store the function on window object so PianoRoll can call it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__pianoGridSwitchChord = switchChord;
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).__pianoGridSwitchChord;
};
}, [matchingChords.length]);
return ( return (
<div className="piano-grid-container"> <div className="piano-grid-container">
<div <div
className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`} className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`}
ref={gridRef} ref={gridRef}
style={{ backgroundImage }}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
onClick={onClick} onClick={onClick}
onMouseDown={(e) => onMouseDown(e)} onMouseDown={(e) => onMouseDown(e)}
@@ -138,13 +229,33 @@ const PianoGrid: React.FC<PianoGridProps> = ({
/> />
{/* Vertical beat column highlight */} {/* Vertical beat column highlight */}
<div <div
className="piano-grid-beat-highlight" className="piano-grid-beat-highlight"
style={{ style={{
left: cursorPosition.beat * (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40), left: cursorPosition.beat * (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40),
width: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40 width: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40
}} }}
/> />
{/* Chord highlights - render red boxes for each note in the matched chord */}
{chordHighlights.map((highlight, index) => {
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const yPosition = (107 - highlight.pitch) * noteHeight; // B7 = 107, reverse for display
return (
<div
key={`chord-highlight-${index}`}
className="piano-grid-chord-highlight"
style={{
top: yPosition,
left: highlight.beat * beatWidth,
width: beatWidth * lastEditedNoteLength,
height: noteHeight
}}
/>
);
})}
</> </>
)} )}
+93 -6
View File
@@ -13,6 +13,7 @@ import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { ConfigManager } from '../../core/config/ConfigManager'; import { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil'; import { beatsToBar } from '../../util/midiUtil';
import { UpdateRegionCommand } from '../../core/commands'; import { UpdateRegionCommand } from '../../core/commands';
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
interface PianoRollProps { interface PianoRollProps {
onClose: () => void; onClose: () => void;
@@ -21,13 +22,13 @@ interface PianoRollProps {
initialSize?: { width: number; height: number }; initialSize?: { width: number; height: number };
} }
const PianoRoll: React.FC<PianoRollProps> = ({ const PianoRoll: React.FC<PianoRollProps> = ({
onClose, onClose,
regionId, regionId,
initialPosition, initialPosition,
initialSize initialSize
}) => { }) => {
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection } = useProjectStore(); const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode } = useProjectStore();
// Tool state for piano roll // Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
@@ -35,10 +36,13 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Quantization state // Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8'); const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8'); const [quantLength, setQuantLength] = useState<string>('1/8');
// Snapping state // Snapping state
const [snapping, setSnapping] = useState<string>('NO SNAP'); const [snapping, setSnapping] = useState<string>('NO SNAP');
// Chord guide state
const [chordGuide, setChordGuide] = useState<string>('N');
// Piano roll state with temporary initial values // Piano roll state with temporary initial values
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 }); const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
@@ -295,7 +299,70 @@ const PianoRoll: React.FC<PianoRollProps> = ({
console.log(`Selected snapping: ${value}`); console.log(`Selected snapping: ${value}`);
} }
}, []); }, []);
// Handle mode selection
const handleModeSelect = useCallback((value: string) => {
setSelectedMode(value);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected mode: ${value}`);
}
}, [setSelectedMode]);
// Handle chord guide selection
const handleChordGuideSelect = useCallback((value: string) => {
setChordGuide(value);
}, []);
// Update suitable chords whenever chord guide, key signature, or mode changes
useEffect(() => {
const pianoRollState = KGPianoRollState.instance();
if (chordGuide === 'N') {
// Disabled - clear chord data
pianoRollState.setCurrentSuitableChords({});
pianoRollState.setCurrentSuitableChordsPitchClasses({});
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Chord guide disabled - cleared suitable chords`);
}
} else {
// Get suitable chords for the selected function (T/S/D)
const functionType = chordGuide as 'T' | 'S' | 'D';
const suitableChords = getSuitableChords(keySignature, selectedMode, functionType);
// Convert note names to pitch classes (ensuring ascending order)
const chordsPitchClasses: Record<string, number[]> = {};
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
const pitchClasses: number[] = [];
let previousPitch = -1;
for (const noteName of noteNames) {
let pitchClass = noteNameToPitchClass(noteName);
// If this pitch is lower than the previous one, add an octave
if (previousPitch >= 0 && pitchClass <= previousPitch) {
pitchClass += 12;
}
pitchClasses.push(pitchClass);
previousPitch = pitchClass;
}
chordsPitchClasses[chordSymbol] = pitchClasses;
}
// Update piano roll state
pianoRollState.setCurrentSuitableChords(suitableChords);
pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Chord guide updated: ${chordGuide} (${functionType})`);
console.log(`Suitable chords for ${keySignature} in ${selectedMode} mode:`, suitableChords);
console.log(`Pitch classes:`, chordsPitchClasses);
}
}
}, [chordGuide, keySignature, selectedMode]);
// Handler for receiving the setNoteUpdateCounter function from PianoRollContent // Handler for receiving the setNoteUpdateCounter function from PianoRollContent
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => { const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
triggerNoteUpdateRef.current = setNoteFn; triggerNoteUpdateRef.current = setNoteFn;
@@ -610,6 +677,19 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Handle piano roll hotkeys // Handle piano roll hotkeys
const configManager = ConfigManager.instance(); const configManager = ConfigManager.instance();
if (configManager.getIsInitialized()) { if (configManager.getIsInitialized()) {
// Chord guide switch hotkey
const switch_key = configManager.get('hotkeys.piano_roll.switch') as string;
if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) {
// Call the switchChord function exposed by PianoGrid
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const switchChord = (window as any).__pianoGridSwitchChord;
if (typeof switchChord === 'function') {
switchChord();
event.preventDefault();
}
return;
}
// Snapping hotkeys // Snapping hotkeys
const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string; const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string;
const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string; const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string;
@@ -765,6 +845,10 @@ const PianoRoll: React.FC<PianoRollProps> = ({
onQuantSelect={handleQuantSelect} onQuantSelect={handleQuantSelect}
snapping={snapping} snapping={snapping}
onSnappingSelect={handleSnappingSelect} onSnappingSelect={handleSnappingSelect}
selectedMode={selectedMode}
onModeChange={handleModeSelect}
chordGuide={chordGuide}
onChordGuideChange={handleChordGuideSelect}
blinkButton={blinkButton} blinkButton={blinkButton}
/> />
@@ -778,6 +862,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
tracks={tracks} tracks={tracks}
onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger} onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger}
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger} onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
/> />
<div <div
+11 -1
View File
@@ -10,6 +10,7 @@ import PianoGridHeader from './PianoGridHeader';
import PianoGrid from './PianoGrid'; import PianoGrid from './PianoGrid';
import { useNoteOperations } from '../../hooks/useNoteOperations'; import { useNoteOperations } from '../../hooks/useNoteOperations';
import { useNoteSelection } from '../../hooks/useNoteSelection'; import { useNoteSelection } from '../../hooks/useNoteSelection';
import type { KeySignature } from '../../core/KGProject';
interface PianoRollContentProps { interface PianoRollContentProps {
contentRef: React.MutableRefObject<HTMLDivElement | null>; contentRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -21,6 +22,9 @@ interface PianoRollContentProps {
tracks: KGTrack[]; tracks: KGTrack[];
onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => void; onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => void;
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void; onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
selectedMode: string;
keySignature: KeySignature;
chordGuide: string;
} }
const PianoRollContent: React.FC<PianoRollContentProps> = ({ const PianoRollContent: React.FC<PianoRollContentProps> = ({
@@ -32,7 +36,10 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
updateTrack, updateTrack,
tracks, tracks,
onSetNoteUpdateTrigger, onSetNoteUpdateTrigger,
onSetDeleteNotesTrigger onSetDeleteNotesTrigger,
selectedMode,
keySignature,
chordGuide
}) => { }) => {
// Get KGCore instance // Get KGCore instance
const core = KGCore.instance(); const core = KGCore.instance();
@@ -227,6 +234,9 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
isBoxSelecting={isBoxSelectingRef.current} isBoxSelecting={isBoxSelectingRef.current}
selectionBox={selectionBoxRef.current} selectionBox={selectionBoxRef.current}
regionStartBeat={activeRegion?.getStartFromBeat() || 0} regionStartBeat={activeRegion?.getStartFromBeat() || 0}
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
> >
{memoizedNotes} {memoizedNotes}
</PianoGrid> </PianoGrid>
+33 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa'; import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
import { KGDropdown } from '../common'; import { KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGCore } from '../../core/KGCore';
interface PianoRollToolbarProps { interface PianoRollToolbarProps {
activeTool: 'pointer' | 'pencil'; activeTool: 'pointer' | 'pencil';
@@ -11,6 +12,10 @@ interface PianoRollToolbarProps {
onQuantSelect: (type: 'position' | 'length', value: string) => void; onQuantSelect: (type: 'position' | 'length', value: string) => void;
snapping: string; snapping: string;
onSnappingSelect: (value: string) => void; onSnappingSelect: (value: string) => void;
selectedMode: string;
onModeChange: (value: string) => void;
chordGuide: string;
onChordGuideChange: (value: string) => void;
blinkButton?: string | null; blinkButton?: string | null;
} }
@@ -22,12 +27,37 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onQuantSelect, onQuantSelect,
snapping, snapping,
onSnappingSelect, onSnappingSelect,
selectedMode,
onModeChange,
chordGuide,
onChordGuideChange,
blinkButton = null blinkButton = null
}) => { }) => {
return ( return (
<div className="piano-roll-toolbar"> <div className="piano-roll-toolbar">
<div className="toolbar-left"> <div className="toolbar-left">
{/* Left section - can add more tools later */} {/* Left section with mode and chord guide dropdowns */}
<KGDropdown
options={KGCore.MODE_DATA.map(m => ({ label: m.name, value: m.id }))}
value={selectedMode}
onChange={(value) => onModeChange(value)}
label="Mode"
buttonClassName="mode-dropdown"
showValueAsLabel={true}
/>
<KGDropdown
options={[
{ label: 'Guide: Disabled', value: 'N' },
{ label: 'Chord Guide: T', value: 'T' },
{ label: 'Chord Guide: S', value: 'S' },
{ label: 'Chord Guide: D', value: 'D' }
]}
value={chordGuide}
onChange={(value) => onChordGuideChange(value)}
label="Chord"
buttonClassName="chord-guide-dropdown"
showValueAsLabel={true}
/>
</div> </div>
<div className="toolbar-center"> <div className="toolbar-center">
@@ -58,7 +88,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
buttonClassName="snapping" buttonClassName="snapping"
showValueAsLabel={true} showValueAsLabel={true}
/> />
<KGDropdown <KGDropdown
options={KGPianoRollState.QUANT_POS_OPTIONS} options={KGPianoRollState.QUANT_POS_OPTIONS}
value={quantPosition} value={quantPosition}
@@ -66,7 +96,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
label="Qua. Pos." label="Qua. Pos."
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`} buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
/> />
<KGDropdown <KGDropdown
options={KGPianoRollState.QUANT_LEN_OPTIONS} options={KGPianoRollState.QUANT_LEN_OPTIONS}
value={quantLength} value={quantLength}
+4
View File
@@ -17,6 +17,10 @@ export class KGCore {
// Private static instance for singleton pattern // Private static instance for singleton pattern
private static _instance: KGCore | null = null; 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
private currentProject: KGProject = new KGProject(); private currentProject: KGProject = new KGProject();
private status: string = "Ready"; private status: string = "Ready";
+15 -2
View File
@@ -31,11 +31,15 @@ export class KGProject {
@WithDefault("C major") @WithDefault("C major")
private keySignature: KeySignature = "C major"; private keySignature: KeySignature = "C major";
@Expose()
@WithDefault("ionian")
private selectedMode: string = "ionian";
@Expose() @Expose()
@WithDefault(0) @WithDefault(0)
private projectStructureVersion: number = 0; private projectStructureVersion: number = 0;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 1; public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 2;
@Expose() @Expose()
@Type(() => KGTrack, { @Type(() => KGTrack, {
@@ -50,13 +54,14 @@ export class KGProject {
private tracks: KGTrack[] = []; private tracks: KGTrack[] = [];
// Constructor // Constructor
constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) { constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) {
this.name = name; this.name = name;
this.maxBars = maxBars; this.maxBars = maxBars;
this.currentBars = currentBars; this.currentBars = currentBars;
this.bpm = bpm; this.bpm = bpm;
this.timeSignature = timeSignature; this.timeSignature = timeSignature;
this.keySignature = keySignature; this.keySignature = keySignature;
this.selectedMode = selectedMode;
this.tracks = tracks; this.tracks = tracks;
this.projectStructureVersion = projectStructureVersion; this.projectStructureVersion = projectStructureVersion;
} }
@@ -90,6 +95,10 @@ export class KGProject {
return this.keySignature; return this.keySignature;
} }
public getSelectedMode(): string {
return this.selectedMode;
}
public getTracks(): KGTrack[] { public getTracks(): KGTrack[] {
return this.tracks; return this.tracks;
} }
@@ -115,6 +124,10 @@ export class KGProject {
this.keySignature = keySignature; this.keySignature = keySignature;
} }
public setSelectedMode(selectedMode: string): void {
this.selectedMode = selectedMode;
}
public setTracks(tracks: KGTrack[]): void { public setTracks(tracks: KGTrack[]): void {
this.tracks = tracks; this.tracks = tracks;
} }
@@ -13,6 +13,7 @@ export interface ProjectUpdateProperties {
bpm?: number; bpm?: number;
timeSignature?: TimeSignature; timeSignature?: TimeSignature;
keySignature?: KeySignature; keySignature?: KeySignature;
selectedMode?: string;
} }
/** /**
@@ -42,6 +43,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
bpm: this.targetProject.getBpm(), bpm: this.targetProject.getBpm(),
timeSignature: { ...this.targetProject.getTimeSignature() }, // Create a copy timeSignature: { ...this.targetProject.getTimeSignature() }, // Create a copy
keySignature: this.targetProject.getKeySignature(), keySignature: this.targetProject.getKeySignature(),
selectedMode: this.targetProject.getSelectedMode(),
}; };
// Apply updates and track what actually changes // Apply updates and track what actually changes
@@ -95,6 +97,13 @@ export class ChangeProjectPropertyCommand extends KGCommand {
updatedProperties.push(`keySignature: "${this.originalProperties.keySignature}" → "${this.newProperties.keySignature}"`); updatedProperties.push(`keySignature: "${this.originalProperties.keySignature}" → "${this.newProperties.keySignature}"`);
} }
// Update selected mode
if (this.newProperties.selectedMode !== undefined && this.newProperties.selectedMode !== this.originalProperties.selectedMode) {
this.targetProject.setSelectedMode(this.newProperties.selectedMode);
this.changedProperties.add('selectedMode');
updatedProperties.push(`selectedMode: "${this.originalProperties.selectedMode}" → "${this.newProperties.selectedMode}"`);
}
if (updatedProperties.length > 0) { if (updatedProperties.length > 0) {
console.log(`Updated project: ${updatedProperties.join(', ')}`); console.log(`Updated project: ${updatedProperties.join(', ')}`);
} else { } else {
@@ -147,6 +156,12 @@ export class ChangeProjectPropertyCommand extends KGCommand {
restoredProperties.push(`keySignature: "${this.originalProperties.keySignature}"`); restoredProperties.push(`keySignature: "${this.originalProperties.keySignature}"`);
} }
// Restore selected mode (only if it was changed)
if (this.changedProperties.has('selectedMode') && this.originalProperties.selectedMode !== undefined) {
this.targetProject.setSelectedMode(this.originalProperties.selectedMode);
restoredProperties.push(`selectedMode: "${this.originalProperties.selectedMode}"`);
}
console.log(`Restored project: ${restoredProperties.join(', ')}`); console.log(`Restored project: ${restoredProperties.join(', ')}`);
} }
@@ -171,6 +186,9 @@ export class ChangeProjectPropertyCommand extends KGCommand {
if (this.newProperties.keySignature !== undefined) { if (this.newProperties.keySignature !== undefined) {
updatedProps.push('key signature'); updatedProps.push('key signature');
} }
if (this.newProperties.selectedMode !== undefined) {
updatedProps.push('selected mode');
}
if (updatedProps.length === 1) { if (updatedProps.length === 1) {
return `Change project ${updatedProps[0]}`; return `Change project ${updatedProps[0]}`;
+2
View File
@@ -48,6 +48,7 @@ interface AppConfig {
save: string; save: string;
}; };
piano_roll: { piano_roll: {
switch: string;
select: string; select: string;
pencil: string; pencil: string;
hold_to_create_note: string; hold_to_create_note: string;
@@ -203,6 +204,7 @@ export class ConfigManager {
save: 'ctrl+s' save: 'ctrl+s'
}, },
piano_roll: { piano_roll: {
switch: 'tab',
select: 'q', select: 'q',
pencil: 'w', pencil: 'w',
hold_to_create_note: 'ctrl', hold_to_create_note: 'ctrl',
@@ -1,5 +1,6 @@
import { KGProject } from '../KGProject'; import { KGProject } from '../KGProject';
import { upgradeToV1 } from './upgradeToV1'; import { upgradeToV1 } from './upgradeToV1';
import { upgradeToV2 } from './upgradeToV2';
/** /**
* Upgrade the given project to the latest structure version, one version at a time. * Upgrade the given project to the latest structure version, one version at a time.
@@ -23,6 +24,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV1(workingProject); workingProject = upgradeToV1(workingProject);
break; break;
} }
case 2: {
workingProject = upgradeToV2(workingProject);
break;
}
default: { default: {
// If an upgrader is missing, throw to prevent loading incompatible structures // If an upgrader is missing, throw to prevent loading incompatible structures
throw new Error(`No upgrader found for project structure version ${nextVersion}`); throw new Error(`No upgrader found for project structure version ${nextVersion}`);
+20
View File
@@ -0,0 +1,20 @@
import { KGProject } from '../KGProject';
/**
* Upgrade a project from structure version 1 to 2.
* Adds the selectedMode field with default value "ionian".
*/
export function upgradeToV2(project: KGProject): KGProject {
try {
// Set default selectedMode to "ionian" if not already set
const currentMode = project.getSelectedMode?.();
if (!currentMode) {
project.setSelectedMode("ionian");
}
} finally {
// Always set the project structure version to 2 to mark migration complete
project.setProjectStructureVersion(2);
}
return project;
}
+56
View File
@@ -12,6 +12,14 @@ export class KGPianoRollState {
private activeTool: string = "pointer"; private activeTool: string = "pointer";
private currentSnap: string = "NO SNAP"; private currentSnap: string = "NO SNAP";
private lastEditedNoteLength: number = 1; // Default to 1 beat private lastEditedNoteLength: number = 1; // Default to 1 beat
private currentMode: string = "ionian"; // Default mode
// Chord guide state
private currentSuitableChords: Record<string, string[]> = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]})
private currentSuitableChordsPitchClasses: Record<string, number[]> = {}; // Map of chord symbols to pitch classes (e.g., {"I": [0, 4, 7]})
private currentMatchingChords: number[][] = [];
private currentSelectedChordIndex: number = 0;
private currentChordCursorPitch: number | null = null;
private constructor() { private constructor() {
console.log("KGPianoRollState initialized"); console.log("KGPianoRollState initialized");
@@ -48,4 +56,52 @@ export class KGPianoRollState {
public setLastEditedNoteLength(length: number): void { public setLastEditedNoteLength(length: number): void {
this.lastEditedNoteLength = length; this.lastEditedNoteLength = length;
} }
public getCurrentMode(): string {
return this.currentMode;
}
public setCurrentMode(mode: string): void {
this.currentMode = mode;
}
public getCurrentSuitableChords(): Record<string, string[]> {
return this.currentSuitableChords;
}
public setCurrentSuitableChords(chords: Record<string, string[]>): void {
this.currentSuitableChords = chords;
}
public getCurrentSuitableChordsPitchClasses(): Record<string, number[]> {
return this.currentSuitableChordsPitchClasses;
}
public setCurrentSuitableChordsPitchClasses(chordsPitchClasses: Record<string, number[]>): void {
this.currentSuitableChordsPitchClasses = chordsPitchClasses;
}
public getCurrentMatchingChords(): number[][] {
return this.currentMatchingChords;
}
public setCurrentMatchingChords(chords: number[][]): void {
this.currentMatchingChords = chords;
}
public getCurrentSelectedChordIndex(): number {
return this.currentSelectedChordIndex;
}
public setCurrentSelectedChordIndex(index: number): void {
this.currentSelectedChordIndex = index;
}
public getCurrentChordCursorPitch(): number | null {
return this.currentChordCursorPitch;
}
public setCurrentChordCursorPitch(pitch: number | null): void {
this.currentChordCursorPitch = pitch;
}
} }
+61 -16
View File
@@ -10,6 +10,7 @@ import { KGCore } from '../core/KGCore';
import { KGPianoRollState } from '../core/state/KGPianoRollState'; import { KGPianoRollState } from '../core/state/KGPianoRollState';
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface'; import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands'; import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands';
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
interface UseNoteOperationsProps { interface UseNoteOperationsProps {
activeRegion: KGMidiRegion | null; activeRegion: KGMidiRegion | null;
@@ -210,20 +211,64 @@ export const useNoteOperations = ({
const lastEditedLength = KGPianoRollState.instance().getLastEditedNoteLength(); const lastEditedLength = KGPianoRollState.instance().getLastEditedNoteLength();
const noteEndBeat = noteStartBeat + lastEditedLength; // Use last edited note length const noteEndBeat = noteStartBeat + lastEditedLength; // Use last edited note length
const velocity = 127; // Maximum velocity const velocity = 127; // Maximum velocity
const pianoRollState = KGPianoRollState.instance();
const matchingChordPitches = pianoRollState.getCurrentMatchingChords();
const selectedChordIndex = pianoRollState.getCurrentSelectedChordIndex();
const cursorChordPitch = pianoRollState.getCurrentChordCursorPitch();
const chordIndex = matchingChordPitches.length > 0
? ((selectedChordIndex % matchingChordPitches.length) + matchingChordPitches.length) % matchingChordPitches.length
: 0;
// Create and execute the note creation command let chordNotePitches: number[] = [];
const command = new CreateNoteCommand(
activeRegion.getId(),
noteStartBeat,
noteEndBeat,
pitch,
velocity
);
core.executeCommand(command); if (
matchingChordPitches.length > 0 &&
cursorChordPitch !== null &&
Math.abs(cursorChordPitch - pitch) <= 1
) {
const cursorOctave = Math.floor(cursorChordPitch / 12);
const pitchClasses = matchingChordPitches[chordIndex] || [];
chordNotePitches = pitchClasses
.map(actualPitchClass => {
const actualPitch = cursorOctave * 12 + actualPitchClass;
return actualPitch >= 0 && actualPitch <= 127 ? actualPitch : null;
})
.filter((value): value is number => value !== null);
// Get the created note for audio preview chordNotePitches = Array.from(new Set(chordNotePitches));
const createdNote = command.getCreatedNote(); }
const notePitchesToCreate = chordNotePitches.length > 0 ? chordNotePitches : [pitch];
const isChordCreation = notePitchesToCreate.length > 1;
let createdNotes: KGMidiNote[] = [];
if (isChordCreation) {
const chordCommand = new CreateNotesCommand(
notePitchesToCreate.map(notePitch => ({
regionId: activeRegion.getId(),
startBeat: noteStartBeat,
endBeat: noteEndBeat,
pitch: notePitch,
velocity
}))
);
core.executeCommand(chordCommand);
createdNotes = chordCommand.getCreatedNotes().map(({ note }) => note);
} else {
const singleNoteCommand = new CreateNoteCommand(
activeRegion.getId(),
noteStartBeat,
noteEndBeat,
notePitchesToCreate[0],
velocity
);
core.executeCommand(singleNoteCommand);
const createdNote = singleNoteCommand.getCreatedNote();
if (createdNote) {
createdNotes = [createdNote];
}
}
// Increment the note update counter to trigger a re-render // Increment the note update counter to trigger a re-render
setNoteUpdateCounter((prev: number) => prev + 1); setNoteUpdateCounter((prev: number) => prev + 1);
@@ -238,19 +283,19 @@ export const useNoteOperations = ({
updateTrack(track); updateTrack(track);
// Play note preview if audio interface is ready and note was created // Play note preview if audio interface is ready and note was created
if (createdNote) { if (createdNotes.length > 0) {
const audioInterface = KGAudioInterface.instance(); const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized()) { if (audioInterface.getIsInitialized()) {
// Try to start audio context if not started yet (user interaction will allow this)
if (!audioInterface.getIsAudioContextStarted()) { if (!audioInterface.getIsAudioContextStarted()) {
audioInterface.startAudioContext().catch(() => { audioInterface.startAudioContext().catch(() => {
// Silently fail if still not allowed - browser policy // Silently fail if still not allowed - browser policy
}); });
} }
// Trigger note if audio context is now started
if (audioInterface.getIsAudioContextStarted()) { if (audioInterface.getIsAudioContextStarted()) {
audioInterface.triggerNote(track.getId().toString(), createdNote); createdNotes.forEach(note => {
audioInterface.triggerNote(track.getId().toString(), note);
});
} }
} }
} }
+25 -4
View File
@@ -39,6 +39,7 @@ interface ProjectState {
timeSignature: TimeSignature; timeSignature: TimeSignature;
bpm: number; bpm: number;
keySignature: KeySignature; keySignature: KeySignature;
selectedMode: string;
playheadPosition: number; // in beats playheadPosition: number; // in beats
isPlaying: boolean; isPlaying: boolean;
currentTime: string; // formatted time string currentTime: string; // formatted time string
@@ -87,7 +88,8 @@ interface ProjectState {
setMaxBars: (maxBars: number) => void; setMaxBars: (maxBars: number) => void;
setTimeSignature: (timeSignature: TimeSignature) => void; setTimeSignature: (timeSignature: TimeSignature) => void;
setKeySignature: (keySignature: KeySignature) => void; setKeySignature: (keySignature: KeySignature) => void;
setSelectedMode: (selectedMode: string) => void;
// Selection actions // Selection actions
syncSelectionFromCore: () => void; syncSelectionFromCore: () => void;
clearAllSelections: () => void; clearAllSelections: () => void;
@@ -222,6 +224,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
timeSignature: currentProject.getTimeSignature(), timeSignature: currentProject.getTimeSignature(),
bpm: currentProject.getBpm(), bpm: currentProject.getBpm(),
keySignature: currentProject.getKeySignature(), keySignature: currentProject.getKeySignature(),
selectedMode: currentProject.getSelectedMode(),
playheadPosition: KGCore.instance().getPlayheadPosition(), playheadPosition: KGCore.instance().getPlayheadPosition(),
isPlaying: KGCore.instance().getIsPlaying(), isPlaying: KGCore.instance().getIsPlaying(),
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
@@ -515,6 +518,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
timeSignature, timeSignature,
bpm, bpm,
keySignature, keySignature,
selectedMode: projectToLoad.getSelectedMode(),
playheadPosition: 0, // Ensure store state is also updated playheadPosition: 0, // Ensure store state is also updated
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
}); });
@@ -616,10 +620,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
// Create and execute the change project property command // Create and execute the change project property command
const command = new ChangeProjectPropertyCommand({ keySignature }); const command = new ChangeProjectPropertyCommand({ keySignature });
KGCore.instance().executeCommand(command); KGCore.instance().executeCommand(command);
// Update the store state // Update the store state
set({ keySignature }); set({ keySignature });
console.log(`Set key signature to ${keySignature}`); console.log(`Set key signature to ${keySignature}`);
} catch (error) { } catch (error) {
console.error('Error setting key signature:', error); console.error('Error setting key signature:', error);
@@ -627,6 +631,22 @@ export const useProjectStore = create<ProjectState>((set, get) => {
} }
}, },
setSelectedMode: (selectedMode: string) => {
try {
// Create and execute the change project property command
const command = new ChangeProjectPropertyCommand({ selectedMode });
KGCore.instance().executeCommand(command);
// Update the store state
set({ selectedMode });
console.log(`Set selected mode to ${selectedMode}`);
} catch (error) {
console.error('Error setting selected mode:', error);
get().setStatus('Failed to set selected mode');
}
},
// Selection actions // Selection actions
syncSelectionFromCore, syncSelectionFromCore,
@@ -784,7 +804,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
maxBars: project.getMaxBars(), maxBars: project.getMaxBars(),
timeSignature: project.getTimeSignature(), timeSignature: project.getTimeSignature(),
bpm: project.getBpm(), bpm: project.getBpm(),
keySignature: project.getKeySignature() keySignature: project.getKeySignature(),
selectedMode: project.getSelectedMode()
}); });
// Sync CSS variables that affect layout // Sync CSS variables that affect layout
+403
View File
@@ -0,0 +1,403 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
getRootNoteFromKeySignature,
noteNameToPitchClass,
getScalePitchClasses,
getModeSteps,
getSuitableChords,
getChordNotesInKey,
getMatchingChordsForPitch,
generatePianoGridBackground
} from './scaleUtil'
import { KGCore } from '../core/KGCore'
import type { KeySignature } from '../core/KGProject'
import functionalChordsData from '../../public/resources/modes/functional_chords.json'
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
KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData
})
describe('getRootNoteFromKeySignature', () => {
it('should extract root note from C major', () => {
expect(getRootNoteFromKeySignature('C major')).toBe('C')
})
it('should extract root note from F# minor', () => {
expect(getRootNoteFromKeySignature('F# minor')).toBe('F#')
})
it('should extract root note from Bb major', () => {
expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb')
})
it('should handle Db major', () => {
expect(getRootNoteFromKeySignature('Db major')).toBe('Db')
})
it('should default to C for invalid format', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C')
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format'))
warnSpy.mockRestore()
})
})
describe('noteNameToPitchClass', () => {
it('should convert C to 0', () => {
expect(noteNameToPitchClass('C')).toBe(0)
})
it('should convert C# to 1', () => {
expect(noteNameToPitchClass('C#')).toBe(1)
})
it('should convert Db to 1', () => {
expect(noteNameToPitchClass('Db')).toBe(1)
})
it('should convert D to 2', () => {
expect(noteNameToPitchClass('D')).toBe(2)
})
it('should convert E to 4', () => {
expect(noteNameToPitchClass('E')).toBe(4)
})
it('should convert F to 5', () => {
expect(noteNameToPitchClass('F')).toBe(5)
})
it('should convert F# to 6', () => {
expect(noteNameToPitchClass('F#')).toBe(6)
})
it('should convert G to 7', () => {
expect(noteNameToPitchClass('G')).toBe(7)
})
it('should convert A to 9', () => {
expect(noteNameToPitchClass('A')).toBe(9)
})
it('should convert Bb to 10', () => {
expect(noteNameToPitchClass('Bb')).toBe(10)
})
it('should convert B to 11', () => {
expect(noteNameToPitchClass('B')).toBe(11)
})
it('should default to 0 for invalid note', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(noteNameToPitchClass('X')).toBe(0)
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name'))
warnSpy.mockRestore()
})
})
describe('getModeSteps', () => {
it('should return ionian steps', () => {
expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1])
})
it('should return dorian steps', () => {
expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2])
})
it('should default to ionian for invalid mode', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1])
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found'))
warnSpy.mockRestore()
})
})
describe('getScalePitchClasses', () => {
it('should return C major scale pitch classes', () => {
const steps = [2, 2, 1, 2, 2, 2, 1]
const result = getScalePitchClasses('C', steps)
expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]) // C D E F G A B
})
it('should return D major scale pitch classes', () => {
const steps = [2, 2, 1, 2, 2, 2, 1]
const result = getScalePitchClasses('D', steps)
expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]) // D E F# G A B C#
})
it('should return F# dorian scale pitch classes', () => {
const steps = [2, 1, 2, 2, 2, 1, 2]
const result = getScalePitchClasses('F#', steps)
expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]) // F# G# A B C# D# E
})
})
describe('getSuitableChords', () => {
it('should return tonic chords for C major ionian', () => {
const result = getSuitableChords('C major', 'ionian', 'T')
expect(result).toHaveProperty('I')
expect(result).toHaveProperty('vi')
expect(result).toHaveProperty('iii')
expect(result['I']).toEqual(['C', 'E', 'G'])
expect(result['vi']).toEqual(['A', 'C', 'E'])
})
it('should return subdominant chords for C major ionian', () => {
const result = getSuitableChords('C major', 'ionian', 'S')
expect(result).toHaveProperty('IV')
expect(result).toHaveProperty('ii')
expect(result['IV']).toEqual(['F', 'A', 'C'])
expect(result['ii']).toEqual(['D', 'F', 'A'])
})
it('should return dominant chords for C major ionian', () => {
const result = getSuitableChords('C major', 'ionian', 'D')
expect(result).toHaveProperty('V')
expect(result).toHaveProperty('V7')
expect(result['V']).toEqual(['G', 'B', 'D'])
expect(result['V7']).toEqual(['G', 'B', 'D', 'F'])
})
it('should transpose chords for D major ionian', () => {
const result = getSuitableChords('D major', 'ionian', 'T')
expect(result['I']).toEqual(['D', 'F#', 'A'])
expect(result['vi']).toEqual(['B', 'D', 'F#'])
})
it('should transpose chords for F# major ionian', () => {
const result = getSuitableChords('F# major', 'ionian', 'T')
expect(result['I']).toEqual(['F#', 'A#', 'C#'])
})
it('should return empty object for invalid mode', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = getSuitableChords('C major', 'invalid', 'T')
expect(result).toEqual({})
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found'))
warnSpy.mockRestore()
})
})
describe('getChordNotesInKey', () => {
it('should return I chord notes in C major ionian', () => {
const result = getChordNotesInKey('I', 'C major', 'ionian')
expect(result).toEqual(['C', 'E', 'G'])
})
it('should return V7 chord notes in C major ionian', () => {
const result = getChordNotesInKey('V7', 'C major', 'ionian')
expect(result).toEqual(['G', 'B', 'D', 'F'])
})
it('should transpose to D major', () => {
const result = getChordNotesInKey('I', 'D major', 'ionian')
expect(result).toEqual(['D', 'F#', 'A'])
})
it('should return empty array for invalid chord symbol', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = getChordNotesInKey('invalid', 'C major', 'ionian')
expect(result).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found'))
warnSpy.mockRestore()
})
})
describe('getMatchingChordsForPitch', () => {
describe('valid inputs', () => {
it('should return matching chords for C (pitch 60) in C major ionian tonic', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
// C major ionian T chords from JSON: I ["C","E","G"], vi ["A","C","E"], iii ["E","G","B"], I⁶ ["E","G","C"]
// Pitch 60 = C (pitch class 0)
// Expected matches prioritized by position:
// 1. I chord: C is root (position 0) → [0, 4, 7] (no offset)
// 2. vi chord: C is 2nd note (position 1) → [9, 12, 16] offset by -12 → [-3, 0, 4]
// 3. I⁶ chord: C is 3rd note (position 2) → [4, 7, 12] offset by -12 → [-8, -5, 0]
expect(result).toEqual([
[0, 4, 7], // I chord (C-E-G): C is root
[-3, 0, 4], // vi chord (A-C-E): C is 2nd, offset applied
[-8, -5, 0] // I⁶ chord (E-G-C): C is 3rd, offset applied
])
})
it('should return matching chords for E (pitch 64) in C major ionian tonic', () => {
const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T')
// Pitch 64 = E (pitch class 4)
// Expected matches prioritized by position:
// 1. iii chord: E is root (position 0) → [4, 7, 11] (no offset)
// 2. I⁶ chord: E is root (position 0) → [4, 7, 12] (no offset)
// 3. I chord: E is 2nd note (position 1) → [0, 4, 7] (no offset, 4 < 12)
// 4. vi chord: E is 3rd note (position 2) → [9, 12, 16] offset by -12 → [-3, 0, 4]
expect(result).toEqual([
[4, 7, 11], // iii chord (E-G-B): E is root
[4, 7, 12], // I⁶ chord (E-G-C): E is root
[0, 4, 7], // I chord (C-E-G): E is 2nd
[-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied
])
})
it('should prioritize root matches over other positions', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') // C
// First chord should have C as root (position 0)
if (result.length > 0) {
expect(result[0][0] % 12).toBe(0) // First note of first chord should be C
}
})
it('should return matching chords for F (pitch 65) in C major ionian subdominant', () => {
const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S') // F
// Pitch 65 = F (pitch class 5)
// C major ionian S chords from JSON: IV ["F","A","C"], ii ["D","F","A"], vi ["A","C","E"], IV⁶ ["A","C","F"]
// Expected matches prioritized by position:
// 1. IV chord: F is root → [5, 9, 12] offset by -12 → [-7, -3, 0]
// 2. ii chord: F is 2nd → [2, 5, 9] (no offset)
// 3. IV⁶ chord: F is 3rd → [9, 12, 17] offset by -12 → [-3, 0, 5]
expect(result).toEqual([
[-7, -3, 0], // IV chord (F-A-C): F is root, offset applied
[2, 5, 9], // ii chord (D-F-A): F is 2nd
[-3, 0, 5] // IV⁶ chord (A-C-F): F is 3rd, offset applied
])
})
it('should return matching chords for G (pitch 67) in C major ionian dominant', () => {
const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D') // G
// Pitch 67 = G (pitch class 7)
// C major ionian D chords from JSON: V ["G","B","D"], V7 ["G","B","D","F"], vii° ["B","D","F"], ♭II ["Db","F","Ab"]
// Expected matches prioritized by position:
// 1. V chord: G is root → [7, 11, 14] offset by -12 → [-5, -1, 2]
// 2. V7 chord: G is root → [7, 11, 14, 17] offset by -12 → [-5, -1, 2, 5]
expect(result).toEqual([
[-5, -1, 2], // V chord (G-B-D): G is root, offset applied
[-5, -1, 2, 5] // V7 chord (G-B-D-F): G is root, offset applied
])
})
it('should work with different modes (dorian)', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T')
expect(result.length).toBeGreaterThan(0)
})
it('should transpose correctly for D major', () => {
const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T') // D
expect(result.length).toBeGreaterThan(0)
expect(result.some(chord => chord.includes(2))).toBe(true) // Contains D (pitch class 2)
})
it('should handle all MIDI pitch ranges (low)', () => {
const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T') // C1
expect(Array.isArray(result)).toBe(true)
})
it('should handle all MIDI pitch ranges (high)', () => {
const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T') // C8
expect(Array.isArray(result)).toBe(true)
})
})
describe('edge cases', () => {
it('should return empty array for invalid mode', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T')
expect(result).toEqual([])
warnSpy.mockRestore()
})
it('should handle pitch class calculations correctly', () => {
// Test that pitch 60 (C4) and pitch 72 (C5) both match C chords
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T')
expect(result1.length).toBe(result2.length) // Same chords match
})
it('should return pitch classes in ascending order', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
result.forEach(chord => {
for (let i = 1; i < chord.length; i++) {
expect(chord[i]).toBeGreaterThan(chord[i - 1])
}
})
})
it('should apply octave offset correctly for pitch classes >= 12', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
// All pitch classes should be < 12 after offset
result.forEach(chord => {
chord.forEach(pitchClass => {
expect(pitchClass).toBeLessThan(24) // Allowing for extended range
})
})
})
})
describe('pitch class calculations', () => {
it('should correctly convert hover pitch to pitch class', () => {
// C4 (60) should match same chords as C5 (72)
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T')
expect(result1).toEqual(result2)
})
it('should maintain ascending pitch order in results', () => {
const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T')
result.forEach(chord => {
for (let i = 1; i < chord.length; i++) {
expect(chord[i]).toBeGreaterThan(chord[i - 1])
}
})
})
})
describe('integration with helper functions', () => {
it('should work with getSuitableChords', () => {
const suitableChords = getSuitableChords('C major', 'ionian', 'T')
expect(Object.keys(suitableChords).length).toBeGreaterThan(0)
const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
expect(matchingChords.length).toBeGreaterThan(0)
})
it('should work with noteNameToPitchClass', () => {
const cPitch = noteNameToPitchClass('C')
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true)
})
it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => {
// Verify the data has the expected structure
const data = KGCore.FUNCTIONAL_CHORDS_DATA
expect(data).toHaveProperty('ionian')
expect(data['ionian']).toHaveProperty('T')
expect(data['ionian']).toHaveProperty('chords')
})
})
})
describe('generatePianoGridBackground', () => {
it('should generate CSS background for C major ionian', () => {
const result = generatePianoGridBackground('ionian', 'C major')
expect(result).toContain('linear-gradient')
expect(typeof result).toBe('string')
})
it('should generate different backgrounds for different modes', () => {
const ionian = generatePianoGridBackground('ionian', 'C major')
const dorian = generatePianoGridBackground('dorian', 'C major')
expect(ionian).not.toBe(dorian)
})
})
})
+352
View File
@@ -0,0 +1,352 @@
import { KGCore } from '../core/KGCore';
import { pianoRollIndexToPitch } from './midiUtil';
import type { KeySignature } from '../core/KGProject';
/**
* Extracts the root note from a key signature string
* @param keySignature - Key signature like "C major", "F# minor", "Bb major"
* @returns Root note like "C", "F#", "Bb"
*/
export const getRootNoteFromKeySignature = (keySignature: KeySignature): string => {
// Extract the note before " major" or " minor"
const match = keySignature.match(/^([A-G][#b]?)\s+(major|minor)$/);
if (!match) {
console.warn(`Invalid key signature format: ${keySignature}, defaulting to C`);
return 'C';
}
return match[1];
};
/**
* Converts a note name (without octave) to pitch class (0-11)
* @param noteName - Note name like "C", "C#", "Db", "F#"
* @returns Pitch class (0=C, 1=C#/Db, 2=D, ..., 11=B)
*/
export const noteNameToPitchClass = (noteName: string): number => {
const noteMap: { [key: string]: number } = {
'C': 0, 'C#': 1, 'Db': 1,
'D': 2, 'D#': 3, 'Eb': 3,
'E': 4,
'F': 5, 'F#': 6, 'Gb': 6,
'G': 7, 'G#': 8, 'Ab': 8,
'A': 9, 'A#': 10, 'Bb': 10,
'B': 11
};
if (!(noteName in noteMap)) {
console.warn(`Invalid note name: ${noteName}, defaulting to C (0)`);
return 0;
}
return noteMap[noteName];
};
/**
* Calculates the pitch classes (0-11) that belong to a scale
* @param rootNote - Root note like "C", "F#", "Bb"
* @param modeSteps - Mode interval steps (e.g., [2, 2, 1, 2, 2, 2, 1] for ionian)
* @returns Array of pitch classes in the scale
*/
export const getScalePitchClasses = (rootNote: string, modeSteps: number[]): number[] => {
const rootPitchClass = noteNameToPitchClass(rootNote);
const scalePitchClasses: number[] = [rootPitchClass];
let currentPitch = rootPitchClass;
for (const step of modeSteps.slice(0, -1)) { // Exclude last step (returns to root)
currentPitch = (currentPitch + step) % 12;
scalePitchClasses.push(currentPitch);
}
return scalePitchClasses;
};
/**
* Gets the mode steps for a given mode id
* @param modeId - ID of the mode (e.g., "ionian", "aeolian")
* @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) {
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;
};
/**
* Transposes a note name by a given number of semitones
* @param noteName - Note name like "C", "C#", "Db"
* @param semitones - Number of semitones to transpose (positive or negative)
* @returns Transposed note name
*/
const transposeNote = (noteName: string, semitones: number): string => {
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const pitchClass = noteNameToPitchClass(noteName);
const newPitchClass = (pitchClass + semitones + 12) % 12;
return noteNames[newPitchClass];
};
/**
* Gets the list of suitable chords for a given key, mode, and functional harmony group
* @param keySignature - Key signature like "C major", "F# minor", "Bb major"
* @param modeId - Mode ID (e.g., "ionian", "aeolian", "dorian")
* @param functionType - Functional harmony group: "T" (Tonic), "S" (Subdominant), or "D" (Dominant)
* @returns Map of chord symbols to their transposed note arrays, e.g., { "I": ["C", "E", "G"], "vi": ["A", "C", "E"] }
* Returns empty object if mode or function not found
*
* Example:
* - getSuitableChords("C major", "ionian", "T") returns { "I": ["C", "E", "G"], "vi": ["A", "C", "E"], ... }
* - getSuitableChords("D major", "ionian", "T") returns { "I": ["D", "F#", "A"], "vi": ["B", "D", "F#"], ... }
*/
export const getSuitableChords = (
keySignature: KeySignature,
modeId: string,
functionType: 'T' | 'S' | 'D'
): Record<string, string[]> => {
// Get the functional chords for this mode
const functionalChords = KGCore.FUNCTIONAL_CHORDS_DATA[modeId];
if (!functionalChords) {
console.warn(`No functional chords found for mode: ${modeId}`);
return {};
}
// Get the chord symbols for the specified function (T/S/D)
const chordSymbols = functionalChords[functionType];
if (!chordSymbols || chordSymbols.length === 0) {
console.warn(`No chords found for function ${functionType} in mode ${modeId}`);
return {};
}
// Get the mode-specific chords data
const modeChords = functionalChords.chords;
if (!modeChords) {
console.warn(`No chords data found for mode: ${modeId}`);
return {};
}
// Get the root note from key signature
const rootNote = getRootNoteFromKeySignature(keySignature);
// Calculate the transposition interval from C to the root note
const transposeSemitones = noteNameToPitchClass(rootNote);
// Build a map of chord symbols to transposed notes
const chordMap: Record<string, string[]> = {};
for (const chordSymbol of chordSymbols) {
// Get the chord notes from mode-specific chords data (in C key)
const chordNotes = modeChords[chordSymbol];
if (!chordNotes) {
console.warn(`Chord symbol not found in mode ${modeId} chords: ${chordSymbol}`);
continue; // Skip this chord if not found
}
// If the key is C, use notes as-is; otherwise transpose
if (rootNote === 'C') {
chordMap[chordSymbol] = chordNotes;
} else {
// Transpose each note in the chord
const transposedNotes = chordNotes.map(note => transposeNote(note, transposeSemitones));
chordMap[chordSymbol] = transposedNotes;
}
}
return chordMap;
};
/**
* Gets the transposed notes for a specific chord in a given key and mode
* @param chordSymbol - Chord symbol (e.g., "I", "V7", "ii")
* @param keySignature - Key signature like "C major", "F# minor"
* @param modeId - Mode ID (e.g., "ionian", "aeolian")
* @returns Array of note names for the chord in the specified key, or empty array if not found
*/
export const getChordNotesInKey = (
chordSymbol: string,
keySignature: KeySignature,
modeId: string
): string[] => {
// Get the functional chords for this mode
const functionalChords = KGCore.FUNCTIONAL_CHORDS_DATA[modeId];
if (!functionalChords) {
console.warn(`No functional chords found for mode: ${modeId}`);
return [];
}
// Get the mode-specific chords data
const modeChords = functionalChords.chords;
if (!modeChords) {
console.warn(`No chords data found for mode: ${modeId}`);
return [];
}
// Get the root note from key signature
const rootNote = getRootNoteFromKeySignature(keySignature);
// Get the chord notes from mode-specific chords data (in C key)
const chordNotes = modeChords[chordSymbol];
if (!chordNotes) {
console.warn(`Chord symbol not found in mode ${modeId} chords: ${chordSymbol}`);
return [];
}
// If the key is C, return the notes as-is
if (rootNote === 'C') {
return chordNotes;
}
// Calculate the transposition interval from C to the root note
const transposeSemitones = noteNameToPitchClass(rootNote);
// Transpose each note in the chord
const transposedNotes = chordNotes.map(note => transposeNote(note, transposeSemitones));
return transposedNotes;
};
/**
* Gets matching chords for a given pitch, prioritized by which note in the chord matches
* @param hoverPitch - The MIDI pitch being hovered over (0-127)
* @param keySignature - Key signature like "C major", "F# minor"
* @param modeId - Mode ID (e.g., "ionian", "aeolian")
* @param functionType - Functional harmony group: "T" (Tonic), "S" (Subdominant), or "D" (Dominant)
* @returns Array of chord pitch class arrays, prioritized by match position
* (root matches first, then 2nd note matches, then 3rd note, etc.)
* Each chord is adjusted so its pitch classes work in the same octave as the hover pitch
*
* Example:
* - getMatchingChordsForPitch(60, "C major", "ionian", "T")
* Returns chords where C (pitch class 0) appears, with pitch classes adjusted for display
*/
export const getMatchingChordsForPitch = (
hoverPitch: number,
keySignature: KeySignature,
modeId: string,
functionType: 'T' | 'S' | 'D'
): number[][] => {
// Get suitable chords for the given key, mode, and function
const suitableChords = getSuitableChords(keySignature, modeId, functionType);
if (Object.keys(suitableChords).length === 0) {
return [];
}
// Convert chord note names to ascending pitch classes
const chordsPitchClasses: Record<string, number[]> = {};
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
const pitchClasses: number[] = [];
let previousPitch = -1;
for (const noteName of noteNames) {
let pitchClass = noteNameToPitchClass(noteName);
// Ensure ascending order by adding 12 if pitch class <= previous
if (previousPitch >= 0 && pitchClass <= previousPitch) {
pitchClass += 12;
}
pitchClasses.push(pitchClass);
previousPitch = pitchClass;
}
chordsPitchClasses[chordSymbol] = pitchClasses;
}
// Get the pitch class of the current hovering note (mod 12)
const hoverPitchClass = hoverPitch % 12;
// Group chords by which note position matches the hovering pitch class
// matchesByPosition[0] = chords where 1st note matches
// matchesByPosition[1] = chords where 2nd note matches, etc.
const matchesByPosition: number[][][] = [];
for (const pitchClasses of Object.values(chordsPitchClasses)) {
// Check each note position in the chord
for (let i = 0; i < pitchClasses.length; i++) {
if (pitchClasses[i] % 12 === hoverPitchClass) {
// Ensure the array exists for this position
if (!matchesByPosition[i]) {
matchesByPosition[i] = [];
}
// Offset the chord based on the matched pitch class
// If the matched note is >= 12 (in the second octave), shift all notes down by 12
if (pitchClasses[i] >= 12) {
const offsetChord = pitchClasses.map(p => p - 12);
matchesByPosition[i].push(offsetChord);
} else {
matchesByPosition[i].push(pitchClasses);
}
break; // Only count each chord once (at first matching position)
}
}
}
// Flatten the grouped matches: 1st position matches first, then 2nd, then 3rd, etc.
const result: number[][] = [];
for (const matches of matchesByPosition) {
if (matches) {
result.push(...matches);
}
}
return result;
};
/**
* Generates the CSS background-image string for the piano grid with scale highlighting
* @param selectedMode - Current mode ID (e.g., "ionian", "dorian")
* @param keySignature - Current key signature (e.g., "C major", "F# minor")
* @returns CSS background-image string with highlighted scale notes
*/
export const generatePianoGridBackground = (
selectedMode: string,
keySignature: KeySignature
): string => {
// Get root note and scale pitch classes
const rootNote = getRootNoteFromKeySignature(keySignature);
const modeSteps = getModeSteps(selectedMode);
const scalePitchClasses = getScalePitchClasses(rootNote, modeSteps);
// Generate horizontal lines for each of 96 rows (8 octaves)
const horizontalLines = Array.from({ length: 96 }, (_, index) => {
const pitch = pianoRollIndexToPitch(index);
const pitchClass = pitch % 12;
const isInScale = scalePitchClasses.includes(pitchClass);
// Calculate row positions using CSS calc() with --region-piano-key-height variable
const rowTop = `calc(var(--region-piano-key-height) * ${index})`;
const rowBottomMinusOne = `calc(var(--region-piano-key-height) * ${index + 1} - 1px)`;
const rowBottom = `calc(var(--region-piano-key-height) * ${index + 1})`;
// For scale notes: highlight the full row with a semi-transparent blue background
// For non-scale notes: use transparent background with just the separator line
if (isInScale) {
return `
rgba(90, 123, 154, 0.15) ${rowTop},
rgba(90, 123, 154, 0.15) ${rowBottomMinusOne},
#3a3a3a ${rowBottomMinusOne},
#3a3a3a ${rowBottom}
`.trim();
} else {
return `
transparent ${rowTop},
transparent ${rowBottomMinusOne},
#3a3a3a ${rowBottomMinusOne},
#3a3a3a ${rowBottom}
`.trim();
}
}).join(',\n');
// Return complete background-image with vertical and horizontal gradients
// Note: Vertical beat lines gradient should be preserved from existing CSS
return `
linear-gradient(to right,
transparent calc(var(--region-grid-beat-width) - 1px),
#3a3a3a calc(var(--region-grid-beat-width) - 1px),
#3a3a3a var(--region-grid-beat-width)
),
linear-gradient(to bottom, ${horizontalLines})
`;
};