Merge pull request #20 from KGAudioLab/feat/2025-12-08-chord-assistant
Feat/2025 12 08 chord assistant
This commit is contained in:
@@ -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 auto‑composition.
|
||||
|
||||
## 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
|
||||
|
||||
**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
|
||||
- **K.G.Studio Musician Assistant**: Chat with the LLM‑powered 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 OpenAI‑compatible (e.g., Ollama, OpenRouter).
|
||||
- **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.
|
||||
- **Real instruments**: Tone.js‑based Sampler with high‑quality FluidR3 soundfonts.
|
||||
- **Undo/Redo everywhere**: Command pattern for tracks, regions, notes, and project properties
|
||||
- **Persistence with privacy**: Save/load projects and configuration in your browser’s 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.
|
||||
- **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.
|
||||
- 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
|
||||
- Set snapping from the NO SNAP menu (top‑right).
|
||||
- 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
|
||||
|
||||
- [ ] More instruments
|
||||
- [ ] Automated testing (unit tests, integration tests, etc.)
|
||||
Feature priorities might change.
|
||||
|
||||
- [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 MIDI control events (e.g. CC, pitch bend, etc.)
|
||||
- [ ] Support WAV audio tracks
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"save": "ctrl+s"
|
||||
},
|
||||
"piano_roll": {
|
||||
"switch": "tab",
|
||||
"select": "q",
|
||||
"pencil": "w",
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -597,6 +597,7 @@ body {
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 100; /* Ensure toolbar and its dropdowns appear above piano roll content */
|
||||
}
|
||||
|
||||
/* Override pointer-events for piano roll toolbar sections */
|
||||
@@ -609,6 +610,15 @@ body {
|
||||
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 */
|
||||
.quant-button.button-blink {
|
||||
animation: buttonBlink 0.2s ease-in-out;
|
||||
@@ -853,19 +863,8 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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-image:
|
||||
/* 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)
|
||||
);
|
||||
background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%;
|
||||
/* background-image is now set dynamically via React inline styles in PianoGrid component */
|
||||
}
|
||||
|
||||
.piano-grid.pencil-cursor {
|
||||
@@ -897,6 +896,15 @@ body {
|
||||
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 {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
|
||||
+45
-8
@@ -12,33 +12,70 @@ import { SettingsPanel } from './components/settings';
|
||||
import LoadingOverlay from './components/common/LoadingOverlay';
|
||||
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
||||
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
||||
import { KGCore } from './core/KGCore';
|
||||
|
||||
function App() {
|
||||
// Enable global keyboard handler for copy/paste and undo/redo
|
||||
useGlobalKeyboardHandler();
|
||||
|
||||
// Use project store instead of local state for project name and tracks
|
||||
const {
|
||||
const {
|
||||
refreshStatus,
|
||||
loadProject, maxBars, showChatBox, showSettings, setShowSettings, initializeFromConfig,
|
||||
loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig,
|
||||
showInstrumentSelection
|
||||
} = useProjectStore();
|
||||
|
||||
// Track if app has been initialized to prevent multiple initializations
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
// Load project when component mounts
|
||||
useEffect(() => {
|
||||
// Guard against multiple initializations (can happen due to React Strict Mode or rerenders)
|
||||
if (hasInitialized.current) {
|
||||
return;
|
||||
}
|
||||
hasInitialized.current = true;
|
||||
|
||||
const initializeApp = async () => {
|
||||
// Load the current project from KGCore
|
||||
loadProject(null);
|
||||
|
||||
|
||||
// Initialize store from config after ConfigManager is ready
|
||||
await initializeFromConfig();
|
||||
|
||||
// Log maxBars to console
|
||||
console.log(`Project max bars: ${maxBars}`);
|
||||
|
||||
// Load all mode and chord data files in parallel
|
||||
try {
|
||||
const [modeListResponse, functionalChordsResponse] = await Promise.all([
|
||||
fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`),
|
||||
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();
|
||||
}, [loadProject, maxBars, initializeFromConfig]);
|
||||
}, [loadProject, initializeFromConfig]);
|
||||
|
||||
// Refresh status periodically to ensure UI is in sync with KGCore
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { Playhead } from '../common';
|
||||
import SelectionBox from './SelectionBox';
|
||||
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 {
|
||||
gridRef: MutableRefObject<HTMLDivElement | null>;
|
||||
@@ -18,6 +21,9 @@ interface PianoGridProps {
|
||||
endY: number;
|
||||
};
|
||||
regionStartBeat?: number;
|
||||
selectedMode: string;
|
||||
keySignature: KeySignature;
|
||||
chordGuide: string;
|
||||
}
|
||||
|
||||
interface CursorPosition {
|
||||
@@ -35,10 +41,19 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
onMouseDown,
|
||||
isBoxSelecting,
|
||||
selectionBox,
|
||||
regionStartBeat = 0
|
||||
regionStartBeat = 0,
|
||||
selectedMode,
|
||||
keySignature,
|
||||
chordGuide
|
||||
}) => {
|
||||
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -114,11 +129,87 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
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 (
|
||||
<div className="piano-grid-container">
|
||||
<div
|
||||
<div
|
||||
className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
||||
ref={gridRef}
|
||||
style={{ backgroundImage }}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onClick={onClick}
|
||||
onMouseDown={(e) => onMouseDown(e)}
|
||||
@@ -138,13 +229,33 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
/>
|
||||
|
||||
{/* Vertical beat column highlight */}
|
||||
<div
|
||||
<div
|
||||
className="piano-grid-beat-highlight"
|
||||
style={{
|
||||
left: cursorPosition.beat * (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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { beatsToBar } from '../../util/midiUtil';
|
||||
import { UpdateRegionCommand } from '../../core/commands';
|
||||
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
||||
|
||||
interface PianoRollProps {
|
||||
onClose: () => void;
|
||||
@@ -21,13 +22,13 @@ interface PianoRollProps {
|
||||
initialSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
onClose,
|
||||
const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
onClose,
|
||||
regionId,
|
||||
initialPosition,
|
||||
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
|
||||
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
||||
@@ -35,10 +36,13 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
// Quantization state
|
||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||
|
||||
|
||||
// Snapping state
|
||||
const [snapping, setSnapping] = useState<string>('NO SNAP');
|
||||
|
||||
|
||||
// Chord guide state
|
||||
const [chordGuide, setChordGuide] = useState<string>('N');
|
||||
|
||||
// Piano roll state with temporary initial values
|
||||
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
|
||||
|
||||
@@ -295,7 +299,70 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
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
|
||||
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
|
||||
triggerNoteUpdateRef.current = setNoteFn;
|
||||
@@ -610,6 +677,19 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
// Handle piano roll hotkeys
|
||||
const configManager = ConfigManager.instance();
|
||||
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
|
||||
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;
|
||||
@@ -765,6 +845,10 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
onQuantSelect={handleQuantSelect}
|
||||
snapping={snapping}
|
||||
onSnappingSelect={handleSnappingSelect}
|
||||
selectedMode={selectedMode}
|
||||
onModeChange={handleModeSelect}
|
||||
chordGuide={chordGuide}
|
||||
onChordGuideChange={handleChordGuideSelect}
|
||||
blinkButton={blinkButton}
|
||||
/>
|
||||
|
||||
@@ -778,6 +862,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
tracks={tracks}
|
||||
onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger}
|
||||
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
|
||||
selectedMode={selectedMode}
|
||||
keySignature={keySignature}
|
||||
chordGuide={chordGuide}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -10,6 +10,7 @@ import PianoGridHeader from './PianoGridHeader';
|
||||
import PianoGrid from './PianoGrid';
|
||||
import { useNoteOperations } from '../../hooks/useNoteOperations';
|
||||
import { useNoteSelection } from '../../hooks/useNoteSelection';
|
||||
import type { KeySignature } from '../../core/KGProject';
|
||||
|
||||
interface PianoRollContentProps {
|
||||
contentRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
@@ -21,6 +22,9 @@ interface PianoRollContentProps {
|
||||
tracks: KGTrack[];
|
||||
onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => void;
|
||||
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
||||
selectedMode: string;
|
||||
keySignature: KeySignature;
|
||||
chordGuide: string;
|
||||
}
|
||||
|
||||
const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
@@ -32,7 +36,10 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
updateTrack,
|
||||
tracks,
|
||||
onSetNoteUpdateTrigger,
|
||||
onSetDeleteNotesTrigger
|
||||
onSetDeleteNotesTrigger,
|
||||
selectedMode,
|
||||
keySignature,
|
||||
chordGuide
|
||||
}) => {
|
||||
// Get KGCore instance
|
||||
const core = KGCore.instance();
|
||||
@@ -227,6 +234,9 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
isBoxSelecting={isBoxSelectingRef.current}
|
||||
selectionBox={selectionBoxRef.current}
|
||||
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
||||
selectedMode={selectedMode}
|
||||
keySignature={keySignature}
|
||||
chordGuide={chordGuide}
|
||||
>
|
||||
{memoizedNotes}
|
||||
</PianoGrid>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
|
||||
import { KGDropdown } from '../common';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
interface PianoRollToolbarProps {
|
||||
activeTool: 'pointer' | 'pencil';
|
||||
@@ -11,6 +12,10 @@ interface PianoRollToolbarProps {
|
||||
onQuantSelect: (type: 'position' | 'length', value: string) => void;
|
||||
snapping: string;
|
||||
onSnappingSelect: (value: string) => void;
|
||||
selectedMode: string;
|
||||
onModeChange: (value: string) => void;
|
||||
chordGuide: string;
|
||||
onChordGuideChange: (value: string) => void;
|
||||
blinkButton?: string | null;
|
||||
}
|
||||
|
||||
@@ -22,12 +27,37 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
onQuantSelect,
|
||||
snapping,
|
||||
onSnappingSelect,
|
||||
selectedMode,
|
||||
onModeChange,
|
||||
chordGuide,
|
||||
onChordGuideChange,
|
||||
blinkButton = null
|
||||
}) => {
|
||||
return (
|
||||
<div className="piano-roll-toolbar">
|
||||
<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 className="toolbar-center">
|
||||
@@ -58,7 +88,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
buttonClassName="snapping"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
|
||||
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_POS_OPTIONS}
|
||||
value={quantPosition}
|
||||
@@ -66,7 +96,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
label="Qua. Pos."
|
||||
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
|
||||
/>
|
||||
|
||||
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_LEN_OPTIONS}
|
||||
value={quantLength}
|
||||
|
||||
@@ -17,6 +17,10 @@ export class KGCore {
|
||||
// Private static instance for singleton pattern
|
||||
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 status: string = "Ready";
|
||||
|
||||
+15
-2
@@ -31,11 +31,15 @@ export class KGProject {
|
||||
@WithDefault("C major")
|
||||
private keySignature: KeySignature = "C major";
|
||||
|
||||
@Expose()
|
||||
@WithDefault("ionian")
|
||||
private selectedMode: string = "ionian";
|
||||
|
||||
@Expose()
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 1;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 2;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
@@ -50,13 +54,14 @@ export class KGProject {
|
||||
private tracks: KGTrack[] = [];
|
||||
|
||||
// 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.maxBars = maxBars;
|
||||
this.currentBars = currentBars;
|
||||
this.bpm = bpm;
|
||||
this.timeSignature = timeSignature;
|
||||
this.keySignature = keySignature;
|
||||
this.selectedMode = selectedMode;
|
||||
this.tracks = tracks;
|
||||
this.projectStructureVersion = projectStructureVersion;
|
||||
}
|
||||
@@ -90,6 +95,10 @@ export class KGProject {
|
||||
return this.keySignature;
|
||||
}
|
||||
|
||||
public getSelectedMode(): string {
|
||||
return this.selectedMode;
|
||||
}
|
||||
|
||||
public getTracks(): KGTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
@@ -115,6 +124,10 @@ export class KGProject {
|
||||
this.keySignature = keySignature;
|
||||
}
|
||||
|
||||
public setSelectedMode(selectedMode: string): void {
|
||||
this.selectedMode = selectedMode;
|
||||
}
|
||||
|
||||
public setTracks(tracks: KGTrack[]): void {
|
||||
this.tracks = tracks;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ProjectUpdateProperties {
|
||||
bpm?: number;
|
||||
timeSignature?: TimeSignature;
|
||||
keySignature?: KeySignature;
|
||||
selectedMode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
||||
bpm: this.targetProject.getBpm(),
|
||||
timeSignature: { ...this.targetProject.getTimeSignature() }, // Create a copy
|
||||
keySignature: this.targetProject.getKeySignature(),
|
||||
selectedMode: this.targetProject.getSelectedMode(),
|
||||
};
|
||||
|
||||
// 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}"`);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.log(`Updated project: ${updatedProperties.join(', ')}`);
|
||||
} else {
|
||||
@@ -147,6 +156,12 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
||||
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(', ')}`);
|
||||
}
|
||||
|
||||
@@ -171,6 +186,9 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
||||
if (this.newProperties.keySignature !== undefined) {
|
||||
updatedProps.push('key signature');
|
||||
}
|
||||
if (this.newProperties.selectedMode !== undefined) {
|
||||
updatedProps.push('selected mode');
|
||||
}
|
||||
|
||||
if (updatedProps.length === 1) {
|
||||
return `Change project ${updatedProps[0]}`;
|
||||
|
||||
@@ -48,6 +48,7 @@ interface AppConfig {
|
||||
save: string;
|
||||
};
|
||||
piano_roll: {
|
||||
switch: string;
|
||||
select: string;
|
||||
pencil: string;
|
||||
hold_to_create_note: string;
|
||||
@@ -203,6 +204,7 @@ export class ConfigManager {
|
||||
save: 'ctrl+s'
|
||||
},
|
||||
piano_roll: {
|
||||
switch: 'tab',
|
||||
select: 'q',
|
||||
pencil: 'w',
|
||||
hold_to_create_note: 'ctrl',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { KGProject } from '../KGProject';
|
||||
import { upgradeToV1 } from './upgradeToV1';
|
||||
import { upgradeToV2 } from './upgradeToV2';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
workingProject = upgradeToV2(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,6 +12,14 @@ export class KGPianoRollState {
|
||||
private activeTool: string = "pointer";
|
||||
private currentSnap: string = "NO SNAP";
|
||||
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() {
|
||||
console.log("KGPianoRollState initialized");
|
||||
@@ -48,4 +56,52 @@ export class KGPianoRollState {
|
||||
public setLastEditedNoteLength(length: number): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { KGCore } from '../core/KGCore';
|
||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
|
||||
import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands';
|
||||
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
|
||||
|
||||
interface UseNoteOperationsProps {
|
||||
activeRegion: KGMidiRegion | null;
|
||||
@@ -210,20 +211,64 @@ export const useNoteOperations = ({
|
||||
const lastEditedLength = KGPianoRollState.instance().getLastEditedNoteLength();
|
||||
const noteEndBeat = noteStartBeat + lastEditedLength; // Use last edited note length
|
||||
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
|
||||
const command = new CreateNoteCommand(
|
||||
activeRegion.getId(),
|
||||
noteStartBeat,
|
||||
noteEndBeat,
|
||||
pitch,
|
||||
velocity
|
||||
);
|
||||
let chordNotePitches: number[] = [];
|
||||
|
||||
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
|
||||
const createdNote = command.getCreatedNote();
|
||||
chordNotePitches = Array.from(new Set(chordNotePitches));
|
||||
}
|
||||
|
||||
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
|
||||
setNoteUpdateCounter((prev: number) => prev + 1);
|
||||
@@ -238,19 +283,19 @@ export const useNoteOperations = ({
|
||||
updateTrack(track);
|
||||
|
||||
// Play note preview if audio interface is ready and note was created
|
||||
if (createdNote) {
|
||||
if (createdNotes.length > 0) {
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized()) {
|
||||
// Try to start audio context if not started yet (user interaction will allow this)
|
||||
if (!audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.startAudioContext().catch(() => {
|
||||
// Silently fail if still not allowed - browser policy
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger note if audio context is now started
|
||||
|
||||
if (audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.triggerNote(track.getId().toString(), createdNote);
|
||||
createdNotes.forEach(note => {
|
||||
audioInterface.triggerNote(track.getId().toString(), note);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ interface ProjectState {
|
||||
timeSignature: TimeSignature;
|
||||
bpm: number;
|
||||
keySignature: KeySignature;
|
||||
selectedMode: string;
|
||||
playheadPosition: number; // in beats
|
||||
isPlaying: boolean;
|
||||
currentTime: string; // formatted time string
|
||||
@@ -87,7 +88,8 @@ interface ProjectState {
|
||||
setMaxBars: (maxBars: number) => void;
|
||||
setTimeSignature: (timeSignature: TimeSignature) => void;
|
||||
setKeySignature: (keySignature: KeySignature) => void;
|
||||
|
||||
setSelectedMode: (selectedMode: string) => void;
|
||||
|
||||
// Selection actions
|
||||
syncSelectionFromCore: () => void;
|
||||
clearAllSelections: () => void;
|
||||
@@ -222,6 +224,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
timeSignature: currentProject.getTimeSignature(),
|
||||
bpm: currentProject.getBpm(),
|
||||
keySignature: currentProject.getKeySignature(),
|
||||
selectedMode: currentProject.getSelectedMode(),
|
||||
playheadPosition: KGCore.instance().getPlayheadPosition(),
|
||||
isPlaying: KGCore.instance().getIsPlaying(),
|
||||
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
|
||||
@@ -515,6 +518,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
timeSignature,
|
||||
bpm,
|
||||
keySignature,
|
||||
selectedMode: projectToLoad.getSelectedMode(),
|
||||
playheadPosition: 0, // Ensure store state is also updated
|
||||
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
|
||||
const command = new ChangeProjectPropertyCommand({ keySignature });
|
||||
KGCore.instance().executeCommand(command);
|
||||
|
||||
|
||||
// Update the store state
|
||||
set({ keySignature });
|
||||
|
||||
|
||||
console.log(`Set key signature to ${keySignature}`);
|
||||
} catch (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
|
||||
syncSelectionFromCore,
|
||||
|
||||
@@ -784,7 +804,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
maxBars: project.getMaxBars(),
|
||||
timeSignature: project.getTimeSignature(),
|
||||
bpm: project.getBpm(),
|
||||
keySignature: project.getKeySignature()
|
||||
keySignature: project.getKeySignature(),
|
||||
selectedMode: project.getSelectedMode()
|
||||
});
|
||||
|
||||
// Sync CSS variables that affect layout
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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})
|
||||
`;
|
||||
};
|
||||
Reference in New Issue
Block a user