From 98ed8a60ceeab1e270e1802819716e96624b6d5a Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:59:09 -0800 Subject: [PATCH 01/12] feat: added mode list dropdown --- public/resources/modes/mode_list.json | 59 +++++++++++++++++++ src/App.css | 10 ++++ src/App.tsx | 20 ++++++- src/components/piano-roll/PianoRoll.tsx | 16 ++++- .../piano-roll/PianoRollToolbar.tsx | 17 +++++- src/core/state/KGPianoRollState.ts | 10 ++++ 6 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 public/resources/modes/mode_list.json diff --git a/public/resources/modes/mode_list.json b/public/resources/modes/mode_list.json new file mode 100644 index 0000000..e255879 --- /dev/null +++ b/public/resources/modes/mode_list.json @@ -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] + } + ] +} diff --git a/src/App.css b/src/App.css index 6249762..e26760b 100644 --- a/src/App.css +++ b/src/App.css @@ -597,6 +597,7 @@ body { padding: 0 10px; user-select: none; position: relative; + z-index: 100; /* Ensure toolbar and its dropdowns appear above piano roll content */ } /* Override pointer-events for piano roll toolbar sections */ @@ -609,6 +610,15 @@ body { font-size: 10px; } +.piano-roll-toolbar .toolbar-left .quant-button { + margin-left: 0px; + margin-right: 5px; +} + +.piano-roll-toolbar .toolbar-left .quant-dropdown { + left: 0; +} + /* Generic button blink effect */ .quant-button.button-blink { animation: buttonBlink 0.2s ease-in-out; diff --git a/src/App.tsx b/src/App.tsx index 3dfe826..bb49492 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { SettingsPanel } from './components/settings'; import LoadingOverlay from './components/common/LoadingOverlay'; import { useEffect as useEffectReact, useState, useRef } from 'react'; import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool'; +import { KGPianoRollState } from './core/state/KGPianoRollState'; function App() { // Enable global keyboard handler for copy/paste and undo/redo @@ -29,14 +30,27 @@ function App() { const initializeApp = async () => { // Load the current project from KGCore loadProject(null); - + // Initialize store from config after ConfigManager is ready await initializeFromConfig(); - + + // Load mode list from JSON + try { + const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`); + const data = await response.json(); + const modeNames = data.modes.map((mode: { name: string; steps: number[] }) => mode.name); + KGPianoRollState.MODE_OPTIONS = modeNames; + console.log(`Loaded ${modeNames.length} modes:`, modeNames); + } catch (error) { + console.error('Failed to load mode list:', error); + // Fallback to default mode + KGPianoRollState.MODE_OPTIONS = ['ionian']; + } + // Log maxBars to console console.log(`Project max bars: ${maxBars}`); }; - + initializeApp(); }, [loadProject, maxBars, initializeFromConfig]); diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 1602d26..c683aa8 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -35,9 +35,12 @@ const PianoRoll: React.FC = ({ // Quantization state const [quantPosition, setQuantPosition] = useState('1/8'); const [quantLength, setQuantLength] = useState('1/8'); - + // Snapping state const [snapping, setSnapping] = useState('NO SNAP'); + + // Mode state + const [selectedMode, setSelectedMode] = useState('ionian'); // Piano roll state with temporary initial values const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 }); @@ -295,6 +298,15 @@ const PianoRoll: React.FC = ({ console.log(`Selected snapping: ${value}`); } }, []); + + // Handle mode selection + const handleModeSelect = useCallback((value: string) => { + setSelectedMode(value); + KGPianoRollState.instance().setCurrentMode(value); + if (DEBUG_MODE.PIANO_ROLL) { + console.log(`Selected mode: ${value}`); + } + }, []); // Handler for receiving the setNoteUpdateCounter function from PianoRollContent const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch>) => { @@ -765,6 +777,8 @@ const PianoRoll: React.FC = ({ onQuantSelect={handleQuantSelect} snapping={snapping} onSnappingSelect={handleSnappingSelect} + selectedMode={selectedMode} + onModeChange={handleModeSelect} blinkButton={blinkButton} /> diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 07232a7..3294ae4 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -11,6 +11,8 @@ interface PianoRollToolbarProps { onQuantSelect: (type: 'position' | 'length', value: string) => void; snapping: string; onSnappingSelect: (value: string) => void; + selectedMode: string; + onModeChange: (value: string) => void; blinkButton?: string | null; } @@ -22,12 +24,21 @@ const PianoRollToolbar: React.FC = ({ onQuantSelect, snapping, onSnappingSelect, + selectedMode, + onModeChange, blinkButton = null }) => { return (
- {/* Left section - can add more tools later */} + {/* Left section with mode dropdown */} + onModeChange(value)} + label="Mode" + buttonClassName="mode-dropdown" + />
@@ -58,7 +69,7 @@ const PianoRollToolbar: React.FC = ({ buttonClassName="snapping" showValueAsLabel={true} /> - + = ({ label="Qua. Pos." buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`} /> - + Date: Tue, 9 Dec 2025 23:50:03 -0800 Subject: [PATCH 02/12] feat: add scale highlighting to Piano Roll grid --- src/App.css | 15 +- src/App.tsx | 10 +- src/components/piano-roll/PianoGrid.tsx | 18 ++- src/components/piano-roll/PianoRoll.tsx | 4 +- .../piano-roll/PianoRollContent.tsx | 9 +- .../piano-roll/PianoRollToolbar.tsx | 2 +- src/core/state/KGPianoRollState.ts | 2 +- src/util/scaleUtil.ts | 132 ++++++++++++++++++ 8 files changed, 168 insertions(+), 24 deletions(-) create mode 100644 src/util/scaleUtil.ts diff --git a/src/App.css b/src/App.css index e26760b..924e316 100644 --- a/src/App.css +++ b/src/App.css @@ -863,19 +863,8 @@ body { width: 100%; height: 100%; position: relative; - background-size: var(--region-grid-beat-width) var(--region-piano-key-height), var(--region-grid-bar-width) var(--region-piano-key-height); - background-image: - /* Vertical lines for beats */ - linear-gradient(to right, - transparent calc(var(--region-grid-beat-width) - 1px), #444 calc(var(--region-grid-beat-width) - 1px), #444 var(--region-grid-beat-width), - transparent calc(var(--region-grid-beat-width)), transparent calc(var(--region-grid-beat-width) * 2 - 1px), #444 calc(var(--region-grid-beat-width) * 2 - 1px), #444 calc(var(--region-grid-beat-width) * 2), - transparent calc(var(--region-grid-beat-width) * 2), transparent calc(var(--region-grid-beat-width) * 3 - 1px), #444 calc(var(--region-grid-beat-width) * 3 - 1px), #444 calc(var(--region-grid-beat-width) * 3), - transparent calc(var(--region-grid-beat-width) * 3), transparent calc(var(--region-grid-beat-width) * 4 - 1px), #3a3a3a calc(var(--region-grid-beat-width) * 4 - 1px), #3a3a3a calc(var(--region-grid-beat-width) * 4) - ), - /* Horizontal lines for notes */ - linear-gradient(to bottom, - transparent calc(var(--region-piano-key-height) - 1px), #3a3a3a calc(var(--region-piano-key-height) - 1px), #3a3a3a var(--region-piano-key-height) - ); + background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%; + /* background-image is now set dynamically via React inline styles in PianoGrid component */ } .piano-grid.pencil-cursor { diff --git a/src/App.tsx b/src/App.tsx index bb49492..cec4e17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,13 +38,15 @@ function App() { try { const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`); const data = await response.json(); - const modeNames = data.modes.map((mode: { name: string; steps: number[] }) => mode.name); - KGPianoRollState.MODE_OPTIONS = modeNames; - console.log(`Loaded ${modeNames.length} modes:`, modeNames); + + // Store mode data with id, name, and steps + KGPianoRollState.MODE_DATA = data.modes; + + console.log(`Loaded ${data.modes.length} modes:`, data.modes.map((m: { name: string }) => m.name)); } catch (error) { console.error('Failed to load mode list:', error); // Fallback to default mode - KGPianoRollState.MODE_OPTIONS = ['ionian']; + KGPianoRollState.MODE_DATA = [{ id: 'ionian', name: 'Ionian', steps: [2, 2, 1, 2, 2, 2, 1] }]; } // Log maxBars to console diff --git a/src/components/piano-roll/PianoGrid.tsx b/src/components/piano-roll/PianoGrid.tsx index 1a589f9..474982f 100644 --- a/src/components/piano-roll/PianoGrid.tsx +++ b/src/components/piano-roll/PianoGrid.tsx @@ -1,8 +1,10 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import type { MutableRefObject } from 'react'; import { Playhead } from '../common'; import SelectionBox from './SelectionBox'; import { isModifierKeyPressed } from '../../util/osUtil'; +import { generatePianoGridBackground } from '../../util/scaleUtil'; +import type { KeySignature } from '../../core/KGProject'; interface PianoGridProps { gridRef: MutableRefObject; @@ -18,6 +20,8 @@ interface PianoGridProps { endY: number; }; regionStartBeat?: number; + selectedMode: string; + keySignature: KeySignature; } interface CursorPosition { @@ -35,11 +39,18 @@ const PianoGrid: React.FC = ({ onMouseDown, isBoxSelecting, selectionBox, - regionStartBeat = 0 + regionStartBeat = 0, + selectedMode, + keySignature }) => { const [cursorPosition, setCursorPosition] = useState(null); const [isModifierPressed, setIsModifierPressed] = useState(false); + // Generate background with scale highlighting - only regenerate when mode or key changes + const backgroundImage = useMemo(() => { + return generatePianoGridBackground(selectedMode, keySignature); + }, [selectedMode, keySignature]); + // Track modifier key state useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -116,9 +127,10 @@ const PianoGrid: React.FC = ({ return (
-
onMouseDown(e)} diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index c683aa8..ec050f8 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -27,7 +27,7 @@ const PianoRoll: React.FC = ({ initialPosition, initialSize }) => { - const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection } = useProjectStore(); + const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature } = useProjectStore(); // Tool state for piano roll const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); @@ -792,6 +792,8 @@ const PianoRoll: React.FC = ({ tracks={tracks} onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger} onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger} + selectedMode={selectedMode} + keySignature={keySignature} />
; @@ -21,6 +22,8 @@ interface PianoRollContentProps { tracks: KGTrack[]; onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch>) => void; onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void; + selectedMode: string; + keySignature: KeySignature; } const PianoRollContent: React.FC = ({ @@ -32,7 +35,9 @@ const PianoRollContent: React.FC = ({ updateTrack, tracks, onSetNoteUpdateTrigger, - onSetDeleteNotesTrigger + onSetDeleteNotesTrigger, + selectedMode, + keySignature }) => { // Get KGCore instance const core = KGCore.instance(); @@ -227,6 +232,8 @@ const PianoRollContent: React.FC = ({ isBoxSelecting={isBoxSelectingRef.current} selectionBox={selectionBoxRef.current} regionStartBeat={activeRegion?.getStartFromBeat() || 0} + selectedMode={selectedMode} + keySignature={keySignature} > {memoizedNotes} diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 3294ae4..b1455da 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -33,7 +33,7 @@ const PianoRollToolbar: React.FC = ({
{/* Left section with mode dropdown */} ({ label: m.name, value: m.id }))} value={selectedMode} onChange={(value) => onModeChange(value)} label="Mode" diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index b1d42bc..1f1ca97 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -8,7 +8,7 @@ export class KGPianoRollState { public static SNAP_OPTIONS: string[] = ['NO SNAP', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; public static QUANT_LEN_OPTIONS: string[] = ['1/1', '1/2', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; - public static MODE_OPTIONS: string[] = []; // Will be populated from mode_list.json + public static MODE_DATA: Array<{ id: string; name: string; steps: number[] }> = []; // Modes with id, display name, and interval steps private activeTool: string = "pointer"; private currentSnap: string = "NO SNAP"; diff --git a/src/util/scaleUtil.ts b/src/util/scaleUtil.ts new file mode 100644 index 0000000..3030705 --- /dev/null +++ b/src/util/scaleUtil.ts @@ -0,0 +1,132 @@ +import { KGPianoRollState } from '../core/state/KGPianoRollState'; +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 = KGPianoRollState.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; +}; + +/** + * 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}) + `; +}; From fa201d11c561666d1d00861f443dec91699d4f5e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 9 Dec 2025 23:55:48 -0800 Subject: [PATCH 03/12] fix: display the currently selected mode in the mode dropdown --- src/components/piano-roll/PianoRollToolbar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index b1455da..92b3d42 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -38,6 +38,7 @@ const PianoRollToolbar: React.FC = ({ onChange={(value) => onModeChange(value)} label="Mode" buttonClassName="mode-dropdown" + showValueAsLabel={true} />
From dcd2d89d0f9694ac46cd2e7041abe2ddf8e0fbde Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 10 Dec 2025 21:54:16 -0800 Subject: [PATCH 04/12] feat: persistence selected mode --- src/components/piano-roll/PianoRoll.tsx | 12 +++----- src/core/KGProject.ts | 17 +++++++++-- .../project/ChangeProjectPropertyCommand.ts | 18 ++++++++++++ .../project-upgrader/KGProjectUpgrader.ts | 5 ++++ src/core/project-upgrader/upgradeToV2.ts | 20 +++++++++++++ src/stores/projectStore.ts | 29 ++++++++++++++++--- 6 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 src/core/project-upgrader/upgradeToV2.ts diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index ec050f8..8f88505 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -21,13 +21,13 @@ interface PianoRollProps { initialSize?: { width: number; height: number }; } -const PianoRoll: React.FC = ({ - onClose, +const PianoRoll: React.FC = ({ + onClose, regionId, initialPosition, initialSize }) => { - const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature } = useProjectStore(); + const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode } = useProjectStore(); // Tool state for piano roll const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); @@ -39,9 +39,6 @@ const PianoRoll: React.FC = ({ // Snapping state const [snapping, setSnapping] = useState('NO SNAP'); - // Mode state - const [selectedMode, setSelectedMode] = useState('ionian'); - // Piano roll state with temporary initial values const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 }); @@ -302,11 +299,10 @@ const PianoRoll: React.FC = ({ // Handle mode selection const handleModeSelect = useCallback((value: string) => { setSelectedMode(value); - KGPianoRollState.instance().setCurrentMode(value); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Selected mode: ${value}`); } - }, []); + }, [setSelectedMode]); // Handler for receiving the setNoteUpdateCounter function from PianoRollContent const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch>) => { diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index d1b9660..6cf07a8 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -31,11 +31,15 @@ export class KGProject { @WithDefault("C major") private keySignature: KeySignature = "C major"; + @Expose() + @WithDefault("ionian") + private selectedMode: string = "ionian"; + @Expose() @WithDefault(0) private projectStructureVersion: number = 0; - public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 1; + public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 2; @Expose() @Type(() => KGTrack, { @@ -50,13 +54,14 @@ export class KGProject { private tracks: KGTrack[] = []; // Constructor - constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) { + constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) { this.name = name; this.maxBars = maxBars; this.currentBars = currentBars; this.bpm = bpm; this.timeSignature = timeSignature; this.keySignature = keySignature; + this.selectedMode = selectedMode; this.tracks = tracks; this.projectStructureVersion = projectStructureVersion; } @@ -90,6 +95,10 @@ export class KGProject { return this.keySignature; } + public getSelectedMode(): string { + return this.selectedMode; + } + public getTracks(): KGTrack[] { return this.tracks; } @@ -115,6 +124,10 @@ export class KGProject { this.keySignature = keySignature; } + public setSelectedMode(selectedMode: string): void { + this.selectedMode = selectedMode; + } + public setTracks(tracks: KGTrack[]): void { this.tracks = tracks; } diff --git a/src/core/commands/project/ChangeProjectPropertyCommand.ts b/src/core/commands/project/ChangeProjectPropertyCommand.ts index 0724eb0..28b1f6b 100644 --- a/src/core/commands/project/ChangeProjectPropertyCommand.ts +++ b/src/core/commands/project/ChangeProjectPropertyCommand.ts @@ -13,6 +13,7 @@ export interface ProjectUpdateProperties { bpm?: number; timeSignature?: TimeSignature; keySignature?: KeySignature; + selectedMode?: string; } /** @@ -42,6 +43,7 @@ export class ChangeProjectPropertyCommand extends KGCommand { bpm: this.targetProject.getBpm(), timeSignature: { ...this.targetProject.getTimeSignature() }, // Create a copy keySignature: this.targetProject.getKeySignature(), + selectedMode: this.targetProject.getSelectedMode(), }; // Apply updates and track what actually changes @@ -95,6 +97,13 @@ export class ChangeProjectPropertyCommand extends KGCommand { updatedProperties.push(`keySignature: "${this.originalProperties.keySignature}" → "${this.newProperties.keySignature}"`); } + // Update selected mode + if (this.newProperties.selectedMode !== undefined && this.newProperties.selectedMode !== this.originalProperties.selectedMode) { + this.targetProject.setSelectedMode(this.newProperties.selectedMode); + this.changedProperties.add('selectedMode'); + updatedProperties.push(`selectedMode: "${this.originalProperties.selectedMode}" → "${this.newProperties.selectedMode}"`); + } + if (updatedProperties.length > 0) { console.log(`Updated project: ${updatedProperties.join(', ')}`); } else { @@ -147,6 +156,12 @@ export class ChangeProjectPropertyCommand extends KGCommand { restoredProperties.push(`keySignature: "${this.originalProperties.keySignature}"`); } + // Restore selected mode (only if it was changed) + if (this.changedProperties.has('selectedMode') && this.originalProperties.selectedMode !== undefined) { + this.targetProject.setSelectedMode(this.originalProperties.selectedMode); + restoredProperties.push(`selectedMode: "${this.originalProperties.selectedMode}"`); + } + console.log(`Restored project: ${restoredProperties.join(', ')}`); } @@ -171,6 +186,9 @@ export class ChangeProjectPropertyCommand extends KGCommand { if (this.newProperties.keySignature !== undefined) { updatedProps.push('key signature'); } + if (this.newProperties.selectedMode !== undefined) { + updatedProps.push('selected mode'); + } if (updatedProps.length === 1) { return `Change project ${updatedProps[0]}`; diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index a3e3089..048d7dd 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -1,5 +1,6 @@ import { KGProject } from '../KGProject'; import { upgradeToV1 } from './upgradeToV1'; +import { upgradeToV2 } from './upgradeToV2'; /** * Upgrade the given project to the latest structure version, one version at a time. @@ -23,6 +24,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject { workingProject = upgradeToV1(workingProject); break; } + case 2: { + workingProject = upgradeToV2(workingProject); + break; + } default: { // If an upgrader is missing, throw to prevent loading incompatible structures throw new Error(`No upgrader found for project structure version ${nextVersion}`); diff --git a/src/core/project-upgrader/upgradeToV2.ts b/src/core/project-upgrader/upgradeToV2.ts new file mode 100644 index 0000000..fc9aadf --- /dev/null +++ b/src/core/project-upgrader/upgradeToV2.ts @@ -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; +} diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 26906d9..4804ec3 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -39,6 +39,7 @@ interface ProjectState { timeSignature: TimeSignature; bpm: number; keySignature: KeySignature; + selectedMode: string; playheadPosition: number; // in beats isPlaying: boolean; currentTime: string; // formatted time string @@ -87,7 +88,8 @@ interface ProjectState { setMaxBars: (maxBars: number) => void; setTimeSignature: (timeSignature: TimeSignature) => void; setKeySignature: (keySignature: KeySignature) => void; - + setSelectedMode: (selectedMode: string) => void; + // Selection actions syncSelectionFromCore: () => void; clearAllSelections: () => void; @@ -222,6 +224,7 @@ export const useProjectStore = create((set, get) => { timeSignature: currentProject.getTimeSignature(), bpm: currentProject.getBpm(), keySignature: currentProject.getKeySignature(), + selectedMode: currentProject.getSelectedMode(), playheadPosition: KGCore.instance().getPlayheadPosition(), isPlaying: KGCore.instance().getIsPlaying(), currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), @@ -515,6 +518,7 @@ export const useProjectStore = create((set, get) => { timeSignature, bpm, keySignature, + selectedMode: projectToLoad.getSelectedMode(), playheadPosition: 0, // Ensure store state is also updated currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display }); @@ -616,10 +620,10 @@ export const useProjectStore = create((set, get) => { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ keySignature }); KGCore.instance().executeCommand(command); - + // Update the store state set({ keySignature }); - + console.log(`Set key signature to ${keySignature}`); } catch (error) { console.error('Error setting key signature:', error); @@ -627,6 +631,22 @@ export const useProjectStore = create((set, get) => { } }, + setSelectedMode: (selectedMode: string) => { + try { + // Create and execute the change project property command + const command = new ChangeProjectPropertyCommand({ selectedMode }); + KGCore.instance().executeCommand(command); + + // Update the store state + set({ selectedMode }); + + console.log(`Set selected mode to ${selectedMode}`); + } catch (error) { + console.error('Error setting selected mode:', error); + get().setStatus('Failed to set selected mode'); + } + }, + // Selection actions syncSelectionFromCore, @@ -784,7 +804,8 @@ export const useProjectStore = create((set, get) => { maxBars: project.getMaxBars(), timeSignature: project.getTimeSignature(), bpm: project.getBpm(), - keySignature: project.getKeySignature() + keySignature: project.getKeySignature(), + selectedMode: project.getSelectedMode() }); // Sync CSS variables that affect layout From 2636395cbfd71b13dc4aeb5e4537ef3a03d3257e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:07:02 -0800 Subject: [PATCH 05/12] feat: added functional chords resource files --- public/resources/modes/chords.json | 24 +++++++ public/resources/modes/functional_chords.json | 67 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 public/resources/modes/chords.json create mode 100644 public/resources/modes/functional_chords.json diff --git a/public/resources/modes/chords.json b/public/resources/modes/chords.json new file mode 100644 index 0000000..043f234 --- /dev/null +++ b/public/resources/modes/chords.json @@ -0,0 +1,24 @@ +{ + "I": ["C", "E", "G"], + "I⁶": ["E", "G", "C"], + "i": ["C", "Eb", "G"], + "i⁶": ["Eb", "G", "C"], + "i°": ["C", "Eb", "Gb"], + "II": ["D", "F#", "A"], + "♭II": ["Db", "F", "Ab"], + "ii": ["D", "F", "A"], + "ii°": ["D", "F", "Ab"], + "III": ["Eb", "G", "Bb"], + "iii": ["E", "G", "B"], + "IV": ["F", "A", "C"], + "IV⁶": ["A", "C", "F"], + "iv": ["F", "Ab", "C"], + "iv⁶": ["Ab", "C", "F"], + "V": ["G", "B", "D"], + "V7": ["G", "B", "D", "F"], + "v": ["G", "Bb", "D"], + "VI": ["Ab", "C", "Eb"], + "vi": ["A", "C", "E"], + "♭VII": ["Bb", "D", "F"], + "vii°": ["B", "D", "F"] +} diff --git a/public/resources/modes/functional_chords.json b/public/resources/modes/functional_chords.json new file mode 100644 index 0000000..809b778 --- /dev/null +++ b/public/resources/modes/functional_chords.json @@ -0,0 +1,67 @@ +{ + "ionian": { + "T": ["I", "vi", "iii", "I⁶"], + "S": ["IV", "ii", "vi", "IV⁶"], + "D": ["V", "V7", "vii°", "♭II"] + }, + + "aeolian": { + "T": ["i", "VI", "III", "i⁶"], + "S": ["iv", "ii°", "VI", "iv⁶"], + "D": ["v", "♭VII"] + }, + + "harmonic_minor": { + "T": ["i", "VI", "III", "i⁶"], + "S": ["iv", "ii°", "VI", "iv⁶"], + "D": ["V", "V7", "vii°", "♭II"] + }, + + "melodic_minor": { + "T": ["i", "III", "VI", "i⁶"], + "S": ["IV", "ii", "iv"], + "D": ["V", "vii°"] + }, + + "dorian": { + "T": ["i", "III", "i⁶"], + "S": ["IV", "ii"], + "D": ["v", "♭VII"] + }, + + "phrygian": { + "T": ["i", "i⁶"], + "S": ["♭II", "iv"], + "D": [] + }, + + "lydian": { + "T": ["I", "I⁶"], + "S": ["II"], + "D": ["V"] + }, + + "mixolydian": { + "T": ["I", "vi"], + "S": ["IV", "ii"], + "D": ["v", "♭VII"] + }, + + "locrian": { + "T": ["i°"], + "S": ["♭II", "iv"], + "D": [] + }, + + "phrygian_dominant": { + "T": ["i", "i⁶"], + "S": ["♭II", "iv"], + "D": ["V", "vii°"] + }, + + "harmonic_major": { + "T": ["I", "vi", "iii"], + "S": ["IV", "ii"], + "D": ["V", "V7", "vii°", "♭II"] + } +} From fe9eb79959bfc0ba9a04dfc8c45bbf2cef3ea2ba Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:37:13 -0800 Subject: [PATCH 06/12] feat: map chords base on each mode --- public/resources/modes/chords.json | 24 --- public/resources/modes/functional_chords.json | 142 ++++++++++++++---- 2 files changed, 116 insertions(+), 50 deletions(-) delete mode 100644 public/resources/modes/chords.json diff --git a/public/resources/modes/chords.json b/public/resources/modes/chords.json deleted file mode 100644 index 043f234..0000000 --- a/public/resources/modes/chords.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "I": ["C", "E", "G"], - "I⁶": ["E", "G", "C"], - "i": ["C", "Eb", "G"], - "i⁶": ["Eb", "G", "C"], - "i°": ["C", "Eb", "Gb"], - "II": ["D", "F#", "A"], - "♭II": ["Db", "F", "Ab"], - "ii": ["D", "F", "A"], - "ii°": ["D", "F", "Ab"], - "III": ["Eb", "G", "Bb"], - "iii": ["E", "G", "B"], - "IV": ["F", "A", "C"], - "IV⁶": ["A", "C", "F"], - "iv": ["F", "Ab", "C"], - "iv⁶": ["Ab", "C", "F"], - "V": ["G", "B", "D"], - "V7": ["G", "B", "D", "F"], - "v": ["G", "Bb", "D"], - "VI": ["Ab", "C", "Eb"], - "vi": ["A", "C", "E"], - "♭VII": ["Bb", "D", "F"], - "vii°": ["B", "D", "F"] -} diff --git a/public/resources/modes/functional_chords.json b/public/resources/modes/functional_chords.json index 809b778..e0092ca 100644 --- a/public/resources/modes/functional_chords.json +++ b/public/resources/modes/functional_chords.json @@ -2,66 +2,156 @@ "ionian": { "T": ["I", "vi", "iii", "I⁶"], "S": ["IV", "ii", "vi", "IV⁶"], - "D": ["V", "V7", "vii°", "♭II"] + "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"] + "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⁶"], + "T": ["i", "VI", "III+", "i⁶"], "S": ["iv", "ii°", "VI", "iv⁶"], - "D": ["V", "V7", "vii°", "♭II"] + "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", "iv"], - "D": ["V", "vii°"] + "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"] + "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": [] + "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"] + "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"] + "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": [] + "D": [], + "chords": { + "i°": ["C", "Eb", "Gb"], + "♭II": ["Db", "F", "Ab"], + "iv": ["F", "Ab", "C"] + } }, - "phrygian_dominant": { - "T": ["i", "i⁶"], + "T": ["I", "I⁶"], "S": ["♭II", "iv"], - "D": ["V", "vii°"] + "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"], + "T": ["I", "VI", "iii"], "S": ["IV", "ii"], - "D": ["V", "V7", "vii°", "♭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"] + } } } From 8512fb42040e70391445d88604c51112ff7a2a8a Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:52:23 -0800 Subject: [PATCH 07/12] feat: added logic to get suitable chords (console log only) --- src/App.tsx | 49 +++++-- src/components/piano-roll/PianoRoll.tsx | 63 +++++++- .../piano-roll/PianoRollToolbar.tsx | 22 ++- src/core/KGCore.ts | 4 + src/core/state/KGPianoRollState.ts | 21 ++- src/util/scaleUtil.ts | 135 +++++++++++++++++- 6 files changed, 274 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index cec4e17..845296a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,21 +12,30 @@ import { SettingsPanel } from './components/settings'; import LoadingOverlay from './components/common/LoadingOverlay'; import { useEffect as useEffectReact, useState, useRef } from 'react'; import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool'; -import { KGPianoRollState } from './core/state/KGPianoRollState'; +import { KGCore } from './core/KGCore'; function App() { // Enable global keyboard handler for copy/paste and undo/redo useGlobalKeyboardHandler(); // Use project store instead of local state for project name and tracks - const { + const { refreshStatus, - loadProject, maxBars, showChatBox, showSettings, setShowSettings, initializeFromConfig, + loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig, showInstrumentSelection } = useProjectStore(); + // Track if app has been initialized to prevent multiple initializations + const hasInitialized = useRef(false); + // Load project when component mounts useEffect(() => { + // Guard against multiple initializations (can happen due to React Strict Mode or rerenders) + if (hasInitialized.current) { + return; + } + hasInitialized.current = true; + const initializeApp = async () => { // Load the current project from KGCore loadProject(null); @@ -34,27 +43,39 @@ function App() { // Initialize store from config after ConfigManager is ready await initializeFromConfig(); - // Load mode list from JSON + // Load all mode and chord data files in parallel try { - const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/mode_list.json`); - const data = await response.json(); + 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 - KGPianoRollState.MODE_DATA = data.modes; + KGCore.MODE_DATA = modeListData.modes; + console.log(`Loaded ${modeListData.modes.length} modes:`, modeListData.modes.map((m: { name: string }) => m.name)); - console.log(`Loaded ${data.modes.length} modes:`, data.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 list:', error); - // Fallback to default mode - KGPianoRollState.MODE_DATA = [{ id: 'ionian', name: 'Ionian', steps: [2, 2, 1, 2, 2, 2, 1] }]; + 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 to console - console.log(`Project max bars: ${maxBars}`); + // Log maxBars after initialization completes + const currentMaxBars = useProjectStore.getState().maxBars; + console.log(`Project max bars: ${currentMaxBars}`); }; initializeApp(); - }, [loadProject, maxBars, initializeFromConfig]); + }, [loadProject, initializeFromConfig]); // Refresh status periodically to ensure UI is in sync with KGCore useEffect(() => { diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 8f88505..96ad3e5 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -13,6 +13,7 @@ import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { ConfigManager } from '../../core/config/ConfigManager'; import { beatsToBar } from '../../util/midiUtil'; import { UpdateRegionCommand } from '../../core/commands'; +import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil'; interface PianoRollProps { onClose: () => void; @@ -39,6 +40,9 @@ const PianoRoll: React.FC = ({ // Snapping state const [snapping, setSnapping] = useState('NO SNAP'); + // Chord guide state + const [chordGuide, setChordGuide] = useState('N'); + // Piano roll state with temporary initial values const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 }); @@ -303,7 +307,62 @@ const PianoRoll: React.FC = ({ 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 = {}; + for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) { + const pitchClasses: number[] = []; + let previousPitch = -1; + + for (const noteName of noteNames) { + let pitchClass = noteNameToPitchClass(noteName); + + // If this pitch is lower than the previous one, add an octave + if (previousPitch >= 0 && pitchClass <= previousPitch) { + pitchClass += 12; + } + + pitchClasses.push(pitchClass); + previousPitch = pitchClass; + } + + chordsPitchClasses[chordSymbol] = pitchClasses; + } + + // Update piano roll state + pianoRollState.setCurrentSuitableChords(suitableChords); + pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses); + + if (DEBUG_MODE.PIANO_ROLL) { + console.log(`Chord guide updated: ${chordGuide} (${functionType})`); + console.log(`Suitable chords for ${keySignature} in ${selectedMode} mode:`, suitableChords); + console.log(`Pitch classes:`, chordsPitchClasses); + } + } + }, [chordGuide, keySignature, selectedMode]); + // Handler for receiving the setNoteUpdateCounter function from PianoRollContent const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch>) => { triggerNoteUpdateRef.current = setNoteFn; @@ -775,6 +834,8 @@ const PianoRoll: React.FC = ({ onSnappingSelect={handleSnappingSelect} selectedMode={selectedMode} onModeChange={handleModeSelect} + chordGuide={chordGuide} + onChordGuideChange={handleChordGuideSelect} blinkButton={blinkButton} /> diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 92b3d42..02eda12 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { FaMousePointer, FaPencilAlt } from 'react-icons/fa'; import { KGDropdown } from '../common'; import { KGPianoRollState } from '../../core/state/KGPianoRollState'; +import { KGCore } from '../../core/KGCore'; interface PianoRollToolbarProps { activeTool: 'pointer' | 'pencil'; @@ -13,6 +14,8 @@ interface PianoRollToolbarProps { onSnappingSelect: (value: string) => void; selectedMode: string; onModeChange: (value: string) => void; + chordGuide: string; + onChordGuideChange: (value: string) => void; blinkButton?: string | null; } @@ -26,20 +29,35 @@ const PianoRollToolbar: React.FC = ({ onSnappingSelect, selectedMode, onModeChange, + chordGuide, + onChordGuideChange, blinkButton = null }) => { return (
- {/* Left section with mode dropdown */} + {/* Left section with mode and chord guide dropdowns */} ({ label: m.name, value: m.id }))} + 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} /> + onChordGuideChange(value)} + label="Chord" + buttonClassName="chord-guide-dropdown" + showValueAsLabel={true} + />
diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index aee612c..5a07788 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -17,6 +17,10 @@ export class KGCore { // Private static instance for singleton pattern private static _instance: KGCore | null = null; + // Global music data resources + public static MODE_DATA: Array<{ id: string; name: string; steps: number[] }> = []; // Modes with id, display name, and interval steps + public static FUNCTIONAL_CHORDS_DATA: Record }> = {}; // Functional chords by mode (T/S/D) with mode-specific chord notes + private currentProject: KGProject = new KGProject(); private status: string = "Ready"; diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index 1f1ca97..c2ca883 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -8,13 +8,16 @@ export class KGPianoRollState { public static SNAP_OPTIONS: string[] = ['NO SNAP', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; public static QUANT_LEN_OPTIONS: string[] = ['1/1', '1/2', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; - public static MODE_DATA: Array<{ id: string; name: string; steps: number[] }> = []; // Modes with id, display name, and interval steps private activeTool: string = "pointer"; private currentSnap: string = "NO SNAP"; private lastEditedNoteLength: number = 1; // Default to 1 beat private currentMode: string = "ionian"; // Default mode + // Chord guide state + private currentSuitableChords: Record = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]}) + private currentSuitableChordsPitchClasses: Record = {}; // Map of chord symbols to pitch classes (e.g., {"I": [0, 4, 7]}) + private constructor() { console.log("KGPianoRollState initialized"); } @@ -58,4 +61,20 @@ export class KGPianoRollState { public setCurrentMode(mode: string): void { this.currentMode = mode; } + + public getCurrentSuitableChords(): Record { + return this.currentSuitableChords; + } + + public setCurrentSuitableChords(chords: Record): void { + this.currentSuitableChords = chords; + } + + public getCurrentSuitableChordsPitchClasses(): Record { + return this.currentSuitableChordsPitchClasses; + } + + public setCurrentSuitableChordsPitchClasses(chordsPitchClasses: Record): void { + this.currentSuitableChordsPitchClasses = chordsPitchClasses; + } } \ No newline at end of file diff --git a/src/util/scaleUtil.ts b/src/util/scaleUtil.ts index 3030705..fa0982d 100644 --- a/src/util/scaleUtil.ts +++ b/src/util/scaleUtil.ts @@ -1,4 +1,4 @@ -import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import { KGCore } from '../core/KGCore'; import { pianoRollIndexToPitch } from './midiUtil'; import type { KeySignature } from '../core/KGProject'; @@ -66,7 +66,7 @@ export const getScalePitchClasses = (rootNote: string, modeSteps: number[]): num * @returns Array of interval steps, or default ionian if not found */ export const getModeSteps = (modeId: string): number[] => { - const modeData = KGPianoRollState.MODE_DATA.find(m => m.id === modeId); + 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) @@ -74,6 +74,137 @@ export const getModeSteps = (modeId: string): number[] => { 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 => { + // 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 = {}; + + 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; +}; + /** * Generates the CSS background-image string for the piano grid with scale highlighting * @param selectedMode - Current mode ID (e.g., "ionian", "dorian") From bdc7fc899a4dfe2d83320e272819a62d6d8ef573 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:14:30 -0800 Subject: [PATCH 08/12] feat: added highlighted chord guide on piano grid --- public/config.json | 1 + src/App.css | 9 ++ src/components/piano-roll/PianoGrid.tsx | 90 ++++++++++++++++++- src/components/piano-roll/PianoRoll.tsx | 14 +++ .../piano-roll/PianoRollContent.tsx | 5 +- src/core/config/ConfigManager.ts | 2 + src/util/scaleUtil.ts | 89 ++++++++++++++++++ 7 files changed, 206 insertions(+), 4 deletions(-) diff --git a/public/config.json b/public/config.json index de5b431..91ea264 100644 --- a/public/config.json +++ b/public/config.json @@ -42,6 +42,7 @@ "save": "ctrl+s" }, "piano_roll": { + "switch": "tab", "select": "q", "pencil": "w", "hold_to_create_note": "ctrl", diff --git a/src/App.css b/src/App.css index 924e316..271bcfd 100644 --- a/src/App.css +++ b/src/App.css @@ -896,6 +896,15 @@ body { transition: opacity 0.1s ease; } +.piano-grid-chord-highlight { + position: absolute; + background-color: rgba(255, 77, 77, 0.25); + border: 1px solid rgba(255, 77, 77, 0.8); + pointer-events: none; + z-index: 2; + transition: opacity 0.1s ease; +} + .resize-handle { position: absolute; right: 5px; diff --git a/src/components/piano-roll/PianoGrid.tsx b/src/components/piano-roll/PianoGrid.tsx index 474982f..8ae120f 100644 --- a/src/components/piano-roll/PianoGrid.tsx +++ b/src/components/piano-roll/PianoGrid.tsx @@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react'; import { Playhead } from '../common'; import SelectionBox from './SelectionBox'; import { isModifierKeyPressed } from '../../util/osUtil'; -import { generatePianoGridBackground } from '../../util/scaleUtil'; +import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil'; import type { KeySignature } from '../../core/KGProject'; interface PianoGridProps { @@ -22,6 +22,7 @@ interface PianoGridProps { regionStartBeat?: number; selectedMode: string; keySignature: KeySignature; + chordGuide: string; } interface CursorPosition { @@ -41,10 +42,12 @@ const PianoGrid: React.FC = ({ selectionBox, regionStartBeat = 0, selectedMode, - keySignature + keySignature, + chordGuide }) => { const [cursorPosition, setCursorPosition] = useState(null); const [isModifierPressed, setIsModifierPressed] = useState(false); + const [selectedChordIndex, setSelectedChordIndex] = useState(0); // Generate background with scale highlighting - only regenerate when mode or key changes const backgroundImage = useMemo(() => { @@ -125,6 +128,67 @@ const PianoGrid: React.FC = ({ 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]); + + // 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 (
= ({ /> {/* Vertical beat column highlight */} -
+ + {/* 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 ( +
+ ); + })} )} diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 96ad3e5..53a71b4 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -677,6 +677,19 @@ const PianoRoll: React.FC = ({ // Handle piano roll hotkeys const configManager = ConfigManager.instance(); if (configManager.getIsInitialized()) { + // Chord guide switch hotkey + const switch_key = configManager.get('hotkeys.piano_roll.switch') as string; + if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) { + // Call the switchChord function exposed by PianoGrid + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const switchChord = (window as any).__pianoGridSwitchChord; + if (typeof switchChord === 'function') { + switchChord(); + event.preventDefault(); + } + return; + } + // Snapping hotkeys const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string; const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string; @@ -851,6 +864,7 @@ const PianoRoll: React.FC = ({ onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger} selectedMode={selectedMode} keySignature={keySignature} + chordGuide={chordGuide} />
boolean) => void; selectedMode: string; keySignature: KeySignature; + chordGuide: string; } const PianoRollContent: React.FC = ({ @@ -37,7 +38,8 @@ const PianoRollContent: React.FC = ({ onSetNoteUpdateTrigger, onSetDeleteNotesTrigger, selectedMode, - keySignature + keySignature, + chordGuide }) => { // Get KGCore instance const core = KGCore.instance(); @@ -234,6 +236,7 @@ const PianoRollContent: React.FC = ({ regionStartBeat={activeRegion?.getStartFromBeat() || 0} selectedMode={selectedMode} keySignature={keySignature} + chordGuide={chordGuide} > {memoizedNotes} diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 841d552..058129b 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -48,6 +48,7 @@ interface AppConfig { save: string; }; piano_roll: { + switch: string; select: string; pencil: string; hold_to_create_note: string; @@ -203,6 +204,7 @@ export class ConfigManager { save: 'ctrl+s' }, piano_roll: { + switch: 'tab', select: 'q', pencil: 'w', hold_to_create_note: 'ctrl', diff --git a/src/util/scaleUtil.ts b/src/util/scaleUtil.ts index fa0982d..5157220 100644 --- a/src/util/scaleUtil.ts +++ b/src/util/scaleUtil.ts @@ -205,6 +205,95 @@ export const getChordNotesInKey = ( 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 = {}; + + 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") From df722abd715197c84622697c335ff6b7fbd846a5 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:40:35 -0800 Subject: [PATCH 09/12] feat: added unit tests for scaleUtil.ts --- src/util/scaleUtil.test.ts | 403 +++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 src/util/scaleUtil.test.ts diff --git a/src/util/scaleUtil.test.ts b/src/util/scaleUtil.test.ts new file mode 100644 index 0000000..b7e5afc --- /dev/null +++ b/src/util/scaleUtil.test.ts @@ -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) + }) + }) +}) From fbd13eef9fff6aa3dc28438f9af8b26a26688ed1 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:48:03 -0800 Subject: [PATCH 10/12] fix: default chord guide note's length to last edited note's length --- src/components/piano-roll/PianoGrid.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/piano-roll/PianoGrid.tsx b/src/components/piano-roll/PianoGrid.tsx index 8ae120f..142386a 100644 --- a/src/components/piano-roll/PianoGrid.tsx +++ b/src/components/piano-roll/PianoGrid.tsx @@ -5,6 +5,7 @@ import SelectionBox from './SelectionBox'; import { isModifierKeyPressed } from '../../util/osUtil'; import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil'; import type { KeySignature } from '../../core/KGProject'; +import { KGPianoRollState } from '../../core/state/KGPianoRollState'; interface PianoGridProps { gridRef: MutableRefObject; @@ -166,6 +167,8 @@ const PianoGrid: React.FC = ({ return highlights; }, [cursorPosition, matchingChords, selectedChordIndex]); + const lastEditedNoteLength = KGPianoRollState.instance().getLastEditedNoteLength(); + // Reset selected chord index when cursor moves to a different pitch or beat useEffect(() => { setSelectedChordIndex(0); @@ -235,7 +238,7 @@ const PianoGrid: React.FC = ({ style={{ top: yPosition, left: highlight.beat * beatWidth, - width: beatWidth, + width: beatWidth * lastEditedNoteLength, height: noteHeight }} /> From 54b227c1be7c8d4df05acb9cd7b80fc204dc23d2 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:59:56 -0800 Subject: [PATCH 11/12] feat: implemented the logic to automatically create a chord when a suitable guide chord is highlighted. --- src/components/piano-roll/PianoGrid.tsx | 12 ++++ src/core/state/KGPianoRollState.ts | 27 +++++++++ src/hooks/useNoteOperations.ts | 77 ++++++++++++++++++++----- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/components/piano-roll/PianoGrid.tsx b/src/components/piano-roll/PianoGrid.tsx index 142386a..14e833c 100644 --- a/src/components/piano-roll/PianoGrid.tsx +++ b/src/components/piano-roll/PianoGrid.tsx @@ -167,6 +167,18 @@ const PianoGrid: React.FC = ({ 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 diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index c2ca883..bcef9b9 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -17,6 +17,9 @@ export class KGPianoRollState { // Chord guide state private currentSuitableChords: Record = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]}) private currentSuitableChordsPitchClasses: Record = {}; // Map of chord symbols to pitch classes (e.g., {"I": [0, 4, 7]}) + private currentMatchingChords: number[][] = []; + private currentSelectedChordIndex: number = 0; + private currentChordCursorPitch: number | null = null; private constructor() { console.log("KGPianoRollState initialized"); @@ -77,4 +80,28 @@ export class KGPianoRollState { public setCurrentSuitableChordsPitchClasses(chordsPitchClasses: Record): 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; + } } \ No newline at end of file diff --git a/src/hooks/useNoteOperations.ts b/src/hooks/useNoteOperations.ts index 9b398ca..3e29bde 100644 --- a/src/hooks/useNoteOperations.ts +++ b/src/hooks/useNoteOperations.ts @@ -10,6 +10,7 @@ import { KGCore } from '../core/KGCore'; import { KGPianoRollState } from '../core/state/KGPianoRollState'; import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface'; import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands'; +import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand'; interface UseNoteOperationsProps { activeRegion: KGMidiRegion | null; @@ -210,20 +211,64 @@ export const useNoteOperations = ({ const lastEditedLength = KGPianoRollState.instance().getLastEditedNoteLength(); const noteEndBeat = noteStartBeat + lastEditedLength; // Use last edited note length const velocity = 127; // Maximum velocity + const pianoRollState = KGPianoRollState.instance(); + const matchingChordPitches = pianoRollState.getCurrentMatchingChords(); + const selectedChordIndex = pianoRollState.getCurrentSelectedChordIndex(); + const cursorChordPitch = pianoRollState.getCurrentChordCursorPitch(); + const chordIndex = matchingChordPitches.length > 0 + ? ((selectedChordIndex % matchingChordPitches.length) + matchingChordPitches.length) % matchingChordPitches.length + : 0; - // Create and execute the note creation command - const command = new CreateNoteCommand( - activeRegion.getId(), - noteStartBeat, - noteEndBeat, - pitch, - velocity - ); + let chordNotePitches: number[] = []; - core.executeCommand(command); + if ( + matchingChordPitches.length > 0 && + cursorChordPitch !== null && + Math.abs(cursorChordPitch - pitch) <= 1 + ) { + const cursorOctave = Math.floor(cursorChordPitch / 12); + const pitchClasses = matchingChordPitches[chordIndex] || []; + chordNotePitches = pitchClasses + .map(actualPitchClass => { + const actualPitch = cursorOctave * 12 + actualPitchClass; + return actualPitch >= 0 && actualPitch <= 127 ? actualPitch : null; + }) + .filter((value): value is number => value !== null); - // Get the created note for audio preview - const createdNote = command.getCreatedNote(); + chordNotePitches = Array.from(new Set(chordNotePitches)); + } + + const notePitchesToCreate = chordNotePitches.length > 0 ? chordNotePitches : [pitch]; + const isChordCreation = notePitchesToCreate.length > 1; + + let createdNotes: KGMidiNote[] = []; + + if (isChordCreation) { + const chordCommand = new CreateNotesCommand( + notePitchesToCreate.map(notePitch => ({ + regionId: activeRegion.getId(), + startBeat: noteStartBeat, + endBeat: noteEndBeat, + pitch: notePitch, + velocity + })) + ); + core.executeCommand(chordCommand); + createdNotes = chordCommand.getCreatedNotes().map(({ note }) => note); + } else { + const singleNoteCommand = new CreateNoteCommand( + activeRegion.getId(), + noteStartBeat, + noteEndBeat, + notePitchesToCreate[0], + velocity + ); + core.executeCommand(singleNoteCommand); + const createdNote = singleNoteCommand.getCreatedNote(); + if (createdNote) { + createdNotes = [createdNote]; + } + } // Increment the note update counter to trigger a re-render setNoteUpdateCounter((prev: number) => prev + 1); @@ -238,19 +283,19 @@ export const useNoteOperations = ({ updateTrack(track); // Play note preview if audio interface is ready and note was created - if (createdNote) { + if (createdNotes.length > 0) { const audioInterface = KGAudioInterface.instance(); if (audioInterface.getIsInitialized()) { - // Try to start audio context if not started yet (user interaction will allow this) if (!audioInterface.getIsAudioContextStarted()) { audioInterface.startAudioContext().catch(() => { // Silently fail if still not allowed - browser policy }); } - - // Trigger note if audio context is now started + if (audioInterface.getIsAudioContextStarted()) { - audioInterface.triggerNote(track.getId().toString(), createdNote); + createdNotes.forEach(note => { + audioInterface.triggerNote(track.getId().toString(), note); + }); } } } From 56be4440fa8c3fc26ceb9a2a5cc10b13b6fb5b7e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:18:28 -0800 Subject: [PATCH 12/12] docs: add Intelligent Chord Assistant feature to README --- README.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e3aac2f..cdf3dd6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with * **K.G.Studio Musician Assistant** is an AI assistance agent for harmony, arrangement, and note editing — but not full auto‑composition. +## Latest Updates + +- **2025.12.15**: Added Intelligent Chord Assistant with functional harmony guidance (T/S/D). Hover over piano keys to see context-aware chord suggestions and create full chords with one click! + ## Project Status **K.G.Studio is an experimental project in early development.** We're exploring the possibilities of integrating AI agents and LLMs into music production workflows — essentially building a "Cursor or Claude Code for DAW" experience. @@ -70,12 +74,13 @@ You can find the detailed user guide [here](./docs/USER_GUIDE.md). ### Highlights - **K.G.Studio Musician Assistant**: Chat with the LLM‑powered K.G.Studio Musician Assistant AI Agent; it can automatically execute tools to make music edits. +- **Intelligent Chord Assistant**: Real-time chord suggestions based on functional harmony (Tonic/Subdominant/Dominant) with visual preview and one-click chord creation. - **Multiple LLM providers**: OpenAI, Claude (via OpenRouter), Gemini (via OpenRouter), or OpenAI‑compatible (e.g., Ollama, OpenRouter). - **Track & Region editing**: Add/reorder tracks, create/move/resize regions, copy/paste regions, and more. - **Piano roll**: Create and edit notes with snapping/quantization support. - **Real instruments**: Tone.js‑based Sampler with high‑quality FluidR3 soundfonts. - **Undo/Redo everywhere**: Command pattern for tracks, regions, notes, and project properties -- **Persistence with privacy**: Save/load projects and configuration in your browser’s IndexedDB (on your device). +- **Persistence with privacy**: Save/load projects and configuration in your browser's IndexedDB (on your device). - **Export/Import**: Export your project as a MIDI file, or import a MIDI file into your project. - **Settings**: LLM provider, AI agent custom instructions, app behavior, and more. @@ -142,6 +147,13 @@ You can find the detailed user guide [here](./docs/USER_GUIDE.md). - Move/resize: drag note body to move selected notes; drag edges to resize. - Close the piano roll with X or ESC. +- Intelligent Chord Assistant (Added 2025-12-15) + - Enable chord guide from the piano roll toolbar: select T (Tonic), S (Subdominant), or D (Dominant) function. + - Hover over any key to see context-aware chord suggestions highlighted in red, matching your selected key signature and mode. + - Press Tab to cycle through different chord voicings for the same harmonic function. + - Double-click (or Ctrl/Cmd+click) on a highlighted chord to create all notes at once. + - Chord length automatically matches your last edited note for consistent rhythm. + - Snapping and Quantize - Set snapping from the NO SNAP menu (top‑right). - Quantize timing with Qua. Pos. (start) and Qua. Len. (length). @@ -216,8 +228,11 @@ K.G.Studio does not provide or host any of the models listed above, nor is it af ## Upcoming Features -- [ ] More instruments -- [ ] Automated testing (unit tests, integration tests, etc.) +Feature priorities might change. + +- [X] More instruments +- [X] Automated testing (unit tests, integration tests, etc.) +- [X] Intelligent Chord Assistant with functional harmony guidance (T/S/D) - [ ] Support track control automations (e.g. sustain, volume, pan, etc.) - [ ] Support MIDI control events (e.g. CC, pitch bend, etc.) - [ ] Support WAV audio tracks