diff --git a/src/components/ListEventPanel.test.tsx b/src/components/ListEventPanel.test.tsx index 99a7659..e70b074 100644 --- a/src/components/ListEventPanel.test.tsx +++ b/src/components/ListEventPanel.test.tsx @@ -26,6 +26,7 @@ vi.mock('../stores/projectStore', () => ({ playheadPosition: 4, updateTrack: vi.fn().mockResolvedValue(undefined), refreshProjectState: vi.fn(), + bumpAutomationRedrawVersion: vi.fn(), }), })); diff --git a/src/components/ListEventPanel.tsx b/src/components/ListEventPanel.tsx index a96a4be..8a56f01 100644 --- a/src/components/ListEventPanel.tsx +++ b/src/components/ListEventPanel.tsx @@ -192,7 +192,8 @@ const ListEventPanel: React.FC = ({ isVisible }) => { selectedControllerEventIds, playheadPosition, updateTrack, - refreshProjectState + refreshProjectState, + bumpAutomationRedrawVersion } = useProjectStore(); const [showNotes, setShowNotes] = useState(true); @@ -597,6 +598,7 @@ const ListEventPanel: React.FC = ({ 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 = ({ isVisible }) => { } KGCore.instance().executeCommand(new UpdateControllerEventPropertiesCommand(activeMidiRegion.getId(), snapshots, updates)); + bumpAutomationRedrawVersion(); } await updateTrack(parentTrack); @@ -890,6 +893,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { }]); KGCore.instance().executeCommand(command); + bumpAutomationRedrawVersion(); const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend; if (createdPitchBend) { createdPitchBend.select(); @@ -910,6 +914,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { }]); KGCore.instance().executeCommand(command); + bumpAutomationRedrawVersion(); const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent; if (createdControllerEvent) { createdControllerEvent.select(); @@ -938,6 +943,9 @@ const ListEventPanel: React.FC = ({ 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(); diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index 708288f..5445b1e 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -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; -} +} \ No newline at end of file diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index dad61c5..852a5f9 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -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 = ({ }) => { 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 = ({ // Piano roll zoom (1x–8x); updates --region-grid-beat-width CSS variable const [pianoRollZoom, setPianoRollZoom] = useState(1); + const [automationEnabled, setAutomationEnabled] = useState(false); + const [automationType, setAutomationType] = useState('pitch-bend'); // Quantization state const [quantPosition, setQuantPosition] = useState('1/8'); @@ -88,6 +91,7 @@ const PianoRoll: React.FC = ({ const pianoRollRef = useRef(null); const pianoRollContentRef = useRef(null); + const pianoRollNoteScrollRef = useRef(null); const pianoGridRef = useRef(null); const wasDraggingRef = useRef(false); @@ -686,9 +690,17 @@ const PianoRoll: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ onPowerChange={setSpectrogramPower} zoom={pianoRollZoom} onZoomChange={handleZoomChange} + showAutomationControls={!isSpectrogram} + automationEnabled={automationEnabled} + automationType={automationType} + onAutomationToggle={handleAutomationToggle} + onAutomationTypeChange={handleAutomationTypeChange} /> = ({ spectrogramPower={spectrogramPower} spectrogramHeightResolution={spectrogramHeightResolution} pianoRollZoom={pianoRollZoom} + automationEnabled={automationEnabled} + automationType={automationType} + automationRedrawVersion={automationRedrawVersion} />
{ + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.getByText('No CC64 events in this region')).toBeInTheDocument(); + }); +}); diff --git a/src/components/piano-roll/PianoRollAutomationLane.tsx b/src/components/piano-roll/PianoRollAutomationLane.tsx new file mode 100644 index 0000000..44bb398 --- /dev/null +++ b/src/components/piano-roll/PianoRollAutomationLane.tsx @@ -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 = ({ + 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(() => { + 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 ( +
+
+
+
+
{laneLabel}
+ + {points.length > 0 && ( + + )} + {svgPoints.map(point => { + const labelY = Math.max(14, Math.min(LANE_HEIGHT - 6, point.y - 10)); + + return ( + + + + {point.label} + + + ); + })} + + {points.length === 0 && ( +
+ No {laneLabel} events in this region +
+ )} +
+
+ ); +}; + +export default PianoRollAutomationLane; diff --git a/src/components/piano-roll/PianoRollContent.test.tsx b/src/components/piano-roll/PianoRollContent.test.tsx new file mode 100644 index 0000000..b00e6bd --- /dev/null +++ b/src/components/piano-roll/PianoRollContent.test.tsx @@ -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(), + 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: () =>
})); +vi.mock('./PianoKeys', () => ({ default: () =>
})); +vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) =>
{children}
})); +vi.mock('./PianoNote', () => ({ default: () =>
})); +vi.mock('./PianoRollAutomationLane', () => ({ default: () =>
})); + +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( + + ); + + 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( + + ); + + expect(screen.getByTestId('piano-roll-content-split')).toBeInTheDocument(); + expect(screen.getByTestId('automation-lane')).toBeInTheDocument(); + }); + + it('suppresses the automation lane in spectrogram mode', () => { + render( + + ); + + expect(screen.getByTestId('piano-roll-content-single')).toBeInTheDocument(); + expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/piano-roll/PianoRollContent.tsx b/src/components/piano-roll/PianoRollContent.tsx index 63fb060..f03d30c 100644 --- a/src/components/piano-roll/PianoRollContent.tsx +++ b/src/components/piano-roll/PianoRollContent.tsx @@ -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; + noteScrollRef: React.MutableRefObject; pianoGridRef: React.MutableRefObject; 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 = ({ contentRef, + noteScrollRef, pianoGridRef, maxBars, timeSignature, @@ -62,8 +69,12 @@ const PianoRollContent: React.FC = ({ 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 = ({
- +
+ -
- +
+
+ - {} : 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} - + {} : 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} + +
+
+ {showAutomationLane && ( +
+ +
+ )}
{spectrogramLoading && (
diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx new file mode 100644 index 0000000..ac9fba1 --- /dev/null +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -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; + 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 ( + + ); + }, +})); + +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( + + ); + + 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( + + ); + + fireEvent.click(screen.getByRole('button', { name: /Pitch Bend/i })); + + expect(onAutomationTypeChange).toHaveBeenCalledWith('cc-11'); + }); + + it('hides automation controls in spectrogram mode', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: 'Toggle automation lane' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 751fff1..6f13429 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -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 = ({ @@ -53,6 +62,11 @@ const PianoRollToolbar: React.FC = ({ 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 = ({ > + {showAutomationControls && ( +
+ + onAutomationTypeChange?.(value as PianoRollAutomationType)} + label="Automation" + buttonClassName="automation-type-dropdown" + showValueAsLabel={true} + /> +
+ )} ({ label: data.name, value: id }))} value={selectedMode} diff --git a/src/components/piano-roll/pianoRollAutomation.ts b/src/components/piano-roll/pianoRollAutomation.ts new file mode 100644 index 0000000..ae8c7e5 --- /dev/null +++ b/src/components/piano-roll/pianoRollAutomation.ts @@ -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; + } +} diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 82c1f7c..6ecaf80 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -85,6 +85,7 @@ interface ProjectState { activeRegionId: string | null; pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid'; hybridAudioRegionId: string | null; + automationRedrawVersion: number; // ChatBox state showChatBox: boolean; @@ -168,6 +169,7 @@ interface ProjectState { openMidiPianoRoll: (regionId: string) => void; openSpectrogramViewer: (regionId: string) => void; openHybridMode: (midiRegionId: string, audioRegionId: string) => void; + bumpAutomationRedrawVersion: () => void; // Project state cleanup cleanupProjectState: () => void; @@ -374,6 +376,7 @@ export const useProjectStore = create((set, get) => { activeRegionId: null, pianoRollMode: 'midi-edit' as const, hybridAudioRegionId: null, + automationRedrawVersion: 0, // Initial ChatBox state showChatBox: initialChatBoxState, @@ -1250,6 +1253,9 @@ export const useProjectStore = create((set, get) => { openHybridMode: (midiRegionId: string, audioRegionId: string) => { 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 cleanupProjectState: () => {