173 lines
5.3 KiB
TypeScript
173 lines
5.3 KiB
TypeScript
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;
|