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] 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")