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.
|
**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
|
## 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 LLM‑powered K.G.Studio Musician Assistant AI Agent; it can automatically execute tools to make music edits.
|
- **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).
|
- **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.
|
- **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.js‑based Sampler with high‑quality FluidR3 soundfonts.
|
- **Real instruments**: Tone.js‑based Sampler with high‑quality 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 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.
|
- **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 (top‑right).
|
- Set snapping from the NO SNAP menu (top‑right).
|
||||||
- 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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
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
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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]}`;
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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