From 7b74687daf1148a19b1b825520d4871abd9750d4 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 2 May 2026 18:49:23 -0700 Subject: [PATCH] feat: added edit MIDI notes' attribute feature --- .../piano-roll/NoteAttributeBar.tsx | 308 ++++++++++++++++++ src/components/piano-roll/PianoNote.tsx | 27 +- src/components/piano-roll/PianoRoll.css | 68 +++- src/components/piano-roll/PianoRoll.tsx | 13 +- .../piano-roll/PianoRollContent.tsx | 4 +- src/core/commands/index.ts | 1 + .../note/UpdateNotePropertiesCommand.ts | 89 +++++ 7 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 src/components/piano-roll/NoteAttributeBar.tsx create mode 100644 src/core/commands/note/UpdateNotePropertiesCommand.ts diff --git a/src/components/piano-roll/NoteAttributeBar.tsx b/src/components/piano-roll/NoteAttributeBar.tsx new file mode 100644 index 0000000..d084960 --- /dev/null +++ b/src/components/piano-roll/NoteAttributeBar.tsx @@ -0,0 +1,308 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { KGMidiNote } from '../../core/midi/KGMidiNote'; +import { KGMidiRegion } from '../../core/region/KGMidiRegion'; +import { KGCore } from '../../core/KGCore'; +import { UpdateNotePropertiesCommand } from '../../core/commands/note/UpdateNotePropertiesCommand'; +import { useProjectStore } from '../../stores/projectStore'; +import { showAlert } from '../../util/dialogUtil'; + +interface NoteAttributeBarProps { + selectedNotes: KGMidiNote[]; + isSpectrogram: boolean; + activeRegion: KGMidiRegion | null; +} + +type TextField = 'pitch' | 'length'; + +function clamp(v: number, min: number, max: number): number { + return Math.max(min, Math.min(max, v)); +} + +function parsePitchInput(raw: string, notes: KGMidiNote[]): number[] | null { + const trimmed = raw.trim(); + if (trimmed.startsWith('+') || trimmed.startsWith('-')) { + const delta = parseInt(trimmed, 10); + if (isNaN(delta)) return null; + return notes.map(n => clamp(n.getPitch() + delta, 0, 127)); + } + const abs = parseInt(trimmed, 10); + if (isNaN(abs)) return null; + return notes.map(() => clamp(abs, 0, 127)); +} + +function parseLengthInput(raw: string): number | null { + const v = parseFloat(raw.trim()); + if (isNaN(v) || v <= 0) return null; + return v; +} + +interface VelocitySession { + originalVelocities: number[]; + notes: KGMidiNote[]; +} + +const NoteAttributeBar: React.FC = ({ selectedNotes, isSpectrogram, activeRegion }) => { + const { tracks, updateTrack } = useProjectStore(); + + // Text field popup state (pitch / length) + const [openTextField, setOpenTextField] = useState(null); + const [textInputValue, setTextInputValue] = useState(''); + + // Velocity slider popup state + const [velocityOpen, setVelocityOpen] = useState(false); + const [sliderValue, setSliderValue] = useState(64); + const velocitySessionRef = useRef(null); + + const barRef = useRef(null); + const commitVelocityRef = useRef<(() => void) | null>(null); + + // ── Text field helpers ────────────────────────────────────────────────────── + + const commitTextField = () => { + if (!openTextField || !activeRegion) { setOpenTextField(null); return; } + + const snapshots = selectedNotes.map(n => ({ + noteId: n.getId(), + pitch: n.getPitch(), + velocity: n.getVelocity(), + startBeat: n.getStartBeat(), + endBeat: n.getEndBeat(), + })); + + const updates: { noteId: string; pitch?: number; endBeat?: number }[] = []; + + if (openTextField === 'pitch') { + const newPitches = parsePitchInput(textInputValue, selectedNotes); + if (!newPitches) { setOpenTextField(null); return; } + selectedNotes.forEach((note, i) => updates.push({ noteId: note.getId(), pitch: newPitches[i] })); + } else { + const newLength = parseLengthInput(textInputValue); + if (!newLength) { setOpenTextField(null); return; } + selectedNotes.forEach(note => updates.push({ noteId: note.getId(), endBeat: note.getStartBeat() + newLength })); + } + + if (updates.length > 0) { + const command = new UpdateNotePropertiesCommand(activeRegion.getId(), snapshots, updates); + KGCore.instance().executeCommand(command); + const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); + if (track) updateTrack(track); + } + setOpenTextField(null); + }; + + const handleTextKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') commitTextField(); + if (e.key === 'Escape') setOpenTextField(null); + }; + + // ── Velocity slider helpers ───────────────────────────────────────────────── + + const commitVelocity = () => { + const session = velocitySessionRef.current; + if (!session || !activeRegion) { setVelocityOpen(false); return; } + + // Build snapshots from original velocities captured at open time + const snapshots = session.notes.map((n, i) => ({ + noteId: n.getId(), + pitch: n.getPitch(), + velocity: session.originalVelocities[i], // original, before live edits + startBeat: n.getStartBeat(), + endBeat: n.getEndBeat(), + })); + const updates = session.notes.map(n => ({ noteId: n.getId(), velocity: sliderValue })); + + // Restore originals so the command's execute() applies cleanly + session.notes.forEach((n, i) => n.setVelocity(session.originalVelocities[i])); + + const command = new UpdateNotePropertiesCommand(activeRegion.getId(), snapshots, updates); + KGCore.instance().executeCommand(command); + + const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); + if (track) updateTrack(track); + + velocitySessionRef.current = null; + setVelocityOpen(false); + }; + + const cancelVelocity = () => { + const session = velocitySessionRef.current; + if (session) { + session.notes.forEach((n, i) => n.setVelocity(session.originalVelocities[i])); + const track = tracks.find(t => t.getId().toString() === activeRegion?.getTrackId()); + if (track) updateTrack(track); + } + velocitySessionRef.current = null; + setVelocityOpen(false); + }; + + // Keep ref current so the outside-click handler always calls the latest commit + commitVelocityRef.current = commitVelocity; + + const handleSliderChange = (e: React.ChangeEvent) => { + const val = parseInt(e.target.value, 10); + setSliderValue(val); + // Live-apply to notes so colors update immediately + velocitySessionRef.current?.notes.forEach(n => n.setVelocity(val)); + const track = tracks.find(t => t.getId().toString() === activeRegion?.getTrackId()); + if (track) updateTrack(track); + }; + + const handleSliderKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') cancelVelocity(); + }; + + // ── Outside-click handler ─────────────────────────────────────────────────── + + useEffect(() => { + const isAnyOpen = openTextField !== null || velocityOpen; + if (!isAnyOpen) return; + const handleMouseDown = (e: MouseEvent) => { + if (barRef.current && !barRef.current.contains(e.target as Node)) { + if (velocityOpen) { + commitVelocityRef.current?.(); + } else { + setOpenTextField(null); + } + } + }; + document.addEventListener('mousedown', handleMouseDown); + return () => document.removeEventListener('mousedown', handleMouseDown); + }, [openTextField, velocityOpen]); + + if (isSpectrogram || selectedNotes.length === 0) { + return
; + } + + // ── Compute display values ───────────────────────────────────────────────── + + let pitch: string; + let length: string; + let velocity: string; + let pitchDefault: string; + let lengthDefault: string; + let velocityDefault: number; + + if (selectedNotes.length === 0) { + pitch = 'N/A'; length = 'N/A'; velocity = 'N/A'; + pitchDefault = ''; lengthDefault = ''; velocityDefault = 64; + } else if (selectedNotes.length === 1) { + const note = selectedNotes[0]; + pitch = String(note.getPitch()); + length = (note.getEndBeat() - note.getStartBeat()).toFixed(2); + velocity = String(note.getVelocity()); + pitchDefault = pitch; lengthDefault = length; velocityDefault = note.getVelocity(); + } else { + const pitches = selectedNotes.map(n => n.getPitch()); + const lengths = selectedNotes.map(n => (n.getEndBeat() - n.getStartBeat()).toFixed(2)); + const velocities = selectedNotes.map(n => n.getVelocity()); + pitch = pitches.every(p => p === pitches[0]) ? String(pitches[0]) : '--'; + length = lengths.every(l => l === lengths[0]) ? lengths[0] : '--'; + velocity = velocities.every(v => v === velocities[0]) ? String(velocities[0]) : '--'; + pitchDefault = pitch === '--' ? '' : pitch; + lengthDefault = length === '--' ? '' : length; + velocityDefault = velocity === '--' ? 64 : velocities[0]; + } + + // ── Field click handlers ─────────────────────────────────────────────────── + + const handleTextFieldClick = (field: TextField, defaultVal: string) => { + if (selectedNotes.length === 0) { + showAlert('Please select one or more notes to edit their properties.'); + return; + } + setTextInputValue(defaultVal); + setOpenTextField(field); + }; + + const handleVelocityClick = () => { + if (selectedNotes.length === 0) { + showAlert('Please select one or more notes to edit their properties.'); + return; + } + velocitySessionRef.current = { + originalVelocities: selectedNotes.map(n => n.getVelocity()), + notes: [...selectedNotes], + }; + setSliderValue(velocityDefault); + setVelocityOpen(true); + }; + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( +
+ {/* Pitch */} + + Pitch: +
+ + {openTextField === 'pitch' && ( +
+ setTextInputValue(e.target.value)} + onKeyDown={handleTextKeyDown} + placeholder="0–127 or ±n" + autoFocus + /> +
+ )} +
+
+ + {/* Length */} + + Length: +
+ + {openTextField === 'length' && ( +
+ setTextInputValue(e.target.value)} + onKeyDown={handleTextKeyDown} + placeholder="beats e.g. 1.00" + autoFocus + /> +
+ )} +
+
+ + {/* Velocity */} + + Velocity: +
+ + {velocityOpen && ( +
+ + {sliderValue} +
+ )} +
+
+
+ ); +}; + +export default NoteAttributeBar; diff --git a/src/components/piano-roll/PianoNote.tsx b/src/components/piano-roll/PianoNote.tsx index 447a3c2..00d187a 100644 --- a/src/components/piano-roll/PianoNote.tsx +++ b/src/components/piano-roll/PianoNote.tsx @@ -2,6 +2,20 @@ import React, { useState, useRef, useEffect } from 'react'; import { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants'; import { useProjectStore } from '../../stores/projectStore'; +// Purple (vel=0) → green (vel=64) → red (vel=127), matching Logic Pro +function velocityToColor(v: number, alpha = 1): string { + const lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t); + let r: number, g: number, b: number; + if (v <= 64) { + const t = v / 64; + r = lerp(123, 90, t); g = lerp(95, 176, t); b = lerp(160, 106, t); + } else { + const t = (v - 64) / 63; + r = lerp(90, 255, t); g = lerp(176, 85, t); b = lerp(106, 85, t); + } + return alpha === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + interface PianoNoteProps { id: string; index: number; @@ -9,6 +23,7 @@ interface PianoNoteProps { top: number; width: number; height: number; + velocity: number; onResizeStart?: (noteId: string, resizeEdge: 'start' | 'end', initialX: number) => void; onResize?: (noteId: string, resizeEdge: 'start' | 'end', deltaX: number) => void; onResizeEnd?: (noteId: string, resizeEdge: 'start' | 'end') => void; @@ -25,6 +40,7 @@ const PianoNote: React.FC = ({ top, width, height, + velocity, onResizeStart, onResize, onResizeEnd, @@ -41,6 +57,7 @@ const PianoNote: React.FC = ({ const [resizeEdge, setResizeEdge] = useState<'none' | 'start' | 'end'>('none'); const [isResizing, setIsResizing] = useState(false); const [isDragging, setIsDragging] = useState(false); + const [isHovered, setIsHovered] = useState(false); // Use refs to track states for immediate access const isResizingRef = useRef(false); @@ -80,6 +97,7 @@ const PianoNote: React.FC = ({ // Reset cursor when mouse leaves const handleMouseLeave = () => { + setIsHovered(false); if (!isResizingRef.current && !isDraggingRef.current) { setCursor('default'); setResizeEdge('none'); @@ -250,9 +268,16 @@ const PianoNote: React.FC = ({ top: `${top}px`, width: `${width}px`, height: `${height}px`, - cursor: cursor + cursor: cursor, + backgroundColor: velocityToColor(velocity), + boxShadow: isResizing + ? `0 0 10px ${velocityToColor(velocity, 0.7)}` + : isHovered + ? `0 0 5px ${velocityToColor(velocity, 0.5)}` + : undefined, }} onMouseMove={handleMouseMove} + onMouseEnter={() => setIsHovered(true)} onMouseLeave={handleMouseLeave} onMouseDown={handleMouseDown} id={id} diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index 6931e83..c161a48 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -18,8 +18,6 @@ /* Piano notes */ .piano-note { position: absolute; - background-color: #ff5555; - /* Red color */ border: 1px solid #999; border-radius: 2px; z-index: 10; @@ -30,7 +28,6 @@ .piano-note:hover { filter: brightness(1.1); - box-shadow: 0 0 5px rgba(255, 85, 85, 0.5); } .piano-note.dragging { @@ -42,7 +39,6 @@ .piano-note.resizing { opacity: 0.8; - box-shadow: 0 0 10px rgba(255, 85, 85, 0.7); z-index: 100; } @@ -77,6 +73,70 @@ /* Ensure toolbar and its dropdowns appear above piano roll content */ } +/* Note attribute bar */ +.note-attribute-bar { + height: 30px; + background-color: #252525; + border-bottom: 1px solid #3a3a3a; + display: flex; + align-items: center; + padding: 0 10px; + user-select: none; + gap: 24px; + font-size: 11px; + color: #aaa; +} + +.note-attribute-bar .attr-item { + display: flex; + align-items: center; +} + +.note-attribute-bar .attr-label { + color: #777; + margin-right: 4px; +} + +.note-attribute-bar .attr-value { + color: #ddd; +} + +.note-attribute-bar .quant-button { + font-size: 11px; + margin-left: 0; + padding: 2px 6px; +} + +.note-attr-popup { + position: absolute; + top: 100%; + left: 0; + background-color: #2d2d2d; + border: 1px solid #444; + border-radius: 3px; + padding: 6px 8px; + z-index: 1500; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + margin-top: 2px; + min-width: 120px; +} + +.note-attr-input { + background-color: #1e1e1e; + border: 1px solid #555; + border-radius: 2px; + color: #e0e0e0; + font-size: 11px; + padding: 3px 6px; + width: 100%; + outline: none; + box-sizing: border-box; +} + +.note-attr-input:focus { + border-color: #4a6b8a; +} + .piano-roll-toolbar .tool-button { width: 20px; height: 20px; diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 144eabf..0f49c5b 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState, useCallback, useLayoutEffect } from 'react'; +import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from 'react'; import './PianoRoll.css'; import type { MouseEvent } from 'react'; import { useProjectStore } from '../../stores/projectStore'; @@ -8,6 +8,7 @@ import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants'; import PianoRollHeader from './PianoRollHeader'; import PianoRollToolbar from './PianoRollToolbar'; +import NoteAttributeBar from './NoteAttributeBar'; import PianoRollContent from './PianoRollContent'; import { KGCore } from '../../core/KGCore'; import { KGMidiNote } from '../../core/midi/KGMidiNote'; @@ -41,7 +42,7 @@ const PianoRoll: React.FC = ({ }) => { const isSpectrogram = mode === 'spectrogram'; const isHybrid = mode === 'hybrid'; - const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest } = useProjectStore(); + const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds } = useProjectStore(); // Tool state for piano roll const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); @@ -73,6 +74,12 @@ const PianoRoll: React.FC = ({ const [isResizing, setIsResizing] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [activeRegion, setActiveRegion] = useState(null); + + const selectedNotes = useMemo( + () => activeRegion?.getNotes().filter(n => selectedNoteIds.includes(n.getId())) ?? [], + [activeRegion, selectedNoteIds] + ); + const pianoRollRef = useRef(null); const pianoRollContentRef = useRef(null); const pianoGridRef = useRef(null); @@ -1019,6 +1026,8 @@ const PianoRoll: React.FC = ({ onZoomChange={handleZoomChange} /> + + = ({ top={parseFloat(tempStyle.top as string)} width={parseFloat(tempStyle.width as string)} height={noteHeight} + velocity={note.getVelocity()} onResizeStart={handleNoteResizeStart} onResize={handleNoteResize} onResizeEnd={handleNoteResizeEnd} @@ -221,7 +222,7 @@ const PianoRollContent: React.FC = ({ /> ); } - + return ( = ({ top={top} width={width} height={noteHeight} + velocity={note.getVelocity()} onResizeStart={handleNoteResizeStart} onResize={handleNoteResize} onResizeEnd={handleNoteResizeEnd} diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index a90f8f2..1ae1d69 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -32,6 +32,7 @@ export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand export { ResizeNotesCommand } from './note/ResizeNotesCommand'; export { MoveNotesCommand } from './note/MoveNotesCommand'; export { PasteNotesCommand } from './note/PasteNotesCommand'; +export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand'; // Project commands export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand'; diff --git a/src/core/commands/note/UpdateNotePropertiesCommand.ts b/src/core/commands/note/UpdateNotePropertiesCommand.ts new file mode 100644 index 0000000..b357d08 --- /dev/null +++ b/src/core/commands/note/UpdateNotePropertiesCommand.ts @@ -0,0 +1,89 @@ +import { KGCommand } from '../KGCommand'; +import { KGCore } from '../../KGCore'; +import { KGMidiNote } from '../../midi/KGMidiNote'; +import { KGMidiRegion } from '../../region/KGMidiRegion'; +import { KGTrack } from '../../track/KGTrack'; + +interface NoteSnapshot { + noteId: string; + pitch: number; + velocity: number; + startBeat: number; + endBeat: number; +} + +interface NoteUpdate { + noteId: string; + pitch?: number; + velocity?: number; + endBeat?: number; +} + +export class UpdateNotePropertiesCommand extends KGCommand { + private regionId: string; + private snapshots: NoteSnapshot[]; + private updates: NoteUpdate[]; + private targetRegion: KGMidiRegion | null = null; + private parentTrack: KGTrack | null = null; + + constructor(regionId: string, snapshots: NoteSnapshot[], updates: NoteUpdate[]) { + super(); + this.regionId = regionId; + this.snapshots = [...snapshots]; + this.updates = [...updates]; + } + + execute(): void { + const core = KGCore.instance(); + const tracks = core.getCurrentProject().getTracks(); + + for (const track of tracks) { + const region = track.getRegions().find(r => r.getId() === this.regionId) as KGMidiRegion | undefined; + if (region) { + this.targetRegion = region; + this.parentTrack = track; + break; + } + } + + if (!this.targetRegion) { + throw new Error(`Region with ID ${this.regionId} not found`); + } + + const notes = this.targetRegion.getNotes(); + for (const update of this.updates) { + const note = notes.find((n: KGMidiNote) => n.getId() === update.noteId); + if (note) { + if (update.pitch !== undefined) note.setPitch(update.pitch); + if (update.velocity !== undefined) note.setVelocity(update.velocity); + if (update.endBeat !== undefined) note.setEndBeat(update.endBeat); + } + } + } + + undo(): void { + if (!this.targetRegion) { + throw new Error('Cannot undo: command was not executed'); + } + + const notes = this.targetRegion.getNotes(); + for (const snap of this.snapshots) { + const note = notes.find((n: KGMidiNote) => n.getId() === snap.noteId); + if (note) { + note.setPitch(snap.pitch); + note.setVelocity(snap.velocity); + note.setStartBeat(snap.startBeat); + note.setEndBeat(snap.endBeat); + } + } + } + + getDescription(): string { + const count = this.snapshots.length; + return count === 1 ? 'Update note properties' : `Update ${count} notes' properties`; + } + + public getParentTrack(): KGTrack | null { + return this.parentTrack; + } +}