feat: added a read-only piano roll automation view

This commit is contained in:
Xiaohan-Tian
2026-05-07 22:55:19 -07:00
parent f3d26126fa
commit 917a1b0a21
12 changed files with 787 additions and 34 deletions
+1
View File
@@ -26,6 +26,7 @@ vi.mock('../stores/projectStore', () => ({
playheadPosition: 4, playheadPosition: 4,
updateTrack: vi.fn().mockResolvedValue(undefined), updateTrack: vi.fn().mockResolvedValue(undefined),
refreshProjectState: vi.fn(), refreshProjectState: vi.fn(),
bumpAutomationRedrawVersion: vi.fn(),
}), }),
})); }));
+9 -1
View File
@@ -192,7 +192,8 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
selectedControllerEventIds, selectedControllerEventIds,
playheadPosition, playheadPosition,
updateTrack, updateTrack,
refreshProjectState refreshProjectState,
bumpAutomationRedrawVersion
} = useProjectStore(); } = useProjectStore();
const [showNotes, setShowNotes] = useState(true); const [showNotes, setShowNotes] = useState(true);
@@ -597,6 +598,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
} }
KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
bumpAutomationRedrawVersion();
} else { } else {
const controllerEvent = row.controllerEvent; const controllerEvent = row.controllerEvent;
const targetControllerEvents = selectedControllerEventIdSet.has(controllerEvent.getId()) && selectedControllerEventIds.length > 1 const targetControllerEvents = selectedControllerEventIdSet.has(controllerEvent.getId()) && selectedControllerEventIds.length > 1
@@ -694,6 +696,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
} }
KGCore.instance().executeCommand(new UpdateControllerEventPropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); KGCore.instance().executeCommand(new UpdateControllerEventPropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
bumpAutomationRedrawVersion();
} }
await updateTrack(parentTrack); await updateTrack(parentTrack);
@@ -890,6 +893,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}]); }]);
KGCore.instance().executeCommand(command); KGCore.instance().executeCommand(command);
bumpAutomationRedrawVersion();
const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend; const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend;
if (createdPitchBend) { if (createdPitchBend) {
createdPitchBend.select(); createdPitchBend.select();
@@ -910,6 +914,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}]); }]);
KGCore.instance().executeCommand(command); KGCore.instance().executeCommand(command);
bumpAutomationRedrawVersion();
const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent; const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent;
if (createdControllerEvent) { if (createdControllerEvent) {
createdControllerEvent.select(); createdControllerEvent.select();
@@ -938,6 +943,9 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
.map(row => row.controllerEvent.getId()); .map(row => row.controllerEvent.getId());
KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds, controllerEventIds)); KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds, controllerEventIds));
if (pitchBendIds.length > 0 || controllerEventIds.length > 0) {
bumpAutomationRedrawVersion();
}
rangeAnchorEventIdRef.current = null; rangeAnchorEventIdRef.current = null;
await updateTrack(parentTrack); await updateTrack(parentTrack);
refreshProjectState(); refreshProjectState();
+147 -1
View File
@@ -201,6 +201,34 @@
left: 0; left: 0;
} }
.piano-roll-automation-toolbar-group {
display: flex;
align-items: center;
margin-right: 5px;
}
.piano-roll-toolbar .automation-toggle-button {
width: 20px;
min-width: 20px;
margin: 0px 0px 0px 3px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border: 1px solid #444;
border-right: 0px;
font-size: 11px;
font-weight: 700;
}
.piano-roll-toolbar .automation-type-dropdown {
min-width: 92px;
height: 20px;
margin: 0;
padding: 0 6px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
font-size: 10px;
}
/* Spectrogram toolbar controls */ /* Spectrogram toolbar controls */
.spectrogram-toolbar-controls { .spectrogram-toolbar-controls {
display: flex; display: flex;
@@ -267,7 +295,35 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: auto; overflow-x: auto;
overflow-y: hidden;
}
.piano-roll-main-section {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width));
}
.piano-roll-main-section.with-automation {
flex: 0 0 50%;
}
.piano-roll-note-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: visible;
}
.piano-roll-automation-section {
flex: 0 0 50%;
min-height: 0;
min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width));
border-top: 1px solid #3a3a3a;
overflow: hidden;
} }
.spectrogram-loading-overlay { .spectrogram-loading-overlay {
@@ -423,6 +479,96 @@
z-index: 3; z-index: 3;
} }
.piano-roll-automation-lane {
position: relative;
height: 100%;
background: linear-gradient(180deg, #343434 0%, #2e2e2e 100%);
overflow: hidden;
}
.piano-roll-automation-track {
position: relative;
height: 100%;
min-height: 160px;
min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width));
}
.piano-roll-automation-track::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: var(--region-piano-key-width);
width: 1px;
background-color: rgba(255, 255, 255, 0.08);
}
.piano-roll-automation-gutter {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: var(--region-piano-key-width);
background: linear-gradient(180deg, #343434 0%, #2e2e2e 100%);
}
.piano-roll-automation-grid {
position: absolute;
top: 0;
bottom: 0;
left: var(--region-piano-key-width);
right: 0;
background: linear-gradient(180deg, #343434 0%, #2e2e2e 100%);
background-image:
repeating-linear-gradient(to right,
transparent,
transparent calc(var(--region-grid-beat-width) - 1px),
rgba(255, 255, 255, 0.08) calc(var(--region-grid-beat-width) - 1px),
rgba(255, 255, 255, 0.08) var(--region-grid-beat-width));
}
.piano-roll-automation-lane-label {
position: absolute;
top: 8px;
left: 10px;
z-index: 1;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: #87cefa;
text-transform: uppercase;
}
.piano-roll-automation-svg {
position: absolute;
inset: 0;
overflow: visible;
}
.piano-roll-automation-line {
filter: drop-shadow(0 0 2px rgba(135, 206, 250, 0.3));
}
.piano-roll-automation-value {
font-size: 11px;
font-weight: 700;
paint-order: stroke;
stroke: rgba(30, 30, 30, 0.8);
stroke-width: 2px;
pointer-events: none;
}
.piano-roll-automation-empty-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #7893a5;
font-size: 11px;
pointer-events: none;
}
.resize-handle { .resize-handle {
position: absolute; position: absolute;
right: 5px; right: 5px;
+25 -4
View File
@@ -22,6 +22,7 @@ import {
normalizeSpectrogramHeightResolution, normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution, type SpectrogramHeightResolution,
} from '../../util/spectrogramUtil'; } from '../../util/spectrogramUtil';
import type { PianoRollAutomationType } from './pianoRollAutomation';
interface PianoRollProps { interface PianoRollProps {
onClose: () => void; onClose: () => void;
@@ -46,7 +47,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}) => { }) => {
const isSpectrogram = mode === 'spectrogram'; const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid'; const isHybrid = mode === 'hybrid';
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showListEventPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds } = useProjectStore(); const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showListEventPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion } = useProjectStore();
// Tool state for piano roll // Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
@@ -59,6 +60,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Piano roll zoom (1x8x); updates --region-grid-beat-width CSS variable // Piano roll zoom (1x8x); updates --region-grid-beat-width CSS variable
const [pianoRollZoom, setPianoRollZoom] = useState<number>(1); const [pianoRollZoom, setPianoRollZoom] = useState<number>(1);
const [automationEnabled, setAutomationEnabled] = useState(false);
const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend');
// Quantization state // Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8'); const [quantPosition, setQuantPosition] = useState<string>('1/8');
@@ -88,6 +91,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const pianoRollRef = useRef<HTMLDivElement>(null); const pianoRollRef = useRef<HTMLDivElement>(null);
const pianoRollContentRef = useRef<HTMLDivElement>(null); const pianoRollContentRef = useRef<HTMLDivElement>(null);
const pianoRollNoteScrollRef = useRef<HTMLDivElement>(null);
const pianoGridRef = useRef<HTMLDivElement>(null); const pianoGridRef = useRef<HTMLDivElement>(null);
const wasDraggingRef = useRef<boolean>(false); const wasDraggingRef = useRef<boolean>(false);
@@ -686,9 +690,17 @@ const PianoRoll: React.FC<PianoRollProps> = ({
setPianoRollZoom(nextZoom); setPianoRollZoom(nextZoom);
}, [pianoRollZoom]); }, [pianoRollZoom]);
const handleAutomationToggle = useCallback(() => {
setAutomationEnabled(current => !current);
}, []);
const handleAutomationTypeChange = useCallback((value: PianoRollAutomationType) => {
setAutomationType(value);
}, []);
// Calculate C4 position and scroll to it when piano roll opens // Calculate C4 position and scroll to it when piano roll opens
useEffect(() => { useEffect(() => {
if (pianoRollContentRef.current) { if (pianoRollNoteScrollRef.current) {
// Calculate position of C4 // Calculate position of C4
// We have 8 octaves (0-7), and C4 is in the middle // We have 8 octaves (0-7), and C4 is in the middle
// Each octave has 12 notes, each note is piano key height // Each octave has 12 notes, each note is piano key height
@@ -704,7 +716,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const totalHeight = 8 * 12 * keyHeight; const totalHeight = 8 * 12 * keyHeight;
// Get the viewport height of the piano roll content // Get the viewport height of the piano roll content
const viewportHeight = pianoRollContentRef.current.clientHeight; const viewportHeight = pianoRollNoteScrollRef.current.clientHeight;
// Calculate scroll position to center C4 // Calculate scroll position to center C4
// We need to scroll from the top, so we calculate: // We need to scroll from the top, so we calculate:
@@ -712,7 +724,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2); const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2);
// Scroll to the calculated position // Scroll to the calculated position
pianoRollContentRef.current.scrollTop = Math.max(0, scrollPosition); pianoRollNoteScrollRef.current.scrollTop = Math.max(0, scrollPosition);
} }
}, []); }, []);
@@ -1065,12 +1077,18 @@ const PianoRoll: React.FC<PianoRollProps> = ({
onPowerChange={setSpectrogramPower} onPowerChange={setSpectrogramPower}
zoom={pianoRollZoom} zoom={pianoRollZoom}
onZoomChange={handleZoomChange} onZoomChange={handleZoomChange}
showAutomationControls={!isSpectrogram}
automationEnabled={automationEnabled}
automationType={automationType}
onAutomationToggle={handleAutomationToggle}
onAutomationTypeChange={handleAutomationTypeChange}
/> />
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isSpectrogram} activeRegion={activeRegion} /> <NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isSpectrogram} activeRegion={activeRegion} />
<PianoRollContent <PianoRollContent
contentRef={pianoRollContentRef} contentRef={pianoRollContentRef}
noteScrollRef={pianoRollNoteScrollRef}
pianoGridRef={pianoGridRef} pianoGridRef={pianoGridRef}
maxBars={maxBars} maxBars={maxBars}
timeSignature={timeSignature} timeSignature={timeSignature}
@@ -1091,6 +1109,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
spectrogramPower={spectrogramPower} spectrogramPower={spectrogramPower}
spectrogramHeightResolution={spectrogramHeightResolution} spectrogramHeightResolution={spectrogramHeightResolution}
pianoRollZoom={pianoRollZoom} pianoRollZoom={pianoRollZoom}
automationEnabled={automationEnabled}
automationType={automationType}
automationRedrawVersion={automationRedrawVersion}
/> />
<div <div
@@ -0,0 +1,77 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import PianoRollAutomationLane from './PianoRollAutomationLane';
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
import {
createMockMidiControllerEvent,
createMockMidiPitchBend,
createMockMidiRegion,
} from '../../test/utils/mock-data';
describe('PianoRollAutomationLane', () => {
it('renders pitch bend points with signed labels', () => {
const region = createMockMidiRegion({
startFromBeat: 4,
pitchBends: [
createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 8192 }),
createMockMidiPitchBend({ id: 'bend-2', beat: 1.5, value: 12288 }),
],
});
render(
<PianoRollAutomationLane
activeRegion={region}
automationType="pitch-bend"
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
/>
);
expect(screen.getByLabelText('Pitch Bend automation lane')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('4096')).toBeInTheDocument();
expect(document.querySelector('.piano-roll-automation-line')).not.toBeNull();
});
it('renders controller values for the selected CC bucket', () => {
const controllerEventsByType: KGMidiControllerEvent[][] = Array.from({ length: 128 }, () => []);
controllerEventsByType[7] = [
createMockMidiControllerEvent({ id: 'cc7-1', beat: 0.25, value: 57 }),
createMockMidiControllerEvent({ id: 'cc7-2', beat: 2, value: 82 }),
];
const region = createMockMidiRegion({
startFromBeat: 0,
controllerEventsByType,
});
render(
<PianoRollAutomationLane
activeRegion={region}
automationType="cc-7"
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
/>
);
expect(screen.getByLabelText('CC7 automation lane')).toBeInTheDocument();
expect(screen.getByText('57')).toBeInTheDocument();
expect(screen.getByText('82')).toBeInTheDocument();
});
it('shows an empty lane shell when the selected automation type has no events', () => {
const region = createMockMidiRegion();
render(
<PianoRollAutomationLane
activeRegion={region}
automationType="cc-64"
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
/>
);
expect(screen.getByText('No CC64 events in this region')).toBeInTheDocument();
});
});
@@ -0,0 +1,172 @@
import React, { useMemo } from 'react';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import {
MIDI_PITCH_BEND_MAX,
MIDI_PITCH_BEND_MIN,
midiPitchBendToSignedValue,
} from '../../util/midiUtil';
import {
getControllerNumberForAutomationType,
PIANO_ROLL_AUTOMATION_OPTIONS,
type PianoRollAutomationType,
} from './pianoRollAutomation';
interface AutomationPoint {
id: string;
absoluteBeat: number;
value: number;
label: string;
}
interface PianoRollAutomationLaneProps {
activeRegion: KGMidiRegion | null;
automationType: PianoRollAutomationType;
maxBars: number;
timeSignature: { numerator: number; denominator: number };
bpm?: number;
redrawVersion?: number;
}
const AUTOMATION_COLOR = '#87CEFA';
const LANE_HEIGHT = 160;
const LANE_PADDING_Y = 16;
const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
activeRegion,
automationType,
maxBars,
timeSignature,
redrawVersion = 0,
}) => {
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const keyWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')) || 60;
const points = useMemo<AutomationPoint[]>(() => {
if (!activeRegion) {
return [];
}
const regionStartBeat = activeRegion.getStartFromBeat();
if (automationType === 'pitch-bend') {
return activeRegion.getPitchBends().map((pitchBend) => ({
id: pitchBend.getId(),
absoluteBeat: regionStartBeat + pitchBend.getBeat(),
value: pitchBend.getValue(),
label: `${midiPitchBendToSignedValue(pitchBend.getValue())}`,
}));
}
const controller = getControllerNumberForAutomationType(automationType);
if (controller === null) {
return [];
}
return activeRegion.getControllerEvents(controller).map((event) => ({
id: event.getId(),
absoluteBeat: regionStartBeat + event.getBeat(),
value: event.getValue(),
label: `${event.getValue()}`,
}));
}, [activeRegion, automationType, redrawVersion]);
const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType);
const laneLabel = selectedOption?.label ?? automationType;
const totalBeats = maxBars * timeSignature.numerator;
const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))';
const toY = (value: number): number => {
const minValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MIN : 0;
const maxValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MAX : 127;
const usableHeight = LANE_HEIGHT - LANE_PADDING_Y * 2;
const normalized = (value - minValue) / (maxValue - minValue);
return LANE_HEIGHT - LANE_PADDING_Y - normalized * usableHeight;
};
const svgPoints = points.map(point => {
return {
...point,
x: point.absoluteBeat * beatWidth + keyWidth,
y: toY(point.value),
};
});
const polylinePoints = (() => {
if (svgPoints.length === 0) {
return '';
}
const renderedPoints = [...svgPoints];
const lastPoint = renderedPoints[renderedPoints.length - 1];
renderedPoints.push({
...lastPoint,
id: `${lastPoint.id}-tail`,
x: beatWidth * totalBeats + keyWidth,
});
return renderedPoints
.map(point => `${point.x},${point.y}`)
.join(' ');
})();
return (
<div
className="piano-roll-automation-lane"
data-testid="piano-roll-automation-lane"
aria-label={`${laneLabel} automation lane`}
>
<div className="piano-roll-automation-track" style={{ width: totalWidth }}>
<div className="piano-roll-automation-gutter" />
<div className="piano-roll-automation-grid" />
<div className="piano-roll-automation-lane-label">{laneLabel}</div>
<svg
className="piano-roll-automation-svg"
width="100%"
height={LANE_HEIGHT}
viewBox={`0 0 ${beatWidth * totalBeats + keyWidth} ${LANE_HEIGHT}`}
preserveAspectRatio="none"
>
{points.length > 0 && (
<polyline
className="piano-roll-automation-line"
fill="none"
stroke={AUTOMATION_COLOR}
strokeWidth="2"
points={polylinePoints}
/>
)}
{svgPoints.map(point => {
const labelY = Math.max(14, Math.min(LANE_HEIGHT - 6, point.y - 10));
return (
<g key={point.id}>
<circle
className="piano-roll-automation-point"
cx={point.x}
cy={point.y}
r="5"
fill={AUTOMATION_COLOR}
stroke="#1d2428"
strokeWidth="2"
/>
<text
className="piano-roll-automation-value"
x={point.x + 6}
y={labelY}
fill={AUTOMATION_COLOR}
>
{point.label}
</text>
</g>
);
})}
</svg>
{points.length === 0 && (
<div className="piano-roll-automation-empty-state">
No {laneLabel} events in this region
</div>
)}
</div>
</div>
);
};
export default PianoRollAutomationLane;
@@ -0,0 +1,109 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import PianoRollContent from './PianoRollContent';
import { createMockMidiRegion } from '../../test/utils/mock-data';
import type { KeySignature } from '../../core/KGProject';
vi.mock('../../stores/projectStore', () => ({
useProjectStore: (selector: (state: { isRecording: boolean; recordingNotes: [] }) => unknown) => (
selector({ isRecording: false, recordingNotes: [] })
),
}));
vi.mock('../../hooks/useNoteOperations', () => ({
useNoteOperations: () => ({
resizingNoteId: null,
draggingNoteId: null,
tempNoteStyles: {},
noteUpdateCounter: 0,
setNoteUpdateCounter: vi.fn(),
handleGridDoubleClick: vi.fn(),
handleGridClick: vi.fn(),
handleNoteResizeStart: vi.fn(),
handleNoteResize: vi.fn(),
handleNoteResizeEnd: vi.fn(),
handleNoteDragStart: vi.fn(),
handleNoteDrag: vi.fn(),
handleNoteDragEnd: vi.fn(),
deleteSelectedNotes: vi.fn(),
}),
}));
vi.mock('../../hooks/useNoteSelection', () => ({
useNoteSelection: () => ({
selectedNoteIds: new Set<string>(),
isBoxSelectingRef: { current: false },
selectionBoxRef: { current: { startX: 0, startY: 0, endX: 0, endY: 0 } },
selectionBoxRender: 0,
handleNoteClick: vi.fn(),
handleBackgroundClick: vi.fn(),
handleBackgroundMouseDown: vi.fn(),
cleanupSelectionListeners: vi.fn(),
}),
}));
vi.mock('./PianoGridHeader', () => ({ default: () => <div data-testid="piano-grid-header" /> }));
vi.mock('./PianoKeys', () => ({ default: () => <div data-testid="piano-keys" /> }));
vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) => <div data-testid="piano-grid">{children}</div> }));
vi.mock('./PianoNote', () => ({ default: () => <div data-testid="piano-note" /> }));
vi.mock('./PianoRollAutomationLane', () => ({ default: () => <div data-testid="automation-lane" /> }));
describe('PianoRollContent', () => {
const baseProps = {
contentRef: { current: null },
noteScrollRef: { current: null },
pianoGridRef: { current: null },
maxBars: 8,
timeSignature: { numerator: 4, denominator: 4 },
activeRegion: createMockMidiRegion(),
updateTrack: vi.fn(),
tracks: [],
selectedMode: 'ionian',
keySignature: 'C major' as KeySignature,
chordGuide: 'N',
bpm: 120,
};
it('keeps the single-pane layout when automation is disabled', () => {
render(
<PianoRollContent
{...baseProps}
mode="midi-edit"
automationEnabled={false}
automationType="pitch-bend"
/>
);
expect(screen.getByTestId('piano-roll-content-single')).toBeInTheDocument();
expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument();
});
it('renders the split layout and automation lane in midi mode', () => {
render(
<PianoRollContent
{...baseProps}
mode="midi-edit"
automationEnabled={true}
automationType="cc-7"
/>
);
expect(screen.getByTestId('piano-roll-content-split')).toBeInTheDocument();
expect(screen.getByTestId('automation-lane')).toBeInTheDocument();
});
it('suppresses the automation lane in spectrogram mode', () => {
render(
<PianoRollContent
{...baseProps}
mode="spectrogram"
automationEnabled={true}
automationType="cc-7"
/>
);
expect(screen.getByTestId('piano-roll-content-single')).toBeInTheDocument();
expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument();
});
});
+55 -27
View File
@@ -15,9 +15,12 @@ import type { KeySignature } from '../../core/KGProject';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { velocityToColor } from '../../util/velocityColor'; import { velocityToColor } from '../../util/velocityColor';
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil'; import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
import PianoRollAutomationLane from './PianoRollAutomationLane';
import type { PianoRollAutomationType } from './pianoRollAutomation';
interface PianoRollContentProps { interface PianoRollContentProps {
contentRef: React.MutableRefObject<HTMLDivElement | null>; contentRef: React.MutableRefObject<HTMLDivElement | null>;
noteScrollRef: React.MutableRefObject<HTMLDivElement | null>;
pianoGridRef: React.MutableRefObject<HTMLDivElement | null>; pianoGridRef: React.MutableRefObject<HTMLDivElement | null>;
maxBars: number; maxBars: number;
timeSignature: { numerator: number; denominator: number }; timeSignature: { numerator: number; denominator: number };
@@ -38,10 +41,14 @@ interface PianoRollContentProps {
spectrogramPower?: number; spectrogramPower?: number;
spectrogramHeightResolution?: SpectrogramHeightResolution; spectrogramHeightResolution?: SpectrogramHeightResolution;
pianoRollZoom?: number; pianoRollZoom?: number;
automationEnabled?: boolean;
automationType?: PianoRollAutomationType;
automationRedrawVersion?: number;
} }
const PianoRollContent: React.FC<PianoRollContentProps> = ({ const PianoRollContent: React.FC<PianoRollContentProps> = ({
contentRef, contentRef,
noteScrollRef,
pianoGridRef, pianoGridRef,
maxBars, maxBars,
timeSignature, timeSignature,
@@ -62,8 +69,12 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
spectrogramPower = 0.5, spectrogramPower = 0.5,
spectrogramHeightResolution = 3, spectrogramHeightResolution = 3,
pianoRollZoom = 1, pianoRollZoom = 1,
automationEnabled = false,
automationType = 'pitch-bend',
automationRedrawVersion = 0,
}) => { }) => {
const isSpectrogram = mode === 'spectrogram'; const isSpectrogram = mode === 'spectrogram';
const showAutomationLane = automationEnabled && !isSpectrogram;
const [spectrogramLoading, setSpectrogramLoading] = useState(false); const [spectrogramLoading, setSpectrogramLoading] = useState(false);
const handleSpectrogramLoadingChange = useCallback((loading: boolean) => { const handleSpectrogramLoadingChange = useCallback((loading: boolean) => {
setSpectrogramLoading(loading); setSpectrogramLoading(loading);
@@ -275,37 +286,54 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
<div <div
className="piano-roll-content" className="piano-roll-content"
ref={contentRef} ref={contentRef}
data-testid={showAutomationLane ? 'piano-roll-content-split' : 'piano-roll-content-single'}
> >
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} /> <div className={`piano-roll-main-section ${showAutomationLane ? 'with-automation' : ''}`}>
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
<div className="piano-roll-body"> <div className="piano-roll-note-scroll" ref={noteScrollRef}>
<PianoKeys activeRegion={activeRegion} /> <div className="piano-roll-body">
<PianoKeys activeRegion={activeRegion} />
<PianoGrid <PianoGrid
gridRef={pianoGridRef} gridRef={pianoGridRef}
onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick} onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick}
onClick={isSpectrogram ? () => {} : handleCombinedClick} onClick={isSpectrogram ? () => {} : handleCombinedClick}
onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown} onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown}
isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current} isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current}
selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current} selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current}
regionStartBeat={activeRegion?.getStartFromBeat() || 0} regionStartBeat={activeRegion?.getStartFromBeat() || 0}
selectedMode={selectedMode} selectedMode={selectedMode}
keySignature={keySignature} keySignature={keySignature}
chordGuide={chordGuide} chordGuide={chordGuide}
audioRegion={audioRegion} audioRegion={audioRegion}
trackId={trackId} trackId={trackId}
projectName={projectName} projectName={projectName}
bpm={bpm} bpm={bpm}
spectrogramThresholdDb={spectrogramThresholdDb} spectrogramThresholdDb={spectrogramThresholdDb}
spectrogramPower={spectrogramPower} spectrogramPower={spectrogramPower}
spectrogramHeightResolution={spectrogramHeightResolution} spectrogramHeightResolution={spectrogramHeightResolution}
pianoRollZoom={pianoRollZoom} pianoRollZoom={pianoRollZoom}
onSpectrogramLoadingChange={handleSpectrogramLoadingChange} onSpectrogramLoadingChange={handleSpectrogramLoadingChange}
> >
{memoizedNotes} {memoizedNotes}
{!isSpectrogram && recordingNoteOverlays} {!isSpectrogram && recordingNoteOverlays}
</PianoGrid> </PianoGrid>
</div>
</div>
</div> </div>
{showAutomationLane && (
<div className="piano-roll-automation-section">
<PianoRollAutomationLane
activeRegion={activeRegion}
automationType={automationType}
maxBars={maxBars}
timeSignature={timeSignature}
bpm={bpm}
redrawVersion={automationRedrawVersion}
/>
</div>
)}
</div> </div>
{spectrogramLoading && ( {spectrogramLoading && (
<div className="spectrogram-loading-overlay"> <div className="spectrogram-loading-overlay">
@@ -0,0 +1,112 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import PianoRollToolbar from './PianoRollToolbar';
vi.mock('../common', () => ({
KGDropdown: ({
value,
onChange,
options,
label,
showValueAsLabel,
}: {
value: string;
onChange: (value: string) => void;
options: Array<string | { label: string; value: string }>;
label: string;
showValueAsLabel?: boolean;
}) => {
const selected = options.find(option => (typeof option === 'string' ? option : option.value) === value);
const text = showValueAsLabel
? (typeof selected === 'string' ? selected : selected?.label) ?? value
: label;
return (
<button type="button" onClick={() => onChange('cc-11')} aria-label={text}>
{text}
</button>
);
},
}));
vi.mock('../../core/KGCore', () => ({
KGCore: {
FUNCTIONAL_CHORDS_DATA: {
ionian: { name: 'Ionian' },
dorian: { name: 'Dorian' },
},
},
}));
describe('PianoRollToolbar', () => {
const baseProps = {
activeTool: 'pointer' as const,
onToolSelect: vi.fn(),
quantPosition: '1/8',
quantLength: '1/8',
onQuantSelect: vi.fn(),
snapping: 'NO SNAP',
onSnappingSelect: vi.fn(),
selectedMode: 'ionian',
onModeChange: vi.fn(),
chordGuide: 'N',
onChordGuideChange: vi.fn(),
zoom: 1,
onZoomChange: vi.fn(),
};
it('shows automation controls in midi mode and toggles the lane', () => {
const onAutomationToggle = vi.fn();
render(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={true}
automationEnabled={false}
automationType="pitch-bend"
onAutomationToggle={onAutomationToggle}
onAutomationTypeChange={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Toggle automation lane' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Pitch Bend/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Toggle automation lane' }));
expect(onAutomationToggle).toHaveBeenCalledTimes(1);
});
it('changes the automation type from the dropdown', () => {
const onAutomationTypeChange = vi.fn();
render(
<PianoRollToolbar
{...baseProps}
mode="hybrid"
showAutomationControls={true}
automationEnabled={true}
automationType="pitch-bend"
onAutomationToggle={vi.fn()}
onAutomationTypeChange={onAutomationTypeChange}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Pitch Bend/i }));
expect(onAutomationTypeChange).toHaveBeenCalledWith('cc-11');
});
it('hides automation controls in spectrogram mode', () => {
render(
<PianoRollToolbar
{...baseProps}
mode="spectrogram"
showAutomationControls={false}
/>
);
expect(screen.queryByRole('button', { name: 'Toggle automation lane' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument();
});
});
@@ -3,6 +3,10 @@ import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
import { KGDropdown } from '../common'; import { KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGCore } from '../../core/KGCore'; import { KGCore } from '../../core/KGCore';
import {
PIANO_ROLL_AUTOMATION_OPTIONS,
type PianoRollAutomationType,
} from './pianoRollAutomation';
const POWER_OPTIONS = [ const POWER_OPTIONS = [
{ label: 'Linear', value: '1.0' }, { label: 'Linear', value: '1.0' },
@@ -31,6 +35,11 @@ interface PianoRollToolbarProps {
onPowerChange?: (power: number) => void; onPowerChange?: (power: number) => void;
zoom: number; zoom: number;
onZoomChange: (value: number) => void; onZoomChange: (value: number) => void;
showAutomationControls?: boolean;
automationEnabled?: boolean;
automationType?: PianoRollAutomationType;
onAutomationToggle?: () => void;
onAutomationTypeChange?: (value: PianoRollAutomationType) => void;
} }
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
@@ -53,6 +62,11 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onPowerChange, onPowerChange,
zoom, zoom,
onZoomChange, onZoomChange,
showAutomationControls = false,
automationEnabled = false,
automationType = 'pitch-bend',
onAutomationToggle,
onAutomationTypeChange,
}) => { }) => {
const isSpectrogram = mode === 'spectrogram'; const isSpectrogram = mode === 'spectrogram';
const showMidiControls = mode !== 'spectrogram'; // midi-edit and hybrid const showMidiControls = mode !== 'spectrogram'; // midi-edit and hybrid
@@ -90,6 +104,26 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
> >
<FaPencilAlt /> <FaPencilAlt />
</button> </button>
{showAutomationControls && (
<div className="piano-roll-automation-toolbar-group">
<button
className={`tool-button automation-toggle-button ${automationEnabled ? 'active' : ''}`}
onClick={() => onAutomationToggle?.()}
title="Toggle automation lane"
aria-label="Toggle automation lane"
>
A
</button>
<KGDropdown
options={PIANO_ROLL_AUTOMATION_OPTIONS}
value={automationType}
onChange={(value) => onAutomationTypeChange?.(value as PianoRollAutomationType)}
label="Automation"
buttonClassName="automation-type-dropdown"
showValueAsLabel={true}
/>
</div>
)}
<KGDropdown <KGDropdown
options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))} options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))}
value={selectedMode} value={selectedMode}
@@ -0,0 +1,39 @@
export type PianoRollAutomationType =
| 'pitch-bend'
| 'cc-1'
| 'cc-2'
| 'cc-7'
| 'cc-11'
| 'cc-64';
export interface PianoRollAutomationOption {
label: string;
value: PianoRollAutomationType;
}
export const PIANO_ROLL_AUTOMATION_OPTIONS: PianoRollAutomationOption[] = [
{ label: 'Pitch Bend', value: 'pitch-bend' },
{ label: 'CC1', value: 'cc-1' },
{ label: 'CC2', value: 'cc-2' },
{ label: 'CC7', value: 'cc-7' },
{ label: 'CC11', value: 'cc-11' },
{ label: 'CC64', value: 'cc-64' },
];
export function getControllerNumberForAutomationType(type: PianoRollAutomationType): number | null {
switch (type) {
case 'cc-1':
return 1;
case 'cc-2':
return 2;
case 'cc-7':
return 7;
case 'cc-11':
return 11;
case 'cc-64':
return 64;
case 'pitch-bend':
default:
return null;
}
}
+6
View File
@@ -85,6 +85,7 @@ interface ProjectState {
activeRegionId: string | null; activeRegionId: string | null;
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid'; pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
hybridAudioRegionId: string | null; hybridAudioRegionId: string | null;
automationRedrawVersion: number;
// ChatBox state // ChatBox state
showChatBox: boolean; showChatBox: boolean;
@@ -168,6 +169,7 @@ interface ProjectState {
openMidiPianoRoll: (regionId: string) => void; openMidiPianoRoll: (regionId: string) => void;
openSpectrogramViewer: (regionId: string) => void; openSpectrogramViewer: (regionId: string) => void;
openHybridMode: (midiRegionId: string, audioRegionId: string) => void; openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
bumpAutomationRedrawVersion: () => void;
// Project state cleanup // Project state cleanup
cleanupProjectState: () => void; cleanupProjectState: () => void;
@@ -374,6 +376,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
activeRegionId: null, activeRegionId: null,
pianoRollMode: 'midi-edit' as const, pianoRollMode: 'midi-edit' as const,
hybridAudioRegionId: null, hybridAudioRegionId: null,
automationRedrawVersion: 0,
// Initial ChatBox state // Initial ChatBox state
showChatBox: initialChatBoxState, showChatBox: initialChatBoxState,
@@ -1250,6 +1253,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
openHybridMode: (midiRegionId: string, audioRegionId: string) => { openHybridMode: (midiRegionId: string, audioRegionId: string) => {
set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' }); set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' });
}, },
bumpAutomationRedrawVersion: () => {
set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 }));
},
// Project state cleanup - used when starting new/loading projects // Project state cleanup - used when starting new/loading projects
cleanupProjectState: () => { cleanupProjectState: () => {