feat: implemented track level automation
This commit is contained in:
@@ -16,6 +16,7 @@ import { useRegionOperations } from '../hooks/useRegionOperations';
|
||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||
import { ChangeLoopSettingsCommand } from '../core/commands';
|
||||
import { DeleteTrackAutomationPointsCommand } from '../core/commands';
|
||||
|
||||
interface MainContentProps {
|
||||
onTrackClick?: () => void;
|
||||
@@ -55,6 +56,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
savedProjectName,
|
||||
requestPianoRollScroll,
|
||||
mainContentScrollRequest,
|
||||
activeTrackAutomationTrackId,
|
||||
activeTrackAutomationType,
|
||||
selectedTrackAutomationPointIds,
|
||||
bumpTrackAutomationRedrawVersion,
|
||||
refreshProjectState,
|
||||
} = useProjectStore();
|
||||
|
||||
// State to store regions
|
||||
@@ -82,15 +88,54 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setActiveRegionId
|
||||
});
|
||||
|
||||
const deleteSelectedTrackAutomationPoints = useCallback((): boolean => {
|
||||
if (!activeTrackAutomationTrackId || !activeTrackAutomationType || selectedTrackAutomationPointIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const track = tracks.find(candidate => candidate.getId().toString() === activeTrackAutomationTrackId);
|
||||
if (!track) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
KGCore.instance().executeCommand(new DeleteTrackAutomationPointsCommand(
|
||||
track.getId(),
|
||||
activeTrackAutomationType,
|
||||
selectedTrackAutomationPointIds
|
||||
));
|
||||
bumpTrackAutomationRedrawVersion();
|
||||
updateTrack(track);
|
||||
refreshProjectState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting track automation points:', error);
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
activeTrackAutomationTrackId,
|
||||
activeTrackAutomationType,
|
||||
selectedTrackAutomationPointIds,
|
||||
tracks,
|
||||
updateTrack,
|
||||
bumpTrackAutomationRedrawVersion,
|
||||
refreshProjectState,
|
||||
]);
|
||||
|
||||
// Register the delete function with the global manager
|
||||
useEffect(() => {
|
||||
regionDeleteManager.registerDeleteCallback(deleteSelectedRegions);
|
||||
regionDeleteManager.registerDeleteCallback(() => {
|
||||
if (deleteSelectedTrackAutomationPoints()) {
|
||||
return true;
|
||||
}
|
||||
return deleteSelectedRegions();
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
regionDeleteManager.unregisterDeleteCallback();
|
||||
};
|
||||
}, [deleteSelectedRegions]);
|
||||
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints]);
|
||||
|
||||
// Refs to track pending updates for verification
|
||||
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
||||
@@ -726,7 +771,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const isPianoRollOpen = showPianoRoll;
|
||||
|
||||
if (!isInPianoRoll && !isPianoRollOpen) {
|
||||
const deleted = deleteSelectedRegions();
|
||||
const deleted = deleteSelectedTrackAutomationPoints() || deleteSelectedRegions();
|
||||
if (deleted) {
|
||||
// Prevent default behavior only if regions were actually deleted
|
||||
event.preventDefault();
|
||||
@@ -742,7 +787,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [deleteSelectedRegions, showPianoRoll]); // Dependencies for the effect
|
||||
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints, showPianoRoll]); // Dependencies for the effect
|
||||
|
||||
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
|
||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||
|
||||
@@ -237,4 +237,66 @@
|
||||
background: #d32f2f;
|
||||
/* red */
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-controls button.automation.active {
|
||||
background: #d7eef9;
|
||||
color: #101010;
|
||||
}
|
||||
|
||||
.track-grid.automation-active {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track-automation-lane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.track-automation-lane.pencil-cursor {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.track-automation-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.track-automation-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.track-automation-line {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track-automation-point {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-automation-point.selected {
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.35));
|
||||
}
|
||||
|
||||
.track-automation-value {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
paint-order: stroke;
|
||||
stroke: rgba(0, 0, 0, 0.7);
|
||||
stroke-width: 2px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.track-automation-selection-box {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(135, 206, 250, 0.85);
|
||||
background: rgba(135, 206, 250, 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import {
|
||||
CreateTrackAutomationPointsCommand,
|
||||
UpdateTrackAutomationPointsCommand,
|
||||
} from '../../core/commands';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { PIANO_ROLL_CONSTANTS } from '../../constants';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||
|
||||
interface TrackAutomationLaneProps {
|
||||
track: KGTrack;
|
||||
automationType: TrackAutomationType;
|
||||
maxBars: number;
|
||||
timeSignature: { numerator: number; denominator: number };
|
||||
redrawVersion?: number;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
}
|
||||
|
||||
interface PreviewPoint {
|
||||
absoluteBeat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const LANE_PADDING_Y = 12;
|
||||
const POINT_RADIUS = 5;
|
||||
const SELECTED_POINT_COLOR = '#FFFFFF';
|
||||
const TRACK_AUTOMATION_COLORS: Record<TrackAutomationType, string> = {
|
||||
volume: '#87CEFA',
|
||||
pan: '#90EE90',
|
||||
};
|
||||
|
||||
function formatAutomationValue(automationType: TrackAutomationType, value: number): string {
|
||||
if (automationType === 'volume') {
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(1)}`;
|
||||
}
|
||||
|
||||
const magnitude = Math.round(Math.abs(value) * 100);
|
||||
if (magnitude === 0) {
|
||||
return 'C';
|
||||
}
|
||||
|
||||
return `${value < 0 ? 'L' : 'R'}${magnitude}`;
|
||||
}
|
||||
|
||||
const TrackAutomationLane: React.FC<TrackAutomationLaneProps> = ({
|
||||
track,
|
||||
automationType,
|
||||
maxBars,
|
||||
timeSignature,
|
||||
redrawVersion = 0,
|
||||
}) => {
|
||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||
const isLassoSelectingRef = useRef(false);
|
||||
const lassoShiftKeyRef = useRef(false);
|
||||
const selectionBoxRef = useRef<SelectionBoxState>({ startX: 0, startY: 0, endX: 0, endY: 0 });
|
||||
const [selectionBoxRenderTick, setSelectionBoxRenderTick] = useState(0);
|
||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||
const preventBackgroundClearRef = useRef(false);
|
||||
const [previewPoints, setPreviewPoints] = useState<Record<string, PreviewPoint>>({});
|
||||
const previewPointsRef = useRef<Record<string, PreviewPoint>>({});
|
||||
const dragStateRef = useRef<{
|
||||
originAbsoluteBeat: number;
|
||||
originValue: number;
|
||||
originClientX: number;
|
||||
originClientY: number;
|
||||
selectedPoints: KGTrackAutomationPoint[];
|
||||
minDeltaValue: number;
|
||||
maxDeltaValue: number;
|
||||
hasMoved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
selectedTrackAutomationPointIds,
|
||||
updateTrack,
|
||||
refreshProjectState,
|
||||
bumpTrackAutomationRedrawVersion,
|
||||
} = useProjectStore();
|
||||
|
||||
useEffect(() => {
|
||||
const isTypingTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.contentEditable === 'true' ||
|
||||
target.hasAttribute('data-chatbox-input') ||
|
||||
target.closest('.chatbox-input') !== null
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isTypingTarget(event.target) && isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!isTypingTarget(event.target) && !isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const points = useMemo(() => track.getAutomationPoints(automationType), [track, automationType, redrawVersion]);
|
||||
const selectedPointIdSet = new Set(selectedTrackAutomationPointIds.filter(id => points.some(point => point.getId() === id)));
|
||||
const pointMap = new Map(points.map(point => [point.getId(), point]));
|
||||
const totalBeats = maxBars * timeSignature.numerator;
|
||||
const barWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')) || 40;
|
||||
const beatWidth = barWidth / timeSignature.numerator;
|
||||
const minValue = automationType === 'volume' ? AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB : -1;
|
||||
const maxValue = automationType === 'volume' ? AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB : 1;
|
||||
|
||||
const toY = (value: number): number => {
|
||||
const laneHeight = laneRef.current?.clientHeight ?? 120;
|
||||
const usableHeight = laneHeight - LANE_PADDING_Y * 2;
|
||||
const normalized = (value - minValue) / (maxValue - minValue);
|
||||
return laneHeight - LANE_PADDING_Y - normalized * usableHeight;
|
||||
};
|
||||
|
||||
const toValue = (y: number): number => {
|
||||
const laneHeight = laneRef.current?.clientHeight ?? 120;
|
||||
const usableHeight = laneHeight - LANE_PADDING_Y * 2;
|
||||
const clampedY = Math.min(laneHeight - LANE_PADDING_Y, Math.max(LANE_PADDING_Y, y));
|
||||
const normalized = (laneHeight - LANE_PADDING_Y - clampedY) / usableHeight;
|
||||
const rawValue = minValue + normalized * (maxValue - minValue);
|
||||
return automationType === 'volume'
|
||||
? Math.max(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, rawValue))
|
||||
: Math.max(-1, Math.min(1, rawValue));
|
||||
};
|
||||
|
||||
const renderedPoints = points.map(point => {
|
||||
const preview = previewPoints[point.getId()];
|
||||
const absoluteBeat = preview?.absoluteBeat ?? point.getBeat();
|
||||
const value = preview?.value ?? point.getValue();
|
||||
|
||||
return {
|
||||
id: point.getId(),
|
||||
absoluteBeat,
|
||||
value,
|
||||
x: absoluteBeat * beatWidth,
|
||||
y: toY(value),
|
||||
isSelected: selectedPointIdSet.has(point.getId()),
|
||||
};
|
||||
}).sort((left, right) => left.absoluteBeat - right.absoluteBeat || left.id.localeCompare(right.id));
|
||||
|
||||
const polylinePoints = renderedPoints.length > 0
|
||||
? renderedPoints
|
||||
.concat([{ ...renderedPoints[renderedPoints.length - 1], id: `${renderedPoints[renderedPoints.length - 1].id}-tail`, x: beatWidth * totalBeats }])
|
||||
.map(point => `${point.x},${point.y}`)
|
||||
.join(' ')
|
||||
: '';
|
||||
|
||||
const commitSelection = async (nextSelectedIds: Set<string>) => {
|
||||
points.forEach(point => {
|
||||
if (nextSelectedIds.has(point.getId())) {
|
||||
point.select();
|
||||
} else {
|
||||
point.deselect();
|
||||
}
|
||||
});
|
||||
|
||||
const core = KGCore.instance();
|
||||
core.clearSelectedItems();
|
||||
const selectedPoints = points.filter(point => nextSelectedIds.has(point.getId()));
|
||||
if (selectedPoints.length > 0) {
|
||||
core.addSelectedItems(selectedPoints);
|
||||
}
|
||||
|
||||
await updateTrack(track);
|
||||
};
|
||||
|
||||
const getLaneCoordinates = (clientX: number, clientY: number) => {
|
||||
if (!laneRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = laneRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const buildPreviewFromDrag = (clientX: number, clientY: number): Record<string, PreviewPoint> => {
|
||||
const dragState = dragStateRef.current;
|
||||
const coordinates = getLaneCoordinates(clientX, clientY);
|
||||
if (!dragState || !coordinates) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawAbsoluteBeat = coordinates.x / beatWidth;
|
||||
const beatDelta = rawAbsoluteBeat - dragState.originAbsoluteBeat;
|
||||
const rawDeltaValue = toValue(coordinates.y) - dragState.originValue;
|
||||
const valueDelta = Math.min(dragState.maxDeltaValue, Math.max(dragState.minDeltaValue, rawDeltaValue));
|
||||
const nextPreview: Record<string, PreviewPoint> = {};
|
||||
|
||||
dragState.selectedPoints.forEach(point => {
|
||||
nextPreview[point.getId()] = {
|
||||
absoluteBeat: Math.max(0, point.getBeat() + beatDelta),
|
||||
value: point.getValue() + valueDelta,
|
||||
};
|
||||
});
|
||||
|
||||
return nextPreview;
|
||||
};
|
||||
|
||||
const applyPreviewPoints = (nextPreview: Record<string, PreviewPoint>) => {
|
||||
previewPointsRef.current = nextPreview;
|
||||
setPreviewPoints(nextPreview);
|
||||
};
|
||||
|
||||
const handleDragMove = (event: MouseEvent) => {
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dragState = dragStateRef.current;
|
||||
const movedX = Math.abs(event.clientX - dragState.originClientX);
|
||||
const movedY = Math.abs(event.clientY - dragState.originClientY);
|
||||
if (movedX >= PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD || movedY >= PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD) {
|
||||
dragState.hasMoved = true;
|
||||
}
|
||||
|
||||
applyPreviewPoints(buildPreviewFromDrag(event.clientX, event.clientY));
|
||||
};
|
||||
|
||||
const cleanupDragListeners = () => {
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
const dragState = dragStateRef.current;
|
||||
cleanupDragListeners();
|
||||
|
||||
if (!dragState) {
|
||||
applyPreviewPoints({});
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingPreview = Object.keys(previewPointsRef.current).length > 0
|
||||
? previewPointsRef.current
|
||||
: buildPreviewFromDrag(dragState.originClientX, dragState.originClientY);
|
||||
|
||||
if (dragState.hasMoved && Object.keys(pendingPreview).length > 0) {
|
||||
KGCore.instance().executeCommand(new UpdateTrackAutomationPointsCommand(
|
||||
track.getId(),
|
||||
automationType,
|
||||
dragState.selectedPoints.map(point => ({
|
||||
pointId: point.getId(),
|
||||
beat: point.getBeat(),
|
||||
value: point.getValue(),
|
||||
})),
|
||||
dragState.selectedPoints.map(point => {
|
||||
const preview = pendingPreview[point.getId()];
|
||||
return {
|
||||
pointId: point.getId(),
|
||||
beat: preview.absoluteBeat,
|
||||
value: preview.value,
|
||||
};
|
||||
})
|
||||
));
|
||||
bumpTrackAutomationRedrawVersion();
|
||||
await updateTrack(track);
|
||||
refreshProjectState();
|
||||
preventBackgroundClearRef.current = true;
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
applyPreviewPoints({});
|
||||
};
|
||||
|
||||
const handlePointMouseDown = async (pointId: string, event: React.MouseEvent<SVGCircleElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const point = pointMap.get(pointId);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextSelection = new Set(selectedPointIdSet);
|
||||
if (event.shiftKey) {
|
||||
if (nextSelection.has(pointId)) {
|
||||
nextSelection.delete(pointId);
|
||||
await commitSelection(nextSelection);
|
||||
preventBackgroundClearRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
nextSelection.add(pointId);
|
||||
} else if (!nextSelection.has(pointId)) {
|
||||
nextSelection = new Set([pointId]);
|
||||
}
|
||||
|
||||
await commitSelection(nextSelection);
|
||||
|
||||
const dragSelectedPoints = points.filter(candidate => nextSelection.has(candidate.getId()));
|
||||
const minDeltaValue = dragSelectedPoints.reduce((currentMin, candidate) => (
|
||||
Math.max(currentMin, minValue - candidate.getValue())
|
||||
), Number.NEGATIVE_INFINITY);
|
||||
const maxDeltaValue = dragSelectedPoints.reduce((currentMax, candidate) => (
|
||||
Math.min(currentMax, maxValue - candidate.getValue())
|
||||
), Number.POSITIVE_INFINITY);
|
||||
|
||||
dragStateRef.current = {
|
||||
originAbsoluteBeat: point.getBeat(),
|
||||
originValue: point.getValue(),
|
||||
originClientX: event.clientX,
|
||||
originClientY: event.clientY,
|
||||
selectedPoints: dragSelectedPoints,
|
||||
minDeltaValue,
|
||||
maxDeltaValue,
|
||||
hasMoved: false,
|
||||
};
|
||||
|
||||
applyPreviewPoints({});
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
preventBackgroundClearRef.current = true;
|
||||
};
|
||||
|
||||
const handleLassoMouseMove = (event: MouseEvent) => {
|
||||
if (!isLassoSelectingRef.current || !laneRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = laneRef.current.getBoundingClientRect();
|
||||
selectionBoxRef.current = {
|
||||
...selectionBoxRef.current,
|
||||
endX: event.clientX - rect.left,
|
||||
endY: event.clientY - rect.top,
|
||||
};
|
||||
setSelectionBoxRenderTick(tick => tick + 1);
|
||||
};
|
||||
|
||||
const cleanupLassoListeners = () => {
|
||||
document.removeEventListener('mousemove', handleLassoMouseMove);
|
||||
document.removeEventListener('mouseup', handleLassoMouseUp);
|
||||
};
|
||||
|
||||
const handleLassoMouseUp = async () => {
|
||||
cleanupLassoListeners();
|
||||
if (!isLassoSelectingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { startX, startY, endX, endY } = selectionBoxRef.current;
|
||||
const left = Math.min(startX, endX);
|
||||
const right = Math.max(startX, endX);
|
||||
const top = Math.min(startY, endY);
|
||||
const bottom = Math.max(startY, endY);
|
||||
const isClick = (right - left < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD)
|
||||
&& (bottom - top < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD);
|
||||
|
||||
isLassoSelectingRef.current = false;
|
||||
setSelectionBoxRenderTick(tick => tick + 1);
|
||||
|
||||
if (isClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextSelection = lassoShiftKeyRef.current ? new Set(selectedPointIdSet) : new Set<string>();
|
||||
renderedPoints.forEach(point => {
|
||||
const isIntersecting = (
|
||||
point.x + POINT_RADIUS >= left &&
|
||||
point.x - POINT_RADIUS <= right &&
|
||||
point.y + POINT_RADIUS >= top &&
|
||||
point.y - POINT_RADIUS <= bottom
|
||||
);
|
||||
if (!isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lassoShiftKeyRef.current && nextSelection.has(point.id)) {
|
||||
nextSelection.delete(point.id);
|
||||
} else {
|
||||
nextSelection.add(point.id);
|
||||
}
|
||||
});
|
||||
|
||||
await commitSelection(nextSelection);
|
||||
preventBackgroundClearRef.current = true;
|
||||
};
|
||||
|
||||
const handleBackgroundMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isPointTarget(event.target) || dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (KGMainContentState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(event) || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
selectionBoxRef.current = {
|
||||
startX: event.clientX - rect.left,
|
||||
startY: event.clientY - rect.top,
|
||||
endX: event.clientX - rect.left,
|
||||
endY: event.clientY - rect.top,
|
||||
};
|
||||
lassoShiftKeyRef.current = event.shiftKey;
|
||||
isLassoSelectingRef.current = true;
|
||||
setSelectionBoxRenderTick(tick => tick + 1);
|
||||
document.addEventListener('mousemove', handleLassoMouseMove);
|
||||
document.addEventListener('mouseup', handleLassoMouseUp);
|
||||
};
|
||||
|
||||
const handleCreatePoint = async (clientX: number, clientY: number) => {
|
||||
const coordinates = getLaneCoordinates(clientX, clientY);
|
||||
if (!coordinates) {
|
||||
return;
|
||||
}
|
||||
|
||||
KGCore.instance().executeCommand(new CreateTrackAutomationPointsCommand(track.getId(), automationType, [{
|
||||
beat: Math.max(0, coordinates.x / beatWidth),
|
||||
value: toValue(coordinates.y),
|
||||
}]));
|
||||
bumpTrackAutomationRedrawVersion();
|
||||
await updateTrack(track);
|
||||
refreshProjectState();
|
||||
preventBackgroundClearRef.current = true;
|
||||
};
|
||||
|
||||
const handleBackgroundClick = async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isPointTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preventBackgroundClearRef.current) {
|
||||
preventBackgroundClearRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (KGMainContentState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(event)) {
|
||||
if (event.detail > 1) {
|
||||
return;
|
||||
}
|
||||
await handleCreatePoint(event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLassoSelectingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await commitSelection(new Set());
|
||||
};
|
||||
|
||||
const handleBackgroundDoubleClick = async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isPointTarget(event.target)) {
|
||||
await handleCreatePoint(event.clientX, event.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupDragListeners();
|
||||
cleanupLassoListeners();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectionBoxStyle = {
|
||||
left: `${Math.min(selectionBoxRef.current.startX, selectionBoxRef.current.endX)}px`,
|
||||
top: `${Math.min(selectionBoxRef.current.startY, selectionBoxRef.current.endY)}px`,
|
||||
width: `${Math.abs(selectionBoxRef.current.endX - selectionBoxRef.current.startX)}px`,
|
||||
height: `${Math.abs(selectionBoxRef.current.endY - selectionBoxRef.current.startY)}px`,
|
||||
};
|
||||
|
||||
const isPointTarget = (target: EventTarget | null): boolean => (
|
||||
target instanceof Element && target.closest('.track-automation-point') !== null
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={laneRef}
|
||||
className={`track-automation-lane ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
||||
aria-label={`${automationType} track automation lane`}
|
||||
onMouseDown={handleBackgroundMouseDown}
|
||||
onClick={(event) => { void handleBackgroundClick(event); }}
|
||||
onDoubleClick={(event) => { void handleBackgroundDoubleClick(event); }}
|
||||
>
|
||||
<div className="track-automation-overlay" />
|
||||
<svg
|
||||
className="track-automation-svg"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox={`0 0 ${beatWidth * totalBeats} ${laneRef.current?.clientHeight ?? 120}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{renderedPoints.length > 0 && (
|
||||
<polyline
|
||||
className="track-automation-line"
|
||||
fill="none"
|
||||
stroke={TRACK_AUTOMATION_COLORS[automationType]}
|
||||
strokeWidth="2"
|
||||
points={polylinePoints}
|
||||
/>
|
||||
)}
|
||||
{renderedPoints.map(point => (
|
||||
<g key={point.id}>
|
||||
<circle
|
||||
className={`track-automation-point${point.isSelected ? ' selected' : ''}`}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={POINT_RADIUS}
|
||||
fill={point.isSelected ? SELECTED_POINT_COLOR : TRACK_AUTOMATION_COLORS[automationType]}
|
||||
stroke={point.isSelected ? SELECTED_POINT_COLOR : '#1d2428'}
|
||||
strokeWidth={point.isSelected ? 3 : 2}
|
||||
onMouseDown={(event) => { void handlePointMouseDown(point.id, event); }}
|
||||
/>
|
||||
<text
|
||||
className="track-automation-value"
|
||||
x={point.x + 8}
|
||||
y={Math.max(14, point.y - 8)}
|
||||
fill={point.isSelected ? SELECTED_POINT_COLOR : TRACK_AUTOMATION_COLORS[automationType]}
|
||||
>
|
||||
{formatAutomationValue(automationType, point.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{isLassoSelectingRef.current && (
|
||||
<div
|
||||
key={selectionBoxRenderTick}
|
||||
className="track-automation-selection-box"
|
||||
style={selectionBoxStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackAutomationLane;
|
||||
@@ -4,6 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import RegionItem from './RegionItem';
|
||||
import TrackAutomationLane from './TrackAutomationLane';
|
||||
import type { RegionClickOptions, RegionUI, ResizeAction } from '../interfaces';
|
||||
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||
@@ -62,6 +63,9 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
onKGOneClipDrop,
|
||||
}) => {
|
||||
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
||||
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
||||
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
||||
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
||||
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
|
||||
@@ -538,13 +542,22 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
|
||||
// Filter regions for this track
|
||||
const trackRegions = regions.filter(region => region.trackIndex === index);
|
||||
const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
||||
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''} ${isAutomationActive ? 'automation-active' : ''}`}
|
||||
data-test-id={`track-grid-${track.getId()}`}
|
||||
onDoubleClick={(e) => onDoubleClick(e, index)}
|
||||
onClick={(e) => onClick && onClick(e, index)}
|
||||
onDoubleClick={(e) => {
|
||||
if (!isAutomationActive) {
|
||||
onDoubleClick(e, index);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (!isAutomationActive) {
|
||||
onClick && onClick(e, index);
|
||||
}
|
||||
}}
|
||||
ref={trackElementRef}
|
||||
onDragOver={(e) => {
|
||||
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
||||
@@ -613,6 +626,15 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isAutomationActive && activeTrackAutomationType && (
|
||||
<TrackAutomationLane
|
||||
track={track}
|
||||
automationType={activeTrackAutomationType}
|
||||
maxBars={maxBars}
|
||||
timeSignature={useProjectStore.getState().timeSignature}
|
||||
redrawVersion={trackAutomationRedrawVersion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DEBUG_MODE } from '../../constants/uiConstants';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
|
||||
import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
|
||||
|
||||
const UNITY_POS = 750;
|
||||
const SLIDER_MAX = 1000;
|
||||
@@ -68,6 +69,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
onDragEnd
|
||||
}) => {
|
||||
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore();
|
||||
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
||||
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
||||
const setTrackAutomationView = useProjectStore(state => state.setTrackAutomationView);
|
||||
const isSelected = selectedTrackId === track.getId().toString();
|
||||
// Inline instrument dropdown removed; use InstrumentSelection panel instead
|
||||
|
||||
@@ -82,7 +86,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
|
||||
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||
const [showAutomationDropdown, setShowAutomationDropdown] = useState(false);
|
||||
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const automationDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const suppressDragRef = useRef(false);
|
||||
const [volume, setVolume] = useState(track.getVolume());
|
||||
const [isEditingVolume, setIsEditingVolume] = useState(false);
|
||||
@@ -103,13 +109,20 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
) {
|
||||
setShowSettingsDropdown(false);
|
||||
}
|
||||
if (
|
||||
showAutomationDropdown &&
|
||||
automationDropdownRef.current &&
|
||||
!automationDropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowAutomationDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showSettingsDropdown]);
|
||||
}, [showSettingsDropdown, showAutomationDropdown]);
|
||||
|
||||
// Sync currentInstrument state with actual track instrument value
|
||||
const instrumentFromTrack = track instanceof KGMidiTrack ? track.getInstrument() : 'acoustic_grand_piano';
|
||||
@@ -299,6 +312,25 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
setShowSettingsDropdown(!showSettingsDropdown);
|
||||
};
|
||||
|
||||
const handleAutomationButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTrack(track.getId().toString());
|
||||
if (automationActive) {
|
||||
setTrackAutomationView(track.getId().toString(), null);
|
||||
setShowAutomationDropdown(false);
|
||||
return;
|
||||
}
|
||||
setShowAutomationDropdown(open => !open);
|
||||
};
|
||||
|
||||
const handleAutomationTypeSelect = (value: string) => {
|
||||
setSelectedTrack(track.getId().toString());
|
||||
setTrackAutomationView(track.getId().toString(), value as TrackAutomationType);
|
||||
setShowAutomationDropdown(false);
|
||||
};
|
||||
|
||||
const automationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
|
||||
|
||||
// Handle settings action
|
||||
const handleSettingsAction = async (action: string) => {
|
||||
if (action === 'Delete Track') {
|
||||
@@ -415,6 +447,31 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
<div className="pan-controls">
|
||||
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
|
||||
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
|
||||
<div style={{ position: 'relative' }} ref={automationDropdownRef}>
|
||||
<button
|
||||
className={`automation${automationActive ? ' active' : ''}`}
|
||||
onClick={handleAutomationButtonClick}
|
||||
title="Track automation"
|
||||
aria-label="Track automation"
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<div style={{ position: 'absolute', top: 0, left: 'calc(100% + 6px)', zIndex: 10000 }}>
|
||||
<KGDropdown
|
||||
options={[
|
||||
{ label: 'Volume', value: 'volume' },
|
||||
{ label: 'Pan', value: 'pan' },
|
||||
]}
|
||||
value={activeTrackAutomationType ?? ''}
|
||||
onChange={handleAutomationTypeSelect}
|
||||
label="Automation"
|
||||
hideButton={true}
|
||||
isOpen={showAutomationDropdown}
|
||||
onToggle={setShowAutomationDropdown}
|
||||
className="automation-dropdown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{isAudioTrack ? (
|
||||
<button className="instrument" onClick={handleAudioImportClick} title="Import Audio">
|
||||
@@ -459,4 +516,4 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackInfoItem;
|
||||
export default TrackInfoItem;
|
||||
|
||||
@@ -52,7 +52,7 @@ export class KGProject {
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 9;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 10;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
|
||||
@@ -30,9 +30,13 @@ export class KGAudioBus {
|
||||
private sampler: Tone.Sampler;
|
||||
private audioBuffers: Tone.ToneAudioBuffers;
|
||||
private instrument: InstrumentType;
|
||||
private panner: Tone.Panner;
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private automationVolume: number | null = null;
|
||||
private pan: number;
|
||||
private automationPan: number | null = null;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
private liveMidiPitchBend: number = 0;
|
||||
@@ -51,19 +55,24 @@ export class KGAudioBus {
|
||||
sampler: Tone.Sampler,
|
||||
audioBuffers: Tone.ToneAudioBuffers,
|
||||
instrument: InstrumentType,
|
||||
panner: Tone.Panner,
|
||||
volume: number,
|
||||
pan: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.sampler = sampler;
|
||||
this.audioBuffers = audioBuffers;
|
||||
this.instrument = instrument;
|
||||
this.panner = panner;
|
||||
this.volume = volume;
|
||||
this.pan = pan;
|
||||
this.muted = muted;
|
||||
this.solo = solo;
|
||||
|
||||
// Set initial volume on the sampler
|
||||
this.updateSamplerVolume();
|
||||
this.updatePanValue();
|
||||
|
||||
console.log(`KGAudioBus created for ${instrument} - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||
}
|
||||
@@ -75,6 +84,7 @@ export class KGAudioBus {
|
||||
public static async create(
|
||||
instrument: InstrumentType = 'acoustic_grand_piano',
|
||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||
pan: number = 0,
|
||||
muted: boolean = false,
|
||||
solo: boolean = false
|
||||
): Promise<KGAudioBus> {
|
||||
@@ -90,7 +100,9 @@ export class KGAudioBus {
|
||||
]);
|
||||
|
||||
// Create the audio bus instance
|
||||
const audioBus = new KGAudioBus(sampler, audioBuffers, instrument, volume, muted, solo);
|
||||
const panner = new Tone.Panner(pan);
|
||||
sampler.connect(panner);
|
||||
const audioBus = new KGAudioBus(sampler, audioBuffers, instrument, panner, volume, pan, muted, solo);
|
||||
|
||||
console.log(`KGAudioBus created successfully for ${instrument}`);
|
||||
return audioBus;
|
||||
@@ -315,6 +327,11 @@ export class KGAudioBus {
|
||||
console.log(`Set ${this.instrument} volume to ${volume}`);
|
||||
}
|
||||
|
||||
public setAutomationVolume(volume: number | null): void {
|
||||
this.automationVolume = volume;
|
||||
this.updateSamplerVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current volume
|
||||
*/
|
||||
@@ -322,6 +339,31 @@ export class KGAudioBus {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
public setPan(pan: number): void {
|
||||
this.pan = Math.max(-1, Math.min(1, pan));
|
||||
this.updatePanValue();
|
||||
}
|
||||
|
||||
public setAutomationPan(pan: number | null): void {
|
||||
this.automationPan = pan === null ? null : Math.max(-1, Math.min(1, pan));
|
||||
this.updatePanValue();
|
||||
}
|
||||
|
||||
public scheduleAutomationPan(pan: number, time: number): void {
|
||||
const clampedPan = Math.max(-1, Math.min(1, pan));
|
||||
this.automationPan = clampedPan;
|
||||
if (typeof this.panner.pan.setValueAtTime === 'function') {
|
||||
this.panner.pan.setValueAtTime(clampedPan, time);
|
||||
return;
|
||||
}
|
||||
|
||||
this.panner.pan.value = clampedPan;
|
||||
}
|
||||
|
||||
public getPan(): number {
|
||||
return this.pan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the mute state for this audio bus
|
||||
*/
|
||||
@@ -382,9 +424,11 @@ export class KGAudioBus {
|
||||
this.sampler = sampler;
|
||||
this.audioBuffers = audioBuffers;
|
||||
this.instrument = newInstrument;
|
||||
this.sampler.connect(this.panner);
|
||||
|
||||
// Restore volume settings
|
||||
this.updateSamplerVolume();
|
||||
this.updatePanValue();
|
||||
|
||||
console.log(`Instrument changed successfully to ${newInstrument}`);
|
||||
} catch (error) {
|
||||
@@ -400,7 +444,7 @@ export class KGAudioBus {
|
||||
*/
|
||||
public connect(destination: Tone.InputNode): void {
|
||||
try {
|
||||
this.sampler.connect(destination);
|
||||
this.panner.connect(destination);
|
||||
console.log(`Connected ${this.instrument} to audio destination`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting ${this.instrument} to destination:`, error);
|
||||
@@ -412,7 +456,7 @@ export class KGAudioBus {
|
||||
*/
|
||||
public disconnect(): void {
|
||||
try {
|
||||
this.sampler.disconnect();
|
||||
this.panner.disconnect();
|
||||
console.log(`Disconnected ${this.instrument} from all destinations`);
|
||||
} catch (error) {
|
||||
console.error(`Error disconnecting ${this.instrument}:`, error);
|
||||
@@ -424,7 +468,7 @@ export class KGAudioBus {
|
||||
*/
|
||||
public toDestination(): void {
|
||||
try {
|
||||
this.sampler.toDestination();
|
||||
this.panner.toDestination();
|
||||
console.log(`Connected ${this.instrument} to main output`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting ${this.instrument} to main output:`, error);
|
||||
@@ -440,6 +484,7 @@ export class KGAudioBus {
|
||||
try {
|
||||
this.releaseAll();
|
||||
this.sampler.dispose();
|
||||
this.panner.dispose();
|
||||
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
||||
} catch (error) {
|
||||
console.error(`Error disposing KGAudioBus for ${this.instrument}:`, error);
|
||||
@@ -453,21 +498,36 @@ export class KGAudioBus {
|
||||
*/
|
||||
private updateSamplerVolume(): void {
|
||||
try {
|
||||
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
||||
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||
const isSilent = this.muted || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : effectiveVolume;
|
||||
} catch (error) {
|
||||
console.error(`Error updating volume for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePanValue(): void {
|
||||
try {
|
||||
const effectivePan = this.automationPan ?? this.pan;
|
||||
if (typeof this.panner.pan.setValueAtTime === 'function') {
|
||||
this.panner.pan.setValueAtTime(effectivePan, Tone.now());
|
||||
} else {
|
||||
this.panner.pan.value = effectivePan;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating pan for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply effective volume considering both mute and solo context
|
||||
* When any track is soloed, only soloed tracks should be audible
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
||||
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : effectiveVolume;
|
||||
} catch (error) {
|
||||
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
||||
}
|
||||
@@ -512,12 +572,14 @@ export class KGAudioBus {
|
||||
public getState(): {
|
||||
instrument: InstrumentType;
|
||||
volume: number;
|
||||
pan: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
} {
|
||||
return {
|
||||
instrument: this.instrument,
|
||||
volume: this.volume,
|
||||
pan: this.pan,
|
||||
muted: this.muted,
|
||||
solo: this.solo
|
||||
};
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
resolveMidiAutomationValueAtBeat,
|
||||
resolveSustainExtendedEndBeat,
|
||||
} from '../../util/midiAutomationUtil';
|
||||
import {
|
||||
bakeTrackAutomationPointsInWindow,
|
||||
getTrackAutomationDefaultValue,
|
||||
resolveTrackAutomationValueAtBeat,
|
||||
} from '../../util/trackAutomationUtil';
|
||||
import * as Tone from 'tone';
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||
@@ -230,7 +235,7 @@ export class KGAudioInterface {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
||||
const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
const audioBus = await KGAudioBus.create(instrumentType, initialVolume);
|
||||
const audioBus = await KGAudioBus.create(instrumentType, initialVolume, 0);
|
||||
|
||||
// Connect to master gain if available, otherwise to destination
|
||||
if (this.masterGain) {
|
||||
@@ -283,7 +288,7 @@ export class KGAudioInterface {
|
||||
|
||||
try {
|
||||
console.log(`Creating audio player bus for track ${trackId}`);
|
||||
const playerBus = await KGAudioPlayerBus.create(volume);
|
||||
const playerBus = await KGAudioPlayerBus.create(volume, 0);
|
||||
|
||||
if (this.masterGain) {
|
||||
playerBus.connect(this.masterGain);
|
||||
@@ -413,6 +418,7 @@ export class KGAudioInterface {
|
||||
this.clearScheduledEvents();
|
||||
this.clearDelayedTransportStart();
|
||||
this.trackAudioBuses.forEach(audioBus => audioBus.resetLiveMidiPitchBend());
|
||||
this.clearTrackAutomationOverrides();
|
||||
|
||||
console.log("Preparing playback");
|
||||
|
||||
@@ -485,7 +491,12 @@ export class KGAudioInterface {
|
||||
project.getTracks().forEach(track => {
|
||||
const trackId = track.getId().toString();
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
const interpolationIntervalMs = (configManager.get('audio.midi_automation_interpolation_interval_ms') as number) ?? 10;
|
||||
const automationWindowStartBeat = isLooping ? Math.max(startPosition, scheduleStartBeat) : startPosition;
|
||||
|
||||
this.applyTrackAutomationAtBeat(track, automationWindowStartBeat);
|
||||
this.scheduleTrackAutomation(track, automationWindowStartBeat, scheduleEndBeat, interpolationIntervalMs, project.getBpm());
|
||||
|
||||
console.log(`Track ${trackId} has audio bus: ${audioBus ? 'true' : 'false'}; type: ${track.getType()}`);
|
||||
|
||||
@@ -693,7 +704,6 @@ export class KGAudioInterface {
|
||||
}
|
||||
|
||||
// Schedule audio/wav track events
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (playerBus && track.getType() === 'Wave') {
|
||||
track.getRegions().forEach(region => {
|
||||
if (region.getCurrentType() === 'KGAudioRegion') {
|
||||
@@ -868,6 +878,7 @@ export class KGAudioInterface {
|
||||
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||
playerBus.stopAll();
|
||||
});
|
||||
this.clearTrackAutomationOverrides();
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
@@ -895,6 +906,7 @@ export class KGAudioInterface {
|
||||
const durationInBeats = note.getEndBeat() - note.getStartBeat();
|
||||
const duration = this.beatsToToneTime(durationInBeats);
|
||||
const triggerTime = time ?? Tone.now();
|
||||
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
@@ -922,6 +934,7 @@ export class KGAudioInterface {
|
||||
const noteName = pitchToNoteNameString(pitch);
|
||||
const normalizedVelocity = velocity / 127; // Normalize to 0-1
|
||||
const triggerTime = time ?? Tone.now();
|
||||
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
@@ -948,6 +961,7 @@ export class KGAudioInterface {
|
||||
|
||||
const normalizedVelocity = velocity / 127;
|
||||
const triggerTime = time ?? Tone.now();
|
||||
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
@@ -1166,6 +1180,24 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public setTrackPan(trackId: string, pan: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setPan(pan);
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setPan(pan);
|
||||
}
|
||||
if (!audioBus && !playerBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} pan:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set track mute state
|
||||
*/
|
||||
@@ -1253,6 +1285,12 @@ export class KGAudioInterface {
|
||||
return audioBus?.getVolume() ?? playerBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
}
|
||||
|
||||
public getTrackPan(trackId: string): number {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
return audioBus?.getPan() ?? playerBus?.getPan() ?? 0;
|
||||
}
|
||||
|
||||
public getTrackMuted(trackId: string): boolean {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
@@ -1290,6 +1328,106 @@ export class KGAudioInterface {
|
||||
|
||||
// ===== PRIVATE UTILITY METHODS =====
|
||||
|
||||
private applyTrackAutomationAtBeat(track: { getId(): number; getVolumeAutomation(): Array<{ getBeat(): number; getValue(): number }>; getPanAutomation(): Array<{ getBeat(): number; getValue(): number }> }, beat: number): void {
|
||||
const trackId = track.getId().toString();
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
const volumePoints = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
const panPoints = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
|
||||
const nextVolume = volumePoints.length > 0
|
||||
? resolveTrackAutomationValueAtBeat(volumePoints, 'volume', beat, getTrackAutomationDefaultValue('volume'))
|
||||
: null;
|
||||
const nextPan = panPoints.length > 0
|
||||
? resolveTrackAutomationValueAtBeat(panPoints, 'pan', beat, getTrackAutomationDefaultValue('pan'))
|
||||
: null;
|
||||
|
||||
if (audioBus) {
|
||||
audioBus.setAutomationVolume(nextVolume);
|
||||
audioBus.setAutomationPan(nextPan);
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setAutomationVolume(nextVolume);
|
||||
playerBus.setAutomationPan(nextPan);
|
||||
}
|
||||
this.updateAllEffectiveVolumes();
|
||||
}
|
||||
|
||||
private applyTrackAutomationForCurrentBeat(trackId: string): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const track = project.getTracks().find(candidate => candidate.getId().toString() === trackId);
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyTrackAutomationAtBeat(track, this.getTransportPosition());
|
||||
}
|
||||
|
||||
private scheduleTrackAutomation(
|
||||
track: { getId(): number; getVolumeAutomation(): Array<{ getBeat(): number; getValue(): number }>; getPanAutomation(): Array<{ getBeat(): number; getValue(): number }> },
|
||||
windowStartBeat: number,
|
||||
windowEndBeat: number,
|
||||
interpolationIntervalMs: number,
|
||||
bpm: number
|
||||
): void {
|
||||
const trackId = track.getId().toString();
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (!audioBus && !playerBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const volumePoints = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
const panPoints = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
|
||||
bakeTrackAutomationPointsInWindow(volumePoints, 'volume', windowStartBeat, windowEndBeat, interpolationIntervalMs, bpm)
|
||||
.forEach(({ beat, value }) => {
|
||||
if (beat <= windowStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = Tone.Transport.schedule(() => {
|
||||
if (audioBus) {
|
||||
audioBus.setAutomationVolume(value);
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setAutomationVolume(value);
|
||||
}
|
||||
this.updateAllEffectiveVolumes();
|
||||
}, this.beatsToToneTime(beat));
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
|
||||
bakeTrackAutomationPointsInWindow(panPoints, 'pan', windowStartBeat, windowEndBeat, interpolationIntervalMs, bpm)
|
||||
.forEach(({ beat, value }) => {
|
||||
if (beat <= windowStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
if (audioBus) {
|
||||
audioBus.scheduleAutomationPan(value, time);
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.scheduleAutomationPan(value, time);
|
||||
}
|
||||
}, this.beatsToToneTime(beat));
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
}
|
||||
|
||||
private clearTrackAutomationOverrides(): void {
|
||||
this.trackAudioBuses.forEach(audioBus => {
|
||||
audioBus.setAutomationVolume(null);
|
||||
audioBus.setAutomationPan(null);
|
||||
});
|
||||
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||
playerBus.setAutomationVolume(null);
|
||||
playerBus.setAutomationPan(null);
|
||||
});
|
||||
this.updateAllEffectiveVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup audio capture for screen sharing
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
export class KGAudioPlayerBus {
|
||||
// Gain node for volume/mute routing
|
||||
private gainNode: Tone.Gain;
|
||||
private pannerNode: Tone.Panner;
|
||||
|
||||
// Cached audio buffers keyed by audioFileId
|
||||
private audioBuffers: Map<string, Tone.ToneAudioBuffer> = new Map();
|
||||
@@ -19,6 +20,9 @@ export class KGAudioPlayerBus {
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private automationVolume: number | null = null;
|
||||
private pan: number;
|
||||
private automationPan: number | null = null;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
|
||||
@@ -27,16 +31,21 @@ export class KGAudioPlayerBus {
|
||||
*/
|
||||
private constructor(
|
||||
gainNode: Tone.Gain,
|
||||
pannerNode: Tone.Panner,
|
||||
volume: number,
|
||||
pan: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.gainNode = gainNode;
|
||||
this.pannerNode = pannerNode;
|
||||
this.volume = volume;
|
||||
this.pan = pan;
|
||||
this.muted = muted;
|
||||
this.solo = solo;
|
||||
|
||||
this.updateGainVolume();
|
||||
this.updatePanValue();
|
||||
|
||||
console.log(`KGAudioPlayerBus created - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||
}
|
||||
@@ -46,12 +55,15 @@ export class KGAudioPlayerBus {
|
||||
*/
|
||||
public static async create(
|
||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||
pan: number = 0,
|
||||
muted: boolean = false,
|
||||
solo: boolean = false
|
||||
): Promise<KGAudioPlayerBus> {
|
||||
try {
|
||||
const gainNode = new Tone.Gain(1);
|
||||
const bus = new KGAudioPlayerBus(gainNode, volume, muted, solo);
|
||||
const pannerNode = new Tone.Panner(pan);
|
||||
gainNode.connect(pannerNode);
|
||||
const bus = new KGAudioPlayerBus(gainNode, pannerNode, volume, pan, muted, solo);
|
||||
console.log('KGAudioPlayerBus created successfully');
|
||||
return bus;
|
||||
} catch (error) {
|
||||
@@ -172,10 +184,40 @@ export class KGAudioPlayerBus {
|
||||
console.log(`Set audio player bus volume to ${volume}`);
|
||||
}
|
||||
|
||||
public setAutomationVolume(volume: number | null): void {
|
||||
this.automationVolume = volume;
|
||||
this.updateGainVolume();
|
||||
}
|
||||
|
||||
public getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
public setPan(pan: number): void {
|
||||
this.pan = Math.max(-1, Math.min(1, pan));
|
||||
this.updatePanValue();
|
||||
}
|
||||
|
||||
public setAutomationPan(pan: number | null): void {
|
||||
this.automationPan = pan === null ? null : Math.max(-1, Math.min(1, pan));
|
||||
this.updatePanValue();
|
||||
}
|
||||
|
||||
public scheduleAutomationPan(pan: number, time: number): void {
|
||||
const clampedPan = Math.max(-1, Math.min(1, pan));
|
||||
this.automationPan = clampedPan;
|
||||
if (typeof this.pannerNode.pan.setValueAtTime === 'function') {
|
||||
this.pannerNode.pan.setValueAtTime(clampedPan, time);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pannerNode.pan.value = clampedPan;
|
||||
}
|
||||
|
||||
public getPan(): number {
|
||||
return this.pan;
|
||||
}
|
||||
|
||||
public setMuted(muted: boolean): void {
|
||||
this.muted = muted;
|
||||
this.updateGainVolume();
|
||||
@@ -200,8 +242,9 @@ export class KGAudioPlayerBus {
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
|
||||
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, effectiveVolume / 20);
|
||||
} catch (error) {
|
||||
console.error('Error applying effective volume for audio player bus:', error);
|
||||
}
|
||||
@@ -224,7 +267,7 @@ export class KGAudioPlayerBus {
|
||||
|
||||
public connect(destination: Tone.InputNode): void {
|
||||
try {
|
||||
this.gainNode.connect(destination);
|
||||
this.pannerNode.connect(destination);
|
||||
console.log('Connected audio player bus to destination');
|
||||
} catch (error) {
|
||||
console.error('Error connecting audio player bus:', error);
|
||||
@@ -233,7 +276,7 @@ export class KGAudioPlayerBus {
|
||||
|
||||
public disconnect(): void {
|
||||
try {
|
||||
this.gainNode.disconnect();
|
||||
this.pannerNode.disconnect();
|
||||
console.log('Disconnected audio player bus');
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting audio player bus:', error);
|
||||
@@ -250,6 +293,7 @@ export class KGAudioPlayerBus {
|
||||
}
|
||||
this.audioBuffers.clear();
|
||||
this.gainNode.dispose();
|
||||
this.pannerNode.dispose();
|
||||
console.log('Disposed KGAudioPlayerBus');
|
||||
} catch (error) {
|
||||
console.error('Error disposing KGAudioPlayerBus:', error);
|
||||
@@ -260,17 +304,32 @@ export class KGAudioPlayerBus {
|
||||
|
||||
private updateGainVolume(): void {
|
||||
try {
|
||||
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
|
||||
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||
const isSilent = this.muted || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, effectiveVolume / 20);
|
||||
} catch (error) {
|
||||
console.error('Error updating gain volume:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePanValue(): void {
|
||||
try {
|
||||
const effectivePan = this.automationPan ?? this.pan;
|
||||
if (typeof this.pannerNode.pan.setValueAtTime === 'function') {
|
||||
this.pannerNode.pan.setValueAtTime(effectivePan, Tone.now());
|
||||
} else {
|
||||
this.pannerNode.pan.value = effectivePan;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating audio player pan:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DEBUGGING =====
|
||||
|
||||
public getState(): {
|
||||
volume: number;
|
||||
pan: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
bufferCount: number;
|
||||
@@ -278,6 +337,7 @@ export class KGAudioPlayerBus {
|
||||
} {
|
||||
return {
|
||||
volume: this.volume,
|
||||
pan: this.pan,
|
||||
muted: this.muted,
|
||||
solo: this.solo,
|
||||
bufferCount: this.audioBuffers.size,
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
type BakedMidiAutomationPoint,
|
||||
type MidiAutomationPoint,
|
||||
} from '../../util/midiAutomationUtil';
|
||||
import {
|
||||
bakeTrackAutomationPointsInWindow,
|
||||
getTrackAutomationDefaultValue,
|
||||
resolveTrackAutomationValueAtBeat,
|
||||
} from '../../util/trackAutomationUtil';
|
||||
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||
import { KGAudioInterface } from './KGAudioInterface';
|
||||
@@ -121,6 +126,8 @@ export class KGOfflineRenderer {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
volumeAutomation: MidiAutomationPoint[];
|
||||
panAutomation: MidiAutomationPoint[];
|
||||
regions: Array<{
|
||||
startBeat: number;
|
||||
notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
|
||||
@@ -134,6 +141,8 @@ export class KGOfflineRenderer {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
volumeAutomation: MidiAutomationPoint[];
|
||||
panAutomation: MidiAutomationPoint[];
|
||||
regions: Array<{
|
||||
startBeat: number;
|
||||
lengthBeats: number;
|
||||
@@ -207,7 +216,9 @@ export class KGOfflineRenderer {
|
||||
)
|
||||
));
|
||||
|
||||
midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions, pitchBends, controllerEventsByType });
|
||||
const volumeAutomation = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
const panAutomation = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
midiTrackData.push({ trackId, instrumentName, volume, muted, solo, volumeAutomation, panAutomation, regions, pitchBends, controllerEventsByType });
|
||||
} else if (track.getType() === 'Wave') {
|
||||
const volume = audioInterface.getTrackVolume(trackId);
|
||||
const muted = audioInterface.getTrackMuted(trackId);
|
||||
@@ -233,7 +244,9 @@ export class KGOfflineRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
audioTrackData.push({ trackId, volume, muted, solo, regions });
|
||||
const volumeAutomation = track.getVolumeAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
const panAutomation = track.getPanAutomation().map(point => ({ beat: point.getBeat(), value: point.getValue() }));
|
||||
audioTrackData.push({ trackId, volume, muted, solo, volumeAutomation, panAutomation, regions });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,8 +315,24 @@ export class KGOfflineRenderer {
|
||||
});
|
||||
|
||||
// Track volumes are stored in dB across the app, with 0 meaning unity gain.
|
||||
sampler.volume.value = getOfflineTrackVolumeDb(trackInfo.volume, trackInfo.muted);
|
||||
sampler.connect(masterGain);
|
||||
sampler.volume.value = 0;
|
||||
const trackGain = new Tone.Gain(getOfflineTrackGain(trackInfo.volume, trackInfo.muted));
|
||||
const trackPanner = new Tone.Panner(0);
|
||||
sampler.connect(trackGain);
|
||||
trackGain.connect(trackPanner);
|
||||
trackPanner.connect(masterGain);
|
||||
applyOfflineTrackAutomation(
|
||||
trackGain,
|
||||
trackPanner,
|
||||
trackInfo.volumeAutomation,
|
||||
trackInfo.panAutomation,
|
||||
trackInfo.volume,
|
||||
renderStartBeat,
|
||||
renderEndBeat,
|
||||
secondsPerBeat,
|
||||
interpolationIntervalMs,
|
||||
bpm
|
||||
);
|
||||
const mergedExpressionEvents = normalizeMidiAutomationPoints(
|
||||
[1, 2, 7, 11].flatMap(controller => trackInfo.controllerEventsByType[controller])
|
||||
);
|
||||
@@ -396,7 +425,21 @@ export class KGOfflineRenderer {
|
||||
if (!shouldPlay(trackInfo, hasSoloedTracks)) continue;
|
||||
|
||||
const trackGain = new Tone.Gain(getOfflineTrackGain(trackInfo.volume, trackInfo.muted));
|
||||
trackGain.connect(masterGain);
|
||||
const trackPanner = new Tone.Panner(0);
|
||||
trackGain.connect(trackPanner);
|
||||
trackPanner.connect(masterGain);
|
||||
applyOfflineTrackAutomation(
|
||||
trackGain,
|
||||
trackPanner,
|
||||
trackInfo.volumeAutomation,
|
||||
trackInfo.panAutomation,
|
||||
trackInfo.volume,
|
||||
renderStartBeat,
|
||||
renderEndBeat,
|
||||
secondsPerBeat,
|
||||
interpolationIntervalMs,
|
||||
bpm
|
||||
);
|
||||
|
||||
for (const regionInfo of trackInfo.regions) {
|
||||
const regionStartBeat = regionInfo.startBeat;
|
||||
@@ -552,6 +595,15 @@ function setOfflineGainValue(gainNode: Tone.Gain, value: number, time: number):
|
||||
gainNode.gain.value = value;
|
||||
}
|
||||
|
||||
function setOfflinePanValue(panner: Tone.Panner, value: number, time: number): void {
|
||||
if (typeof panner.pan.setValueAtTime === 'function') {
|
||||
panner.pan.setValueAtTime(value, time);
|
||||
return;
|
||||
}
|
||||
|
||||
panner.pan.value = value;
|
||||
}
|
||||
|
||||
function createOfflinePitchBendAwareSource(
|
||||
sampler: Tone.Sampler,
|
||||
audioBuffers: Tone.ToneAudioBuffers,
|
||||
@@ -619,6 +671,73 @@ export function applyOfflineExpressionAutomation(
|
||||
});
|
||||
}
|
||||
|
||||
function applyOfflineTrackAutomation(
|
||||
gainNode: Tone.Gain,
|
||||
pannerNode: Tone.Panner,
|
||||
volumeAutomation: MidiAutomationPoint[],
|
||||
panAutomation: MidiAutomationPoint[],
|
||||
baseVolume: number,
|
||||
renderStartBeat: number,
|
||||
renderEndBeat: number,
|
||||
secondsPerBeat: number,
|
||||
interpolationIntervalMs: number,
|
||||
bpm: number
|
||||
): void {
|
||||
if (volumeAutomation.length > 0) {
|
||||
const initialVolume = resolveTrackAutomationValueAtBeat(
|
||||
volumeAutomation,
|
||||
'volume',
|
||||
renderStartBeat,
|
||||
getTrackAutomationDefaultValue('volume')
|
||||
);
|
||||
setOfflineGainValue(gainNode, getOfflineTrackGain(initialVolume, false), 0);
|
||||
bakeTrackAutomationPointsInWindow(
|
||||
volumeAutomation,
|
||||
'volume',
|
||||
renderStartBeat,
|
||||
renderEndBeat,
|
||||
interpolationIntervalMs,
|
||||
bpm
|
||||
).forEach(point => {
|
||||
if (point.beat <= renderStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
||||
setOfflineGainValue(gainNode, getOfflineTrackGain(point.value, false), automationTime);
|
||||
});
|
||||
} else {
|
||||
setOfflineGainValue(gainNode, getOfflineTrackGain(baseVolume, false), 0);
|
||||
}
|
||||
|
||||
if (panAutomation.length > 0) {
|
||||
const initialPan = resolveTrackAutomationValueAtBeat(
|
||||
panAutomation,
|
||||
'pan',
|
||||
renderStartBeat,
|
||||
getTrackAutomationDefaultValue('pan')
|
||||
);
|
||||
setOfflinePanValue(pannerNode, initialPan, 0);
|
||||
bakeTrackAutomationPointsInWindow(
|
||||
panAutomation,
|
||||
'pan',
|
||||
renderStartBeat,
|
||||
renderEndBeat,
|
||||
interpolationIntervalMs,
|
||||
bpm
|
||||
).forEach(point => {
|
||||
if (point.beat <= renderStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
||||
setOfflinePanValue(pannerNode, point.value, automationTime);
|
||||
});
|
||||
} else {
|
||||
setOfflinePanValue(pannerNode, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number {
|
||||
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
return isSilent ? -Infinity : volumeDb;
|
||||
|
||||
@@ -12,6 +12,12 @@ export { AddAudioTrackCommand } from './track/AddAudioTrackCommand';
|
||||
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
||||
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
||||
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
|
||||
export {
|
||||
CreateTrackAutomationPointsCommand,
|
||||
type TrackAutomationPointCreationData,
|
||||
} from './track/CreateTrackAutomationPointsCommand';
|
||||
export { DeleteTrackAutomationPointsCommand } from './track/DeleteTrackAutomationPointsCommand';
|
||||
export { UpdateTrackAutomationPointsCommand } from './track/UpdateTrackAutomationPointsCommand';
|
||||
|
||||
// Region commands
|
||||
export { CreateRegionCommand } from './region/CreateRegionCommand';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { instantiateTrackAutomationPoints } from '../../../util/trackAutomationUtil';
|
||||
|
||||
export interface TrackAutomationPointCreationData {
|
||||
beat: number;
|
||||
value: number;
|
||||
pointId?: string;
|
||||
}
|
||||
|
||||
export class CreateTrackAutomationPointsCommand extends KGCommand {
|
||||
private readonly trackId: number;
|
||||
private readonly automationType: TrackAutomationType;
|
||||
private readonly creationData: TrackAutomationPointCreationData[];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
private originalPoints: KGTrackAutomationPoint[] = [];
|
||||
private createdPointIds: string[] = [];
|
||||
|
||||
constructor(trackId: number, automationType: TrackAutomationType, creationData: TrackAutomationPointCreationData[]) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.automationType = automationType;
|
||||
this.creationData = creationData.map(data => ({
|
||||
...data,
|
||||
pointId: data.pointId ?? generateUniqueId('KGTrackAutomationPoint'),
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.targetTrack = this.resolveTrack();
|
||||
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
|
||||
const nextPoints = instantiateTrackAutomationPoints(this.automationType, [
|
||||
...this.originalPoints.map(point => ({
|
||||
id: point.getId(),
|
||||
beat: point.getBeat(),
|
||||
value: point.getValue(),
|
||||
})),
|
||||
...this.creationData.map(data => ({
|
||||
id: data.pointId!,
|
||||
beat: data.beat,
|
||||
value: data.value,
|
||||
})),
|
||||
]);
|
||||
this.createdPointIds = nextPoints
|
||||
.filter(point => this.creationData.some(data => data.pointId === point.getId()))
|
||||
.map(point => point.getId());
|
||||
this.targetTrack.setAutomationPoints(this.automationType, nextPoints);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack) {
|
||||
throw new Error('Cannot undo: command was not executed');
|
||||
}
|
||||
|
||||
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
|
||||
const core = KGCore.instance();
|
||||
core.getSelectedItems()
|
||||
.filter(item => item instanceof KGTrackAutomationPoint && this.createdPointIds.includes(item.getId()))
|
||||
.forEach(item => core.removeSelectedItem(item));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const count = this.creationData.length;
|
||||
return count === 1
|
||||
? `Create ${this.automationType} automation point`
|
||||
: `Create ${count} ${this.automationType} automation points`;
|
||||
}
|
||||
|
||||
public getCreatedPointIds(): string[] {
|
||||
return this.createdPointIds;
|
||||
}
|
||||
|
||||
private resolveTrack(): KGTrack {
|
||||
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
|
||||
|
||||
export class DeleteTrackAutomationPointsCommand extends KGCommand {
|
||||
private readonly trackId: number;
|
||||
private readonly automationType: TrackAutomationType;
|
||||
private readonly pointIds: string[];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
private originalPoints: KGTrackAutomationPoint[] = [];
|
||||
|
||||
constructor(trackId: number, automationType: TrackAutomationType, pointIds: string[]) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.automationType = automationType;
|
||||
this.pointIds = pointIds;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.targetTrack = this.resolveTrack();
|
||||
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
|
||||
const remainingPoints = this.originalPoints.filter(point => !this.pointIds.includes(point.getId()));
|
||||
if (remainingPoints.length === this.originalPoints.length) {
|
||||
throw new Error('No track automation points found to delete');
|
||||
}
|
||||
|
||||
this.targetTrack.setAutomationPoints(this.automationType, remainingPoints);
|
||||
const core = KGCore.instance();
|
||||
core.getSelectedItems()
|
||||
.filter(item => item instanceof KGTrackAutomationPoint && this.pointIds.includes(item.getId()))
|
||||
.forEach(item => core.removeSelectedItem(item));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack) {
|
||||
throw new Error('Cannot undo: command was not executed');
|
||||
}
|
||||
|
||||
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const count = this.pointIds.length;
|
||||
return count === 1
|
||||
? `Delete ${this.automationType} automation point`
|
||||
: `Delete ${count} ${this.automationType} automation points`;
|
||||
}
|
||||
|
||||
private resolveTrack(): KGTrack {
|
||||
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGProject } from '../../KGProject';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { CreateTrackAutomationPointsCommand } from './CreateTrackAutomationPointsCommand';
|
||||
import { DeleteTrackAutomationPointsCommand } from './DeleteTrackAutomationPointsCommand';
|
||||
import { UpdateTrackAutomationPointsCommand } from './UpdateTrackAutomationPointsCommand';
|
||||
import { KGTrackAutomationPoint } from '../../track/KGTrackAutomationPoint';
|
||||
|
||||
vi.mock('../../KGCore', () => ({
|
||||
KGCore: {
|
||||
instance: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('track automation commands', () => {
|
||||
let track: KGTrack;
|
||||
let project: KGProject;
|
||||
const mockCore = {
|
||||
getCurrentProject: vi.fn(),
|
||||
getSelectedItems: vi.fn(() => []),
|
||||
removeSelectedItem: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
track = new KGTrack('Track 1', 1);
|
||||
project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10);
|
||||
mockCore.getCurrentProject.mockReturnValue(project);
|
||||
mockCore.getSelectedItems.mockReturnValue([]);
|
||||
mockCore.removeSelectedItem.mockReset();
|
||||
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
|
||||
});
|
||||
|
||||
it('creates and dedupes same-beat automation points', () => {
|
||||
const command = new CreateTrackAutomationPointsCommand(1, 'volume', [
|
||||
{ beat: 1, value: -6, pointId: 'point-1' },
|
||||
{ beat: 1, value: -3, pointId: 'point-2' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect(track.getVolumeAutomation()).toHaveLength(1);
|
||||
expect(track.getVolumeAutomation()[0].getId()).toBe('point-2');
|
||||
expect(track.getVolumeAutomation()[0].getValue()).toBe(-3);
|
||||
});
|
||||
|
||||
it('restores deleted automation points on undo', () => {
|
||||
track.setPanAutomation([
|
||||
new KGTrackAutomationPoint('point-1', 1, -0.5),
|
||||
new KGTrackAutomationPoint('point-2', 2, 0.5),
|
||||
]);
|
||||
const command = new DeleteTrackAutomationPointsCommand(1, 'pan', ['point-1']);
|
||||
|
||||
command.execute();
|
||||
expect(track.getPanAutomation()).toHaveLength(1);
|
||||
|
||||
command.undo();
|
||||
expect(track.getPanAutomation()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('updates points and removes collisions caused by moves', () => {
|
||||
track.setPanAutomation([
|
||||
new KGTrackAutomationPoint('point-1', 1, -0.5),
|
||||
new KGTrackAutomationPoint('point-2', 2, 0.5),
|
||||
]);
|
||||
const command = new UpdateTrackAutomationPointsCommand(
|
||||
1,
|
||||
'pan',
|
||||
[
|
||||
{ pointId: 'point-1', beat: 1, value: -0.5 },
|
||||
{ pointId: 'point-2', beat: 2, value: 0.5 },
|
||||
],
|
||||
[
|
||||
{ pointId: 'point-1', beat: 2, value: -0.25 },
|
||||
]
|
||||
);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect(track.getPanAutomation()).toHaveLength(1);
|
||||
expect(track.getPanAutomation()[0].getId()).toBe('point-2');
|
||||
|
||||
command.undo();
|
||||
expect(track.getPanAutomation()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
|
||||
import { instantiateTrackAutomationPoints } from '../../../util/trackAutomationUtil';
|
||||
|
||||
interface TrackAutomationPointSnapshot {
|
||||
pointId: string;
|
||||
beat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface TrackAutomationPointUpdate {
|
||||
pointId: string;
|
||||
beat?: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export class UpdateTrackAutomationPointsCommand extends KGCommand {
|
||||
private readonly trackId: number;
|
||||
private readonly automationType: TrackAutomationType;
|
||||
private readonly snapshots: TrackAutomationPointSnapshot[];
|
||||
private readonly updates: TrackAutomationPointUpdate[];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
private originalPoints: KGTrackAutomationPoint[] = [];
|
||||
|
||||
constructor(
|
||||
trackId: number,
|
||||
automationType: TrackAutomationType,
|
||||
snapshots: TrackAutomationPointSnapshot[],
|
||||
updates: TrackAutomationPointUpdate[]
|
||||
) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.automationType = automationType;
|
||||
this.snapshots = [...snapshots];
|
||||
this.updates = [...updates];
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.targetTrack = this.resolveTrack();
|
||||
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
|
||||
const nextPoints = instantiateTrackAutomationPoints(this.automationType, this.originalPoints.map(point => {
|
||||
const update = this.updates.find(candidate => candidate.pointId === point.getId());
|
||||
return {
|
||||
id: point.getId(),
|
||||
beat: update?.beat ?? point.getBeat(),
|
||||
value: update?.value ?? point.getValue(),
|
||||
};
|
||||
}));
|
||||
this.targetTrack.setAutomationPoints(this.automationType, nextPoints);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack) {
|
||||
throw new Error('Cannot undo: command was not executed');
|
||||
}
|
||||
|
||||
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const count = this.snapshots.length;
|
||||
return count === 1
|
||||
? `Update ${this.automationType} automation point`
|
||||
: `Update ${count} ${this.automationType} automation points`;
|
||||
}
|
||||
|
||||
private resolveTrack(): KGTrack {
|
||||
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { upgradeToV6 } from './upgradeToV6';
|
||||
import { upgradeToV7 } from './upgradeToV7';
|
||||
import { upgradeToV8 } from './upgradeToV8';
|
||||
import { upgradeToV9 } from './upgradeToV9';
|
||||
import { upgradeToV10 } from './upgradeToV10';
|
||||
|
||||
/**
|
||||
* Upgrade the given project to the latest structure version, one version at a time.
|
||||
@@ -63,6 +64,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
||||
workingProject = upgradeToV9(workingProject);
|
||||
break;
|
||||
}
|
||||
case 10: {
|
||||
workingProject = upgradeToV10(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { KGTrack } from '../track/KGTrack';
|
||||
import { KGTrackAutomationPoint } from '../track/KGTrackAutomationPoint';
|
||||
import { upgradeProjectToLatest } from './KGProjectUpgrader';
|
||||
import { upgradeToV10 } from './upgradeToV10';
|
||||
|
||||
describe('upgradeToV10', () => {
|
||||
it('initializes missing track automation arrays on legacy tracks', () => {
|
||||
const track = new KGTrack('Legacy Track', 1);
|
||||
delete (track as unknown as { volumeAutomation?: unknown }).volumeAutomation;
|
||||
delete (track as unknown as { panAutomation?: unknown }).panAutomation;
|
||||
const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 9);
|
||||
|
||||
upgradeToV10(project);
|
||||
|
||||
expect(track.getVolumeAutomation()).toEqual([]);
|
||||
expect(track.getPanAutomation()).toEqual([]);
|
||||
expect(project.getProjectStructureVersion()).toBe(10);
|
||||
});
|
||||
|
||||
it('preserves existing track automation points through the main upgrader path', () => {
|
||||
const track = new KGTrack('Legacy Track', 1);
|
||||
track.setVolumeAutomation([new KGTrackAutomationPoint('point-1', 1, -3)]);
|
||||
const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 9);
|
||||
|
||||
const upgraded = upgradeProjectToLatest(project);
|
||||
|
||||
expect(upgraded.getProjectStructureVersion()).toBe(10);
|
||||
expect(upgraded.getTracks()[0].getVolumeAutomation()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { KGProject } from '../KGProject';
|
||||
|
||||
export function upgradeToV10(project: KGProject): KGProject {
|
||||
try {
|
||||
for (const track of project.getTracks()) {
|
||||
const volumeAutomation = (track as unknown as { volumeAutomation?: unknown }).volumeAutomation;
|
||||
if (!Array.isArray(volumeAutomation)) {
|
||||
track.setVolumeAutomation([]);
|
||||
}
|
||||
|
||||
const panAutomation = (track as unknown as { panAutomation?: unknown }).panAutomation;
|
||||
if (!Array.isArray(panAutomation)) {
|
||||
track.setPanAutomation([]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
project.setProjectStructureVersion(10);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
@@ -57,7 +57,7 @@ describe('upgradeToV8', () => {
|
||||
|
||||
const upgraded = upgradeProjectToLatest(project);
|
||||
|
||||
expect(upgraded.getProjectStructureVersion()).toBe(9);
|
||||
expect(upgraded.getProjectStructureVersion()).toBe(10);
|
||||
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getPitchBends()).toEqual([]);
|
||||
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../region/KGAudioRegion';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { WithDefault } from '../../types/projectTypes';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from './KGTrackAutomationPoint';
|
||||
import { clampTrackAutomationValue } from '../../util/trackAutomationUtil';
|
||||
|
||||
// Track type enum
|
||||
export enum TrackType {
|
||||
@@ -49,6 +51,14 @@ export class KGTrack {
|
||||
})
|
||||
protected regions: KGRegion[] = [];
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrackAutomationPoint)
|
||||
protected volumeAutomation: KGTrackAutomationPoint[] = [];
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrackAutomationPoint)
|
||||
protected panAutomation: KGTrackAutomationPoint[] = [];
|
||||
|
||||
constructor(name: string = 'Untitled Track', id: number = 0, type: TrackType = TrackType.MIDI, volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) {
|
||||
this.name = name;
|
||||
this.id = id;
|
||||
@@ -114,6 +124,45 @@ export class KGTrack {
|
||||
this.regions = regions;
|
||||
}
|
||||
|
||||
public getVolumeAutomation(): KGTrackAutomationPoint[] {
|
||||
return this.volumeAutomation;
|
||||
}
|
||||
|
||||
public setVolumeAutomation(points: KGTrackAutomationPoint[]): void {
|
||||
this.volumeAutomation = points
|
||||
.map(point => {
|
||||
point.setValue(clampTrackAutomationValue('volume', point.getValue()));
|
||||
return point;
|
||||
})
|
||||
.sort((left, right) => left.getBeat() - right.getBeat());
|
||||
}
|
||||
|
||||
public getPanAutomation(): KGTrackAutomationPoint[] {
|
||||
return this.panAutomation;
|
||||
}
|
||||
|
||||
public setPanAutomation(points: KGTrackAutomationPoint[]): void {
|
||||
this.panAutomation = points
|
||||
.map(point => {
|
||||
point.setValue(clampTrackAutomationValue('pan', point.getValue()));
|
||||
return point;
|
||||
})
|
||||
.sort((left, right) => left.getBeat() - right.getBeat());
|
||||
}
|
||||
|
||||
public getAutomationPoints(type: TrackAutomationType): KGTrackAutomationPoint[] {
|
||||
return type === 'volume' ? this.volumeAutomation : this.panAutomation;
|
||||
}
|
||||
|
||||
public setAutomationPoints(type: TrackAutomationType, points: KGTrackAutomationPoint[]): void {
|
||||
if (type === 'volume') {
|
||||
this.setVolumeAutomation(points);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPanAutomation(points);
|
||||
}
|
||||
|
||||
// Add a single region
|
||||
public addRegion(region: KGRegion): void {
|
||||
this.regions.push(region);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import type { Selectable } from '../../components/interfaces';
|
||||
|
||||
export type TrackAutomationType = 'volume' | 'pan';
|
||||
|
||||
export class KGTrackAutomationPoint implements Selectable {
|
||||
@Expose()
|
||||
private id: string = '';
|
||||
|
||||
@Expose()
|
||||
private beat: number = 0;
|
||||
|
||||
@Expose()
|
||||
private value: number = 0;
|
||||
|
||||
@Expose()
|
||||
private selected: boolean = false;
|
||||
|
||||
constructor(id: string, beat: number = 0, value: number = 0) {
|
||||
this.id = id;
|
||||
this.beat = beat;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getBeat(): number {
|
||||
return this.beat;
|
||||
}
|
||||
|
||||
public getValue(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setId(id: string): void {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public setBeat(beat: number): void {
|
||||
this.beat = beat;
|
||||
}
|
||||
|
||||
public setValue(value: number): void {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public select(): void {
|
||||
this.selected = true;
|
||||
}
|
||||
|
||||
public deselect(): void {
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
public isSelected(): boolean {
|
||||
return this.selected;
|
||||
}
|
||||
|
||||
public getRootType(): string {
|
||||
return 'KGTrackAutomationPoint';
|
||||
}
|
||||
|
||||
public getCurrentType(): string {
|
||||
return 'KGTrackAutomationPoint';
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand';
|
||||
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
||||
|
||||
/**
|
||||
* Update CSS custom property for time signature numerator
|
||||
@@ -77,6 +78,7 @@ interface ProjectState {
|
||||
selectedNoteIds: string[];
|
||||
selectedPitchBendIds: string[];
|
||||
selectedControllerEventIds: string[];
|
||||
selectedTrackAutomationPointIds: string[];
|
||||
selectedRegionIds: string[];
|
||||
selectedTrackId: string | null;
|
||||
|
||||
@@ -86,6 +88,9 @@ interface ProjectState {
|
||||
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
|
||||
hybridAudioRegionId: string | null;
|
||||
automationRedrawVersion: number;
|
||||
activeTrackAutomationTrackId: string | null;
|
||||
activeTrackAutomationType: TrackAutomationType | null;
|
||||
trackAutomationRedrawVersion: number;
|
||||
|
||||
// ChatBox state
|
||||
showChatBox: boolean;
|
||||
@@ -162,6 +167,7 @@ interface ProjectState {
|
||||
syncSelectionFromCore: () => void;
|
||||
clearAllSelections: () => void;
|
||||
setSelectedTrack: (trackId: string | null) => void;
|
||||
setTrackAutomationView: (trackId: string | null, automationType: TrackAutomationType | null) => void;
|
||||
|
||||
// Piano roll actions
|
||||
setShowPianoRoll: (show: boolean) => void;
|
||||
@@ -170,6 +176,7 @@ interface ProjectState {
|
||||
openSpectrogramViewer: (regionId: string) => void;
|
||||
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
||||
bumpAutomationRedrawVersion: () => void;
|
||||
bumpTrackAutomationRedrawVersion: () => void;
|
||||
|
||||
// Project state cleanup
|
||||
cleanupProjectState: () => void;
|
||||
@@ -291,6 +298,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
const controllerEventIds = selectedItems
|
||||
.filter(item => item instanceof KGMidiControllerEvent)
|
||||
.map(item => item.getId());
|
||||
const trackAutomationPointIds = selectedItems
|
||||
.filter(item => item instanceof KGTrackAutomationPoint)
|
||||
.map(item => item.getId());
|
||||
const regionIds = selectedItems
|
||||
.filter(item => item instanceof KGRegion)
|
||||
.map(item => item.getId());
|
||||
@@ -299,6 +309,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
selectedNoteIds: noteIds,
|
||||
selectedPitchBendIds: pitchBendIds,
|
||||
selectedControllerEventIds: controllerEventIds,
|
||||
selectedTrackAutomationPointIds: trackAutomationPointIds,
|
||||
selectedRegionIds: regionIds
|
||||
});
|
||||
};
|
||||
@@ -368,6 +379,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
selectedNoteIds: [],
|
||||
selectedPitchBendIds: [],
|
||||
selectedControllerEventIds: [],
|
||||
selectedTrackAutomationPointIds: [],
|
||||
selectedRegionIds: [],
|
||||
selectedTrackId: initialSelectedTrackId,
|
||||
|
||||
@@ -377,6 +389,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
pianoRollMode: 'midi-edit' as const,
|
||||
hybridAudioRegionId: null,
|
||||
automationRedrawVersion: 0,
|
||||
activeTrackAutomationTrackId: null,
|
||||
activeTrackAutomationType: null,
|
||||
trackAutomationRedrawVersion: 0,
|
||||
|
||||
// Initial ChatBox state
|
||||
showChatBox: initialChatBoxState,
|
||||
@@ -829,6 +844,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
selectedMode: projectToLoad.getSelectedMode(),
|
||||
isLooping: projectToLoad.getIsLooping(),
|
||||
loopingRange: projectToLoad.getLoopingRange(),
|
||||
activeTrackAutomationTrackId: null,
|
||||
activeTrackAutomationType: null,
|
||||
trackAutomationRedrawVersion: 0,
|
||||
playheadPosition: 0, // Ensure store state is also updated
|
||||
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
|
||||
});
|
||||
@@ -1232,6 +1250,30 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
// Update selected track id
|
||||
set({ selectedTrackId: trackId });
|
||||
},
|
||||
|
||||
setTrackAutomationView: (trackId: string | null, automationType: TrackAutomationType | null) => {
|
||||
const core = KGCore.instance();
|
||||
core.getSelectedItems()
|
||||
.filter(item => item instanceof KGTrackAutomationPoint)
|
||||
.forEach(item => core.removeSelectedItem(item));
|
||||
|
||||
if (trackId && automationType === null) {
|
||||
set({
|
||||
activeTrackAutomationTrackId: null,
|
||||
activeTrackAutomationType: null,
|
||||
selectedTrackAutomationPointIds: [],
|
||||
selectedTrackId: trackId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
activeTrackAutomationTrackId: trackId,
|
||||
activeTrackAutomationType: trackId ? automationType : null,
|
||||
selectedTrackAutomationPointIds: [],
|
||||
selectedTrackId: trackId,
|
||||
});
|
||||
},
|
||||
|
||||
// Piano roll actions
|
||||
setShowPianoRoll: (show: boolean) => {
|
||||
@@ -1256,6 +1298,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
bumpAutomationRedrawVersion: () => {
|
||||
set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 }));
|
||||
},
|
||||
bumpTrackAutomationRedrawVersion: () => {
|
||||
set(state => ({ trackAutomationRedrawVersion: state.trackAutomationRedrawVersion + 1 }));
|
||||
},
|
||||
|
||||
// Project state cleanup - used when starting new/loading projects
|
||||
cleanupProjectState: () => {
|
||||
@@ -1263,7 +1308,14 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
set({ showPianoRoll: false });
|
||||
|
||||
// Clear active region and hybrid state
|
||||
set({ activeRegionId: null, hybridAudioRegionId: null, pianoRollMode: 'midi-edit' });
|
||||
set({
|
||||
activeRegionId: null,
|
||||
hybridAudioRegionId: null,
|
||||
pianoRollMode: 'midi-edit',
|
||||
activeTrackAutomationTrackId: null,
|
||||
activeTrackAutomationType: null,
|
||||
trackAutomationRedrawVersion: 0,
|
||||
});
|
||||
|
||||
// Clear any selected items
|
||||
KGCore.instance().clearSelectedItems();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KGTrackAutomationPoint } from '../core/track/KGTrackAutomationPoint';
|
||||
import {
|
||||
bakeTrackAutomationPointsInWindow,
|
||||
normalizeTrackAutomationPoints,
|
||||
resolveTrackAutomationValueAtBeat,
|
||||
} from './trackAutomationUtil';
|
||||
|
||||
describe('trackAutomationUtil', () => {
|
||||
it('dedupes same-beat points and keeps the latest value', () => {
|
||||
const normalized = normalizeTrackAutomationPoints([
|
||||
{ beat: 1, value: -6 },
|
||||
{ beat: 1, value: -3 },
|
||||
{ beat: 2, value: 1 },
|
||||
], 'volume');
|
||||
|
||||
expect(normalized).toEqual([
|
||||
{ beat: 1, value: -3 },
|
||||
{ beat: 2, value: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('interpolates between points and falls back to the default before the first point', () => {
|
||||
const points = [
|
||||
new KGTrackAutomationPoint('point-1', 1, -6),
|
||||
new KGTrackAutomationPoint('point-2', 3, 6),
|
||||
];
|
||||
|
||||
expect(resolveTrackAutomationValueAtBeat(points, 'volume', 0.5, 0)).toBe(0);
|
||||
expect(resolveTrackAutomationValueAtBeat(points, 'volume', 2, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('bakes intermediate points for changing automation spans', () => {
|
||||
const baked = bakeTrackAutomationPointsInWindow([
|
||||
{ beat: 0, value: 0 },
|
||||
{ beat: 2, value: 1 },
|
||||
], 'pan', 0, 2, 250, 120);
|
||||
|
||||
expect(baked[0]).toEqual({ beat: 0, value: 0 });
|
||||
expect(baked.some(point => point.beat > 0 && point.beat < 2)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../constants/coreConstants';
|
||||
|
||||
export interface TrackAutomationValuePoint {
|
||||
beat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BakedTrackAutomationPoint {
|
||||
beat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const BEAT_EPSILON = 1e-9;
|
||||
const DEFAULT_TRACK_AUTOMATION_VALUES: Record<TrackAutomationType, number> = {
|
||||
volume: 0,
|
||||
pan: 0,
|
||||
};
|
||||
|
||||
export function getTrackAutomationDefaultValue(type: TrackAutomationType): number {
|
||||
return DEFAULT_TRACK_AUTOMATION_VALUES[type];
|
||||
}
|
||||
|
||||
export function clampTrackAutomationValue(type: TrackAutomationType, value: number): number {
|
||||
if (type === 'volume') {
|
||||
return Math.max(
|
||||
AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
|
||||
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, value)
|
||||
);
|
||||
}
|
||||
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function normalizeTrackAutomationPoints(
|
||||
points: Array<TrackAutomationValuePoint | KGTrackAutomationPoint>,
|
||||
type: TrackAutomationType
|
||||
): TrackAutomationValuePoint[] {
|
||||
const normalized = [...points]
|
||||
.map(point => ({
|
||||
beat: point instanceof KGTrackAutomationPoint ? point.getBeat() : point.beat,
|
||||
value: point instanceof KGTrackAutomationPoint ? point.getValue() : point.value,
|
||||
}))
|
||||
.filter(point => Number.isFinite(point.beat) && Number.isFinite(point.value))
|
||||
.map(point => ({
|
||||
beat: point.beat,
|
||||
value: clampTrackAutomationValue(type, point.value),
|
||||
}))
|
||||
.sort((a, b) => a.beat - b.beat);
|
||||
|
||||
const deduped: TrackAutomationValuePoint[] = [];
|
||||
normalized.forEach(point => {
|
||||
const lastPoint = deduped[deduped.length - 1];
|
||||
if (lastPoint && Math.abs(lastPoint.beat - point.beat) <= BEAT_EPSILON) {
|
||||
deduped[deduped.length - 1] = point;
|
||||
return;
|
||||
}
|
||||
|
||||
deduped.push(point);
|
||||
});
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function instantiateTrackAutomationPoints(
|
||||
type: TrackAutomationType,
|
||||
points: Array<{ id: string; beat: number; value: number }>
|
||||
): KGTrackAutomationPoint[] {
|
||||
return normalizeTrackAutomationPoints(points, type).map(point => {
|
||||
const matchingSource = [...points].reverse().find(source => Math.abs(source.beat - point.beat) <= BEAT_EPSILON);
|
||||
return new KGTrackAutomationPoint(matchingSource?.id ?? '', point.beat, point.value);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTrackAutomationValueAtBeat(
|
||||
points: Array<TrackAutomationValuePoint | KGTrackAutomationPoint>,
|
||||
type: TrackAutomationType,
|
||||
beat: number,
|
||||
defaultValue: number,
|
||||
): number {
|
||||
const normalizedPoints = normalizeTrackAutomationPoints(points, type);
|
||||
if (normalizedPoints.length === 0) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
let previousPoint: TrackAutomationValuePoint | null = null;
|
||||
for (const point of normalizedPoints) {
|
||||
if (beat < point.beat) {
|
||||
if (!previousPoint) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const span = point.beat - previousPoint.beat;
|
||||
if (Math.abs(span) <= BEAT_EPSILON) {
|
||||
return point.value;
|
||||
}
|
||||
|
||||
const ratio = (beat - previousPoint.beat) / span;
|
||||
return previousPoint.value + ((point.value - previousPoint.value) * ratio);
|
||||
}
|
||||
|
||||
previousPoint = point;
|
||||
}
|
||||
|
||||
return previousPoint?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
export function bakeTrackAutomationPointsInWindow(
|
||||
points: Array<TrackAutomationValuePoint | KGTrackAutomationPoint>,
|
||||
type: TrackAutomationType,
|
||||
windowStartBeat: number,
|
||||
windowEndBeat: number,
|
||||
maxIntervalMs: number,
|
||||
bpm: number,
|
||||
defaultValue: number = getTrackAutomationDefaultValue(type),
|
||||
): BakedTrackAutomationPoint[] {
|
||||
const normalizedPoints = normalizeTrackAutomationPoints(points, type);
|
||||
const anchorPoint = {
|
||||
beat: windowStartBeat,
|
||||
value: resolveTrackAutomationValueAtBeat(normalizedPoints, type, windowStartBeat, defaultValue),
|
||||
};
|
||||
|
||||
if (windowEndBeat <= windowStartBeat) {
|
||||
return [anchorPoint];
|
||||
}
|
||||
|
||||
const baked: BakedTrackAutomationPoint[] = [anchorPoint];
|
||||
if (normalizedPoints.length === 0) {
|
||||
return baked;
|
||||
}
|
||||
|
||||
const maxIntervalBeats = !Number.isFinite(maxIntervalMs) || maxIntervalMs <= 0
|
||||
? Number.POSITIVE_INFINITY
|
||||
: (maxIntervalMs / 1000) * (bpm / 60);
|
||||
|
||||
const appendPoint = (point: BakedTrackAutomationPoint) => {
|
||||
const lastPoint = baked[baked.length - 1];
|
||||
if (!lastPoint) {
|
||||
baked.push(point);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(lastPoint.beat - point.beat) <= BEAT_EPSILON) {
|
||||
baked[baked.length - 1] = point;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(lastPoint.value - point.value) <= BEAT_EPSILON) {
|
||||
return;
|
||||
}
|
||||
|
||||
baked.push(point);
|
||||
};
|
||||
|
||||
normalizedPoints.forEach(point => {
|
||||
if (point.beat > windowStartBeat && point.beat < windowEndBeat) {
|
||||
appendPoint(point);
|
||||
}
|
||||
});
|
||||
|
||||
for (let index = 0; index < normalizedPoints.length - 1; index += 1) {
|
||||
const startPoint = normalizedPoints[index];
|
||||
const endPoint = normalizedPoints[index + 1];
|
||||
const overlapStartBeat = Math.max(windowStartBeat, startPoint.beat);
|
||||
const overlapEndBeat = Math.min(windowEndBeat, endPoint.beat);
|
||||
|
||||
if (overlapEndBeat - overlapStartBeat <= BEAT_EPSILON) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(startPoint.value - endPoint.value) <= BEAT_EPSILON) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const segmentLengthBeats = overlapEndBeat - overlapStartBeat;
|
||||
const segmentCount = Number.isFinite(maxIntervalBeats)
|
||||
? Math.max(1, Math.ceil(segmentLengthBeats / maxIntervalBeats))
|
||||
: 1;
|
||||
|
||||
for (let segmentIndex = 1; segmentIndex <= segmentCount; segmentIndex += 1) {
|
||||
const beat = overlapStartBeat + ((segmentLengthBeats * segmentIndex) / segmentCount);
|
||||
if (beat >= windowEndBeat - BEAT_EPSILON) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ratio = (beat - startPoint.beat) / (endPoint.beat - startPoint.beat);
|
||||
appendPoint({
|
||||
beat,
|
||||
value: clampTrackAutomationValue(type, startPoint.value + ((endPoint.value - startPoint.value) * ratio)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return baked;
|
||||
}
|
||||
Reference in New Issue
Block a user