feat: added a read-only piano roll automation view
This commit is contained in:
@@ -26,6 +26,7 @@ vi.mock('../stores/projectStore', () => ({
|
||||
playheadPosition: 4,
|
||||
updateTrack: vi.fn().mockResolvedValue(undefined),
|
||||
refreshProjectState: vi.fn(),
|
||||
bumpAutomationRedrawVersion: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -192,7 +192,8 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
selectedControllerEventIds,
|
||||
playheadPosition,
|
||||
updateTrack,
|
||||
refreshProjectState
|
||||
refreshProjectState,
|
||||
bumpAutomationRedrawVersion
|
||||
} = useProjectStore();
|
||||
|
||||
const [showNotes, setShowNotes] = useState(true);
|
||||
@@ -597,6 +598,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
}
|
||||
|
||||
KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
|
||||
bumpAutomationRedrawVersion();
|
||||
} else {
|
||||
const controllerEvent = row.controllerEvent;
|
||||
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));
|
||||
bumpAutomationRedrawVersion();
|
||||
}
|
||||
|
||||
await updateTrack(parentTrack);
|
||||
@@ -890,6 +893,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
}]);
|
||||
|
||||
KGCore.instance().executeCommand(command);
|
||||
bumpAutomationRedrawVersion();
|
||||
const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend;
|
||||
if (createdPitchBend) {
|
||||
createdPitchBend.select();
|
||||
@@ -910,6 +914,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
}]);
|
||||
|
||||
KGCore.instance().executeCommand(command);
|
||||
bumpAutomationRedrawVersion();
|
||||
const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent;
|
||||
if (createdControllerEvent) {
|
||||
createdControllerEvent.select();
|
||||
@@ -938,6 +943,9 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
.map(row => row.controllerEvent.getId());
|
||||
|
||||
KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds, controllerEventIds));
|
||||
if (pitchBendIds.length > 0 || controllerEventIds.length > 0) {
|
||||
bumpAutomationRedrawVersion();
|
||||
}
|
||||
rangeAnchorEventIdRef.current = null;
|
||||
await updateTrack(parentTrack);
|
||||
refreshProjectState();
|
||||
|
||||
@@ -201,6 +201,34 @@
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -267,7 +295,35 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
@@ -423,6 +479,96 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
@@ -463,4 +609,4 @@
|
||||
color: #e0e0e0;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
normalizeSpectrogramHeightResolution,
|
||||
type SpectrogramHeightResolution,
|
||||
} from '../../util/spectrogramUtil';
|
||||
import type { PianoRollAutomationType } from './pianoRollAutomation';
|
||||
|
||||
interface PianoRollProps {
|
||||
onClose: () => void;
|
||||
@@ -46,7 +47,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}) => {
|
||||
const isSpectrogram = mode === 'spectrogram';
|
||||
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
|
||||
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
||||
@@ -59,6 +60,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
// Piano roll zoom (1x–8x); updates --region-grid-beat-width CSS variable
|
||||
const [pianoRollZoom, setPianoRollZoom] = useState<number>(1);
|
||||
const [automationEnabled, setAutomationEnabled] = useState(false);
|
||||
const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend');
|
||||
|
||||
// Quantization state
|
||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||
@@ -88,6 +91,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
const pianoRollRef = useRef<HTMLDivElement>(null);
|
||||
const pianoRollContentRef = useRef<HTMLDivElement>(null);
|
||||
const pianoRollNoteScrollRef = useRef<HTMLDivElement>(null);
|
||||
const pianoGridRef = useRef<HTMLDivElement>(null);
|
||||
const wasDraggingRef = useRef<boolean>(false);
|
||||
|
||||
@@ -686,9 +690,17 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
setPianoRollZoom(nextZoom);
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (pianoRollContentRef.current) {
|
||||
if (pianoRollNoteScrollRef.current) {
|
||||
// Calculate position of C4
|
||||
// We have 8 octaves (0-7), and C4 is in the middle
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// 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);
|
||||
|
||||
// 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}
|
||||
zoom={pianoRollZoom}
|
||||
onZoomChange={handleZoomChange}
|
||||
showAutomationControls={!isSpectrogram}
|
||||
automationEnabled={automationEnabled}
|
||||
automationType={automationType}
|
||||
onAutomationToggle={handleAutomationToggle}
|
||||
onAutomationTypeChange={handleAutomationTypeChange}
|
||||
/>
|
||||
|
||||
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isSpectrogram} activeRegion={activeRegion} />
|
||||
|
||||
<PianoRollContent
|
||||
contentRef={pianoRollContentRef}
|
||||
noteScrollRef={pianoRollNoteScrollRef}
|
||||
pianoGridRef={pianoGridRef}
|
||||
maxBars={maxBars}
|
||||
timeSignature={timeSignature}
|
||||
@@ -1091,6 +1109,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
spectrogramPower={spectrogramPower}
|
||||
spectrogramHeightResolution={spectrogramHeightResolution}
|
||||
pianoRollZoom={pianoRollZoom}
|
||||
automationEnabled={automationEnabled}
|
||||
automationType={automationType}
|
||||
automationRedrawVersion={automationRedrawVersion}
|
||||
/>
|
||||
|
||||
<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();
|
||||
});
|
||||
});
|
||||
@@ -15,9 +15,12 @@ import type { KeySignature } from '../../core/KGProject';
|
||||
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { velocityToColor } from '../../util/velocityColor';
|
||||
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
||||
import PianoRollAutomationLane from './PianoRollAutomationLane';
|
||||
import type { PianoRollAutomationType } from './pianoRollAutomation';
|
||||
|
||||
interface PianoRollContentProps {
|
||||
contentRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
noteScrollRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
pianoGridRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
maxBars: number;
|
||||
timeSignature: { numerator: number; denominator: number };
|
||||
@@ -38,10 +41,14 @@ interface PianoRollContentProps {
|
||||
spectrogramPower?: number;
|
||||
spectrogramHeightResolution?: SpectrogramHeightResolution;
|
||||
pianoRollZoom?: number;
|
||||
automationEnabled?: boolean;
|
||||
automationType?: PianoRollAutomationType;
|
||||
automationRedrawVersion?: number;
|
||||
}
|
||||
|
||||
const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
contentRef,
|
||||
noteScrollRef,
|
||||
pianoGridRef,
|
||||
maxBars,
|
||||
timeSignature,
|
||||
@@ -62,8 +69,12 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
spectrogramPower = 0.5,
|
||||
spectrogramHeightResolution = 3,
|
||||
pianoRollZoom = 1,
|
||||
automationEnabled = false,
|
||||
automationType = 'pitch-bend',
|
||||
automationRedrawVersion = 0,
|
||||
}) => {
|
||||
const isSpectrogram = mode === 'spectrogram';
|
||||
const showAutomationLane = automationEnabled && !isSpectrogram;
|
||||
const [spectrogramLoading, setSpectrogramLoading] = useState(false);
|
||||
const handleSpectrogramLoadingChange = useCallback((loading: boolean) => {
|
||||
setSpectrogramLoading(loading);
|
||||
@@ -275,37 +286,54 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
<div
|
||||
className="piano-roll-content"
|
||||
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">
|
||||
<PianoKeys activeRegion={activeRegion} />
|
||||
<div className="piano-roll-note-scroll" ref={noteScrollRef}>
|
||||
<div className="piano-roll-body">
|
||||
<PianoKeys activeRegion={activeRegion} />
|
||||
|
||||
<PianoGrid
|
||||
gridRef={pianoGridRef}
|
||||
onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick}
|
||||
onClick={isSpectrogram ? () => {} : handleCombinedClick}
|
||||
onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown}
|
||||
isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current}
|
||||
selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current}
|
||||
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
||||
selectedMode={selectedMode}
|
||||
keySignature={keySignature}
|
||||
chordGuide={chordGuide}
|
||||
audioRegion={audioRegion}
|
||||
trackId={trackId}
|
||||
projectName={projectName}
|
||||
bpm={bpm}
|
||||
spectrogramThresholdDb={spectrogramThresholdDb}
|
||||
spectrogramPower={spectrogramPower}
|
||||
spectrogramHeightResolution={spectrogramHeightResolution}
|
||||
pianoRollZoom={pianoRollZoom}
|
||||
onSpectrogramLoadingChange={handleSpectrogramLoadingChange}
|
||||
>
|
||||
{memoizedNotes}
|
||||
{!isSpectrogram && recordingNoteOverlays}
|
||||
</PianoGrid>
|
||||
<PianoGrid
|
||||
gridRef={pianoGridRef}
|
||||
onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick}
|
||||
onClick={isSpectrogram ? () => {} : handleCombinedClick}
|
||||
onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown}
|
||||
isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current}
|
||||
selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current}
|
||||
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
||||
selectedMode={selectedMode}
|
||||
keySignature={keySignature}
|
||||
chordGuide={chordGuide}
|
||||
audioRegion={audioRegion}
|
||||
trackId={trackId}
|
||||
projectName={projectName}
|
||||
bpm={bpm}
|
||||
spectrogramThresholdDb={spectrogramThresholdDb}
|
||||
spectrogramPower={spectrogramPower}
|
||||
spectrogramHeightResolution={spectrogramHeightResolution}
|
||||
pianoRollZoom={pianoRollZoom}
|
||||
onSpectrogramLoadingChange={handleSpectrogramLoadingChange}
|
||||
>
|
||||
{memoizedNotes}
|
||||
{!isSpectrogram && recordingNoteOverlays}
|
||||
</PianoGrid>
|
||||
</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>
|
||||
{spectrogramLoading && (
|
||||
<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 { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import {
|
||||
PIANO_ROLL_AUTOMATION_OPTIONS,
|
||||
type PianoRollAutomationType,
|
||||
} from './pianoRollAutomation';
|
||||
|
||||
const POWER_OPTIONS = [
|
||||
{ label: 'Linear', value: '1.0' },
|
||||
@@ -31,6 +35,11 @@ interface PianoRollToolbarProps {
|
||||
onPowerChange?: (power: number) => void;
|
||||
zoom: number;
|
||||
onZoomChange: (value: number) => void;
|
||||
showAutomationControls?: boolean;
|
||||
automationEnabled?: boolean;
|
||||
automationType?: PianoRollAutomationType;
|
||||
onAutomationToggle?: () => void;
|
||||
onAutomationTypeChange?: (value: PianoRollAutomationType) => void;
|
||||
}
|
||||
|
||||
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
@@ -53,6 +62,11 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
onPowerChange,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
showAutomationControls = false,
|
||||
automationEnabled = false,
|
||||
automationType = 'pitch-bend',
|
||||
onAutomationToggle,
|
||||
onAutomationTypeChange,
|
||||
}) => {
|
||||
const isSpectrogram = mode === 'spectrogram';
|
||||
const showMidiControls = mode !== 'spectrogram'; // midi-edit and hybrid
|
||||
@@ -90,6 +104,26 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
>
|
||||
<FaPencilAlt />
|
||||
</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
|
||||
options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))}
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user