From 9e0c0cc1f574532f93ff849337a20778946f6a44 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 9 Dec 2025 23:50:03 -0800 Subject: [PATCH] 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}) + `; +};