feat: added a read-only piano roll automation view
This commit is contained in:
@@ -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(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (1x–8x); updates --region-grid-beat-width CSS variable
|
// Piano roll zoom (1x–8x); 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user