feat: added highlighted chord guide on piano grid

This commit is contained in:
Xiaohan-Tian
2025-12-15 20:14:30 -08:00
parent 8512fb4204
commit bdc7fc899a
7 changed files with 206 additions and 4 deletions
+1
View File
@@ -42,6 +42,7 @@
"save": "ctrl+s" "save": "ctrl+s"
}, },
"piano_roll": { "piano_roll": {
"switch": "tab",
"select": "q", "select": "q",
"pencil": "w", "pencil": "w",
"hold_to_create_note": "ctrl", "hold_to_create_note": "ctrl",
+9
View File
@@ -896,6 +896,15 @@ body {
transition: opacity 0.1s ease; transition: opacity 0.1s ease;
} }
.piano-grid-chord-highlight {
position: absolute;
background-color: rgba(255, 77, 77, 0.25);
border: 1px solid rgba(255, 77, 77, 0.8);
pointer-events: none;
z-index: 2;
transition: opacity 0.1s ease;
}
.resize-handle { .resize-handle {
position: absolute; position: absolute;
right: 5px; right: 5px;
+86 -2
View File
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react';
import { Playhead } from '../common'; import { Playhead } from '../common';
import SelectionBox from './SelectionBox'; import SelectionBox from './SelectionBox';
import { isModifierKeyPressed } from '../../util/osUtil'; import { isModifierKeyPressed } from '../../util/osUtil';
import { generatePianoGridBackground } from '../../util/scaleUtil'; import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil';
import type { KeySignature } from '../../core/KGProject'; import type { KeySignature } from '../../core/KGProject';
interface PianoGridProps { interface PianoGridProps {
@@ -22,6 +22,7 @@ interface PianoGridProps {
regionStartBeat?: number; regionStartBeat?: number;
selectedMode: string; selectedMode: string;
keySignature: KeySignature; keySignature: KeySignature;
chordGuide: string;
} }
interface CursorPosition { interface CursorPosition {
@@ -41,10 +42,12 @@ const PianoGrid: React.FC<PianoGridProps> = ({
selectionBox, selectionBox,
regionStartBeat = 0, regionStartBeat = 0,
selectedMode, selectedMode,
keySignature keySignature,
chordGuide
}) => { }) => {
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null); const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
const [isModifierPressed, setIsModifierPressed] = useState(false); const [isModifierPressed, setIsModifierPressed] = useState(false);
const [selectedChordIndex, setSelectedChordIndex] = useState(0);
// Generate background with scale highlighting - only regenerate when mode or key changes // Generate background with scale highlighting - only regenerate when mode or key changes
const backgroundImage = useMemo(() => { const backgroundImage = useMemo(() => {
@@ -125,6 +128,67 @@ const PianoGrid: React.FC<PianoGridProps> = ({
setCursorPosition(null); setCursorPosition(null);
}; };
// Get all matching chords for the current hover position
const matchingChords = useMemo(() => {
if (!cursorPosition) return [];
// If chord guide is disabled, return empty array
if (chordGuide === 'N') return [];
// Use the utility function to get matching chords
const functionType = chordGuide as 'T' | 'S' | 'D';
return getMatchingChordsForPitch(cursorPosition.pitch, keySignature, selectedMode, functionType);
}, [cursorPosition, chordGuide, keySignature, selectedMode]);
// Calculate chord highlights based on selected chord index
const chordHighlights = useMemo(() => {
if (matchingChords.length === 0) return [];
// Use the selected chord index (wrap around if needed)
const chordIndex = selectedChordIndex % matchingChords.length;
const matchedChordPitches = matchingChords[chordIndex];
// Convert pitch classes to actual pitches in the same octave as cursor
const highlights: Array<{ pitch: number; beat: number }> = [];
const cursorOctave = Math.floor(cursorPosition!.pitch / 12);
// For each pitch class in the chord, create highlights
for (const pitchClass of matchedChordPitches) {
// Calculate the actual pitch in the cursor's octave
const actualPitch = cursorOctave * 12 + pitchClass;
// Ensure the pitch is in valid MIDI range (0-127)
if (actualPitch >= 0 && actualPitch <= 127) {
highlights.push({ pitch: actualPitch, beat: cursorPosition!.beat });
}
}
return highlights;
}, [cursorPosition, matchingChords, selectedChordIndex]);
// Reset selected chord index when cursor moves to a different pitch or beat
useEffect(() => {
setSelectedChordIndex(0);
}, [cursorPosition?.pitch, cursorPosition?.beat]);
// Expose switchChord function via window for hotkey handler
useEffect(() => {
const switchChord = () => {
if (matchingChords.length > 1) {
setSelectedChordIndex(prev => (prev + 1) % matchingChords.length);
}
};
// Store the function on window object so PianoRoll can call it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__pianoGridSwitchChord = switchChord;
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).__pianoGridSwitchChord;
};
}, [matchingChords.length]);
return ( return (
<div className="piano-grid-container"> <div className="piano-grid-container">
<div <div
@@ -157,6 +221,26 @@ const PianoGrid: React.FC<PianoGridProps> = ({
width: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40 width: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40
}} }}
/> />
{/* Chord highlights - render red boxes for each note in the matched chord */}
{chordHighlights.map((highlight, index) => {
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const yPosition = (107 - highlight.pitch) * noteHeight; // B7 = 107, reverse for display
return (
<div
key={`chord-highlight-${index}`}
className="piano-grid-chord-highlight"
style={{
top: yPosition,
left: highlight.beat * beatWidth,
width: beatWidth,
height: noteHeight
}}
/>
);
})}
</> </>
)} )}
+14
View File
@@ -677,6 +677,19 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Handle piano roll hotkeys // Handle piano roll hotkeys
const configManager = ConfigManager.instance(); const configManager = ConfigManager.instance();
if (configManager.getIsInitialized()) { if (configManager.getIsInitialized()) {
// Chord guide switch hotkey
const switch_key = configManager.get('hotkeys.piano_roll.switch') as string;
if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) {
// Call the switchChord function exposed by PianoGrid
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const switchChord = (window as any).__pianoGridSwitchChord;
if (typeof switchChord === 'function') {
switchChord();
event.preventDefault();
}
return;
}
// Snapping hotkeys // Snapping hotkeys
const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string; const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string;
const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string; const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string;
@@ -851,6 +864,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger} onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
selectedMode={selectedMode} selectedMode={selectedMode}
keySignature={keySignature} keySignature={keySignature}
chordGuide={chordGuide}
/> />
<div <div
@@ -24,6 +24,7 @@ interface PianoRollContentProps {
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void; onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
selectedMode: string; selectedMode: string;
keySignature: KeySignature; keySignature: KeySignature;
chordGuide: string;
} }
const PianoRollContent: React.FC<PianoRollContentProps> = ({ const PianoRollContent: React.FC<PianoRollContentProps> = ({
@@ -37,7 +38,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
onSetNoteUpdateTrigger, onSetNoteUpdateTrigger,
onSetDeleteNotesTrigger, onSetDeleteNotesTrigger,
selectedMode, selectedMode,
keySignature keySignature,
chordGuide
}) => { }) => {
// Get KGCore instance // Get KGCore instance
const core = KGCore.instance(); const core = KGCore.instance();
@@ -234,6 +236,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
regionStartBeat={activeRegion?.getStartFromBeat() || 0} regionStartBeat={activeRegion?.getStartFromBeat() || 0}
selectedMode={selectedMode} selectedMode={selectedMode}
keySignature={keySignature} keySignature={keySignature}
chordGuide={chordGuide}
> >
{memoizedNotes} {memoizedNotes}
</PianoGrid> </PianoGrid>
+2
View File
@@ -48,6 +48,7 @@ interface AppConfig {
save: string; save: string;
}; };
piano_roll: { piano_roll: {
switch: string;
select: string; select: string;
pencil: string; pencil: string;
hold_to_create_note: string; hold_to_create_note: string;
@@ -203,6 +204,7 @@ export class ConfigManager {
save: 'ctrl+s' save: 'ctrl+s'
}, },
piano_roll: { piano_roll: {
switch: 'tab',
select: 'q', select: 'q',
pencil: 'w', pencil: 'w',
hold_to_create_note: 'ctrl', hold_to_create_note: 'ctrl',
+89
View File
@@ -205,6 +205,95 @@ export const getChordNotesInKey = (
return transposedNotes; return transposedNotes;
}; };
/**
* Gets matching chords for a given pitch, prioritized by which note in the chord matches
* @param hoverPitch - The MIDI pitch being hovered over (0-127)
* @param keySignature - Key signature like "C major", "F# minor"
* @param modeId - Mode ID (e.g., "ionian", "aeolian")
* @param functionType - Functional harmony group: "T" (Tonic), "S" (Subdominant), or "D" (Dominant)
* @returns Array of chord pitch class arrays, prioritized by match position
* (root matches first, then 2nd note matches, then 3rd note, etc.)
* Each chord is adjusted so its pitch classes work in the same octave as the hover pitch
*
* Example:
* - getMatchingChordsForPitch(60, "C major", "ionian", "T")
* Returns chords where C (pitch class 0) appears, with pitch classes adjusted for display
*/
export const getMatchingChordsForPitch = (
hoverPitch: number,
keySignature: KeySignature,
modeId: string,
functionType: 'T' | 'S' | 'D'
): number[][] => {
// Get suitable chords for the given key, mode, and function
const suitableChords = getSuitableChords(keySignature, modeId, functionType);
if (Object.keys(suitableChords).length === 0) {
return [];
}
// Convert chord note names to ascending pitch classes
const chordsPitchClasses: Record<string, number[]> = {};
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
const pitchClasses: number[] = [];
let previousPitch = -1;
for (const noteName of noteNames) {
let pitchClass = noteNameToPitchClass(noteName);
// Ensure ascending order by adding 12 if pitch class <= previous
if (previousPitch >= 0 && pitchClass <= previousPitch) {
pitchClass += 12;
}
pitchClasses.push(pitchClass);
previousPitch = pitchClass;
}
chordsPitchClasses[chordSymbol] = pitchClasses;
}
// Get the pitch class of the current hovering note (mod 12)
const hoverPitchClass = hoverPitch % 12;
// Group chords by which note position matches the hovering pitch class
// matchesByPosition[0] = chords where 1st note matches
// matchesByPosition[1] = chords where 2nd note matches, etc.
const matchesByPosition: number[][][] = [];
for (const pitchClasses of Object.values(chordsPitchClasses)) {
// Check each note position in the chord
for (let i = 0; i < pitchClasses.length; i++) {
if (pitchClasses[i] % 12 === hoverPitchClass) {
// Ensure the array exists for this position
if (!matchesByPosition[i]) {
matchesByPosition[i] = [];
}
// Offset the chord based on the matched pitch class
// If the matched note is >= 12 (in the second octave), shift all notes down by 12
if (pitchClasses[i] >= 12) {
const offsetChord = pitchClasses.map(p => p - 12);
matchesByPosition[i].push(offsetChord);
} else {
matchesByPosition[i].push(pitchClasses);
}
break; // Only count each chord once (at first matching position)
}
}
}
// Flatten the grouped matches: 1st position matches first, then 2nd, then 3rd, etc.
const result: number[][] = [];
for (const matches of matchesByPosition) {
if (matches) {
result.push(...matches);
}
}
return result;
};
/** /**
* Generates the CSS background-image string for the piano grid with scale highlighting * Generates the CSS background-image string for the piano grid with scale highlighting
* @param selectedMode - Current mode ID (e.g., "ionian", "dorian") * @param selectedMode - Current mode ID (e.g., "ionian", "dorian")