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 { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { ChangeLoopSettingsCommand } from '../core/commands';
|
import { ChangeLoopSettingsCommand } from '../core/commands';
|
||||||
|
import { DeleteTrackAutomationPointsCommand } from '../core/commands';
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
onTrackClick?: () => void;
|
onTrackClick?: () => void;
|
||||||
@@ -55,6 +56,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
savedProjectName,
|
savedProjectName,
|
||||||
requestPianoRollScroll,
|
requestPianoRollScroll,
|
||||||
mainContentScrollRequest,
|
mainContentScrollRequest,
|
||||||
|
activeTrackAutomationTrackId,
|
||||||
|
activeTrackAutomationType,
|
||||||
|
selectedTrackAutomationPointIds,
|
||||||
|
bumpTrackAutomationRedrawVersion,
|
||||||
|
refreshProjectState,
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
// State to store regions
|
// State to store regions
|
||||||
@@ -82,15 +88,54 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setActiveRegionId
|
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
|
// Register the delete function with the global manager
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
regionDeleteManager.registerDeleteCallback(deleteSelectedRegions);
|
regionDeleteManager.registerDeleteCallback(() => {
|
||||||
|
if (deleteSelectedTrackAutomationPoints()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return deleteSelectedRegions();
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
regionDeleteManager.unregisterDeleteCallback();
|
regionDeleteManager.unregisterDeleteCallback();
|
||||||
};
|
};
|
||||||
}, [deleteSelectedRegions]);
|
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints]);
|
||||||
|
|
||||||
// Refs to track pending updates for verification
|
// Refs to track pending updates for verification
|
||||||
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
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;
|
const isPianoRollOpen = showPianoRoll;
|
||||||
|
|
||||||
if (!isInPianoRoll && !isPianoRollOpen) {
|
if (!isInPianoRoll && !isPianoRollOpen) {
|
||||||
const deleted = deleteSelectedRegions();
|
const deleted = deleteSelectedTrackAutomationPoints() || deleteSelectedRegions();
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
// Prevent default behavior only if regions were actually deleted
|
// Prevent default behavior only if regions were actually deleted
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -742,7 +787,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
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)
|
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
|
||||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||||
|
|||||||
@@ -237,4 +237,66 @@
|
|||||||
background: #d32f2f;
|
background: #d32f2f;
|
||||||
/* red */
|
/* red */
|
||||||
color: #ffffff;
|
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 { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
import RegionItem from './RegionItem';
|
import RegionItem from './RegionItem';
|
||||||
|
import TrackAutomationLane from './TrackAutomationLane';
|
||||||
import type { RegionClickOptions, RegionUI, ResizeAction } from '../interfaces';
|
import type { RegionClickOptions, RegionUI, ResizeAction } from '../interfaces';
|
||||||
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
@@ -62,6 +63,9 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
onKGOneClipDrop,
|
onKGOneClipDrop,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
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 [containerWidth, setContainerWidth] = useState(0);
|
||||||
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
||||||
const [draggingRegion, setDraggingRegion] = 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
|
// Filter regions for this track
|
||||||
const trackRegions = regions.filter(region => region.trackIndex === index);
|
const trackRegions = regions.filter(region => region.trackIndex === index);
|
||||||
|
const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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()}`}
|
data-test-id={`track-grid-${track.getId()}`}
|
||||||
onDoubleClick={(e) => onDoubleClick(e, index)}
|
onDoubleClick={(e) => {
|
||||||
onClick={(e) => onClick && onClick(e, index)}
|
if (!isAutomationActive) {
|
||||||
|
onDoubleClick(e, index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!isAutomationActive) {
|
||||||
|
onClick && onClick(e, index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
ref={trackElementRef}
|
ref={trackElementRef}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { DEBUG_MODE } from '../../constants/uiConstants';
|
|||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
|
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
|
||||||
|
import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
|
||||||
|
|
||||||
const UNITY_POS = 750;
|
const UNITY_POS = 750;
|
||||||
const SLIDER_MAX = 1000;
|
const SLIDER_MAX = 1000;
|
||||||
@@ -68,6 +69,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
onDragEnd
|
onDragEnd
|
||||||
}) => {
|
}) => {
|
||||||
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore();
|
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();
|
const isSelected = selectedTrackId === track.getId().toString();
|
||||||
// Inline instrument dropdown removed; use InstrumentSelection panel instead
|
// Inline instrument dropdown removed; use InstrumentSelection panel instead
|
||||||
|
|
||||||
@@ -82,7 +86,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
|
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
|
||||||
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
||||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||||
|
const [showAutomationDropdown, setShowAutomationDropdown] = useState(false);
|
||||||
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const automationDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const suppressDragRef = useRef(false);
|
const suppressDragRef = useRef(false);
|
||||||
const [volume, setVolume] = useState(track.getVolume());
|
const [volume, setVolume] = useState(track.getVolume());
|
||||||
const [isEditingVolume, setIsEditingVolume] = useState(false);
|
const [isEditingVolume, setIsEditingVolume] = useState(false);
|
||||||
@@ -103,13 +109,20 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
) {
|
) {
|
||||||
setShowSettingsDropdown(false);
|
setShowSettingsDropdown(false);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
showAutomationDropdown &&
|
||||||
|
automationDropdownRef.current &&
|
||||||
|
!automationDropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowAutomationDropdown(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [showSettingsDropdown]);
|
}, [showSettingsDropdown, showAutomationDropdown]);
|
||||||
|
|
||||||
// Sync currentInstrument state with actual track instrument value
|
// Sync currentInstrument state with actual track instrument value
|
||||||
const instrumentFromTrack = track instanceof KGMidiTrack ? track.getInstrument() : 'acoustic_grand_piano';
|
const instrumentFromTrack = track instanceof KGMidiTrack ? track.getInstrument() : 'acoustic_grand_piano';
|
||||||
@@ -299,6 +312,25 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
setShowSettingsDropdown(!showSettingsDropdown);
|
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
|
// Handle settings action
|
||||||
const handleSettingsAction = async (action: string) => {
|
const handleSettingsAction = async (action: string) => {
|
||||||
if (action === 'Delete Track') {
|
if (action === 'Delete Track') {
|
||||||
@@ -415,6 +447,31 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
<div className="pan-controls">
|
<div className="pan-controls">
|
||||||
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
|
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
|
||||||
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</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>
|
<div>
|
||||||
{isAudioTrack ? (
|
{isAudioTrack ? (
|
||||||
<button className="instrument" onClick={handleAudioImportClick} title="Import Audio">
|
<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)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 9;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 10;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
|
|||||||
@@ -30,9 +30,13 @@ export class KGAudioBus {
|
|||||||
private sampler: Tone.Sampler;
|
private sampler: Tone.Sampler;
|
||||||
private audioBuffers: Tone.ToneAudioBuffers;
|
private audioBuffers: Tone.ToneAudioBuffers;
|
||||||
private instrument: InstrumentType;
|
private instrument: InstrumentType;
|
||||||
|
private panner: Tone.Panner;
|
||||||
|
|
||||||
// Audio properties
|
// Audio properties
|
||||||
private volume: number;
|
private volume: number;
|
||||||
|
private automationVolume: number | null = null;
|
||||||
|
private pan: number;
|
||||||
|
private automationPan: number | null = null;
|
||||||
private muted: boolean;
|
private muted: boolean;
|
||||||
private solo: boolean;
|
private solo: boolean;
|
||||||
private liveMidiPitchBend: number = 0;
|
private liveMidiPitchBend: number = 0;
|
||||||
@@ -51,19 +55,24 @@ export class KGAudioBus {
|
|||||||
sampler: Tone.Sampler,
|
sampler: Tone.Sampler,
|
||||||
audioBuffers: Tone.ToneAudioBuffers,
|
audioBuffers: Tone.ToneAudioBuffers,
|
||||||
instrument: InstrumentType,
|
instrument: InstrumentType,
|
||||||
|
panner: Tone.Panner,
|
||||||
volume: number,
|
volume: number,
|
||||||
|
pan: number,
|
||||||
muted: boolean,
|
muted: boolean,
|
||||||
solo: boolean
|
solo: boolean
|
||||||
) {
|
) {
|
||||||
this.sampler = sampler;
|
this.sampler = sampler;
|
||||||
this.audioBuffers = audioBuffers;
|
this.audioBuffers = audioBuffers;
|
||||||
this.instrument = instrument;
|
this.instrument = instrument;
|
||||||
|
this.panner = panner;
|
||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
|
this.pan = pan;
|
||||||
this.muted = muted;
|
this.muted = muted;
|
||||||
this.solo = solo;
|
this.solo = solo;
|
||||||
|
|
||||||
// Set initial volume on the sampler
|
// Set initial volume on the sampler
|
||||||
this.updateSamplerVolume();
|
this.updateSamplerVolume();
|
||||||
|
this.updatePanValue();
|
||||||
|
|
||||||
console.log(`KGAudioBus created for ${instrument} - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
console.log(`KGAudioBus created for ${instrument} - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||||
}
|
}
|
||||||
@@ -75,6 +84,7 @@ export class KGAudioBus {
|
|||||||
public static async create(
|
public static async create(
|
||||||
instrument: InstrumentType = 'acoustic_grand_piano',
|
instrument: InstrumentType = 'acoustic_grand_piano',
|
||||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||||
|
pan: number = 0,
|
||||||
muted: boolean = false,
|
muted: boolean = false,
|
||||||
solo: boolean = false
|
solo: boolean = false
|
||||||
): Promise<KGAudioBus> {
|
): Promise<KGAudioBus> {
|
||||||
@@ -90,7 +100,9 @@ export class KGAudioBus {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Create the audio bus instance
|
// 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}`);
|
console.log(`KGAudioBus created successfully for ${instrument}`);
|
||||||
return audioBus;
|
return audioBus;
|
||||||
@@ -315,6 +327,11 @@ export class KGAudioBus {
|
|||||||
console.log(`Set ${this.instrument} volume to ${volume}`);
|
console.log(`Set ${this.instrument} volume to ${volume}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setAutomationVolume(volume: number | null): void {
|
||||||
|
this.automationVolume = volume;
|
||||||
|
this.updateSamplerVolume();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current volume
|
* Get the current volume
|
||||||
*/
|
*/
|
||||||
@@ -322,6 +339,31 @@ export class KGAudioBus {
|
|||||||
return this.volume;
|
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
|
* Set the mute state for this audio bus
|
||||||
*/
|
*/
|
||||||
@@ -382,9 +424,11 @@ export class KGAudioBus {
|
|||||||
this.sampler = sampler;
|
this.sampler = sampler;
|
||||||
this.audioBuffers = audioBuffers;
|
this.audioBuffers = audioBuffers;
|
||||||
this.instrument = newInstrument;
|
this.instrument = newInstrument;
|
||||||
|
this.sampler.connect(this.panner);
|
||||||
|
|
||||||
// Restore volume settings
|
// Restore volume settings
|
||||||
this.updateSamplerVolume();
|
this.updateSamplerVolume();
|
||||||
|
this.updatePanValue();
|
||||||
|
|
||||||
console.log(`Instrument changed successfully to ${newInstrument}`);
|
console.log(`Instrument changed successfully to ${newInstrument}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -400,7 +444,7 @@ export class KGAudioBus {
|
|||||||
*/
|
*/
|
||||||
public connect(destination: Tone.InputNode): void {
|
public connect(destination: Tone.InputNode): void {
|
||||||
try {
|
try {
|
||||||
this.sampler.connect(destination);
|
this.panner.connect(destination);
|
||||||
console.log(`Connected ${this.instrument} to audio destination`);
|
console.log(`Connected ${this.instrument} to audio destination`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error connecting ${this.instrument} to destination:`, error);
|
console.error(`Error connecting ${this.instrument} to destination:`, error);
|
||||||
@@ -412,7 +456,7 @@ export class KGAudioBus {
|
|||||||
*/
|
*/
|
||||||
public disconnect(): void {
|
public disconnect(): void {
|
||||||
try {
|
try {
|
||||||
this.sampler.disconnect();
|
this.panner.disconnect();
|
||||||
console.log(`Disconnected ${this.instrument} from all destinations`);
|
console.log(`Disconnected ${this.instrument} from all destinations`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error disconnecting ${this.instrument}:`, error);
|
console.error(`Error disconnecting ${this.instrument}:`, error);
|
||||||
@@ -424,7 +468,7 @@ export class KGAudioBus {
|
|||||||
*/
|
*/
|
||||||
public toDestination(): void {
|
public toDestination(): void {
|
||||||
try {
|
try {
|
||||||
this.sampler.toDestination();
|
this.panner.toDestination();
|
||||||
console.log(`Connected ${this.instrument} to main output`);
|
console.log(`Connected ${this.instrument} to main output`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error connecting ${this.instrument} to main output:`, error);
|
console.error(`Error connecting ${this.instrument} to main output:`, error);
|
||||||
@@ -440,6 +484,7 @@ export class KGAudioBus {
|
|||||||
try {
|
try {
|
||||||
this.releaseAll();
|
this.releaseAll();
|
||||||
this.sampler.dispose();
|
this.sampler.dispose();
|
||||||
|
this.panner.dispose();
|
||||||
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error disposing KGAudioBus for ${this.instrument}:`, error);
|
console.error(`Error disposing KGAudioBus for ${this.instrument}:`, error);
|
||||||
@@ -453,21 +498,36 @@ export class KGAudioBus {
|
|||||||
*/
|
*/
|
||||||
private updateSamplerVolume(): void {
|
private updateSamplerVolume(): void {
|
||||||
try {
|
try {
|
||||||
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||||
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
const isSilent = this.muted || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
|
this.sampler.volume.value = isSilent ? -Infinity : effectiveVolume;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error updating volume for ${this.instrument}:`, 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
|
* Apply effective volume considering both mute and solo context
|
||||||
* When any track is soloed, only soloed tracks should be audible
|
* When any track is soloed, only soloed tracks should be audible
|
||||||
*/
|
*/
|
||||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||||
try {
|
try {
|
||||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||||
this.sampler.volume.value = isSilent ? -Infinity : 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) {
|
} catch (error) {
|
||||||
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
||||||
}
|
}
|
||||||
@@ -512,12 +572,14 @@ export class KGAudioBus {
|
|||||||
public getState(): {
|
public getState(): {
|
||||||
instrument: InstrumentType;
|
instrument: InstrumentType;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
pan: number;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
solo: boolean;
|
solo: boolean;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
instrument: this.instrument,
|
instrument: this.instrument,
|
||||||
volume: this.volume,
|
volume: this.volume,
|
||||||
|
pan: this.pan,
|
||||||
muted: this.muted,
|
muted: this.muted,
|
||||||
solo: this.solo
|
solo: this.solo
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
resolveMidiAutomationValueAtBeat,
|
resolveMidiAutomationValueAtBeat,
|
||||||
resolveSustainExtendedEndBeat,
|
resolveSustainExtendedEndBeat,
|
||||||
} from '../../util/midiAutomationUtil';
|
} from '../../util/midiAutomationUtil';
|
||||||
|
import {
|
||||||
|
bakeTrackAutomationPointsInWindow,
|
||||||
|
getTrackAutomationDefaultValue,
|
||||||
|
resolveTrackAutomationValueAtBeat,
|
||||||
|
} from '../../util/trackAutomationUtil';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { KGAudioBus } from './KGAudioBus';
|
import { KGAudioBus } from './KGAudioBus';
|
||||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||||
@@ -230,7 +235,7 @@ export class KGAudioInterface {
|
|||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
||||||
const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
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
|
// Connect to master gain if available, otherwise to destination
|
||||||
if (this.masterGain) {
|
if (this.masterGain) {
|
||||||
@@ -283,7 +288,7 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Creating audio player bus for track ${trackId}`);
|
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) {
|
if (this.masterGain) {
|
||||||
playerBus.connect(this.masterGain);
|
playerBus.connect(this.masterGain);
|
||||||
@@ -413,6 +418,7 @@ export class KGAudioInterface {
|
|||||||
this.clearScheduledEvents();
|
this.clearScheduledEvents();
|
||||||
this.clearDelayedTransportStart();
|
this.clearDelayedTransportStart();
|
||||||
this.trackAudioBuses.forEach(audioBus => audioBus.resetLiveMidiPitchBend());
|
this.trackAudioBuses.forEach(audioBus => audioBus.resetLiveMidiPitchBend());
|
||||||
|
this.clearTrackAutomationOverrides();
|
||||||
|
|
||||||
console.log("Preparing playback");
|
console.log("Preparing playback");
|
||||||
|
|
||||||
@@ -485,7 +491,12 @@ export class KGAudioInterface {
|
|||||||
project.getTracks().forEach(track => {
|
project.getTracks().forEach(track => {
|
||||||
const trackId = track.getId().toString();
|
const trackId = track.getId().toString();
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
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 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()}`);
|
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
|
// Schedule audio/wav track events
|
||||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
|
||||||
if (playerBus && track.getType() === 'Wave') {
|
if (playerBus && track.getType() === 'Wave') {
|
||||||
track.getRegions().forEach(region => {
|
track.getRegions().forEach(region => {
|
||||||
if (region.getCurrentType() === 'KGAudioRegion') {
|
if (region.getCurrentType() === 'KGAudioRegion') {
|
||||||
@@ -868,6 +878,7 @@ export class KGAudioInterface {
|
|||||||
this.trackAudioPlayerBuses.forEach(playerBus => {
|
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||||
playerBus.stopAll();
|
playerBus.stopAll();
|
||||||
});
|
});
|
||||||
|
this.clearTrackAutomationOverrides();
|
||||||
|
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
|
|
||||||
@@ -895,6 +906,7 @@ export class KGAudioInterface {
|
|||||||
const durationInBeats = note.getEndBeat() - note.getStartBeat();
|
const durationInBeats = note.getEndBeat() - note.getStartBeat();
|
||||||
const duration = this.beatsToToneTime(durationInBeats);
|
const duration = this.beatsToToneTime(durationInBeats);
|
||||||
const triggerTime = time ?? Tone.now();
|
const triggerTime = time ?? Tone.now();
|
||||||
|
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||||
|
|
||||||
// Check if track should play considering solo logic
|
// Check if track should play considering solo logic
|
||||||
const hasSoloedTracks = this.hasSoloedTracks();
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
@@ -922,6 +934,7 @@ export class KGAudioInterface {
|
|||||||
const noteName = pitchToNoteNameString(pitch);
|
const noteName = pitchToNoteNameString(pitch);
|
||||||
const normalizedVelocity = velocity / 127; // Normalize to 0-1
|
const normalizedVelocity = velocity / 127; // Normalize to 0-1
|
||||||
const triggerTime = time ?? Tone.now();
|
const triggerTime = time ?? Tone.now();
|
||||||
|
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||||
|
|
||||||
// Check if track should play considering solo logic
|
// Check if track should play considering solo logic
|
||||||
const hasSoloedTracks = this.hasSoloedTracks();
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
@@ -948,6 +961,7 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
const normalizedVelocity = velocity / 127;
|
const normalizedVelocity = velocity / 127;
|
||||||
const triggerTime = time ?? Tone.now();
|
const triggerTime = time ?? Tone.now();
|
||||||
|
this.applyTrackAutomationForCurrentBeat(trackId);
|
||||||
|
|
||||||
const hasSoloedTracks = this.hasSoloedTracks();
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
if (audioBus.shouldPlayWithSolo(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
|
* Set track mute state
|
||||||
*/
|
*/
|
||||||
@@ -1253,6 +1285,12 @@ export class KGAudioInterface {
|
|||||||
return audioBus?.getVolume() ?? playerBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
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 {
|
public getTrackMuted(trackId: string): boolean {
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
const audioBus = this.trackAudioBuses.get(trackId);
|
||||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||||
@@ -1290,6 +1328,106 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
// ===== PRIVATE UTILITY METHODS =====
|
// ===== 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
|
* Setup audio capture for screen sharing
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
|||||||
export class KGAudioPlayerBus {
|
export class KGAudioPlayerBus {
|
||||||
// Gain node for volume/mute routing
|
// Gain node for volume/mute routing
|
||||||
private gainNode: Tone.Gain;
|
private gainNode: Tone.Gain;
|
||||||
|
private pannerNode: Tone.Panner;
|
||||||
|
|
||||||
// Cached audio buffers keyed by audioFileId
|
// Cached audio buffers keyed by audioFileId
|
||||||
private audioBuffers: Map<string, Tone.ToneAudioBuffer> = new Map();
|
private audioBuffers: Map<string, Tone.ToneAudioBuffer> = new Map();
|
||||||
@@ -19,6 +20,9 @@ export class KGAudioPlayerBus {
|
|||||||
|
|
||||||
// Audio properties
|
// Audio properties
|
||||||
private volume: number;
|
private volume: number;
|
||||||
|
private automationVolume: number | null = null;
|
||||||
|
private pan: number;
|
||||||
|
private automationPan: number | null = null;
|
||||||
private muted: boolean;
|
private muted: boolean;
|
||||||
private solo: boolean;
|
private solo: boolean;
|
||||||
|
|
||||||
@@ -27,16 +31,21 @@ export class KGAudioPlayerBus {
|
|||||||
*/
|
*/
|
||||||
private constructor(
|
private constructor(
|
||||||
gainNode: Tone.Gain,
|
gainNode: Tone.Gain,
|
||||||
|
pannerNode: Tone.Panner,
|
||||||
volume: number,
|
volume: number,
|
||||||
|
pan: number,
|
||||||
muted: boolean,
|
muted: boolean,
|
||||||
solo: boolean
|
solo: boolean
|
||||||
) {
|
) {
|
||||||
this.gainNode = gainNode;
|
this.gainNode = gainNode;
|
||||||
|
this.pannerNode = pannerNode;
|
||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
|
this.pan = pan;
|
||||||
this.muted = muted;
|
this.muted = muted;
|
||||||
this.solo = solo;
|
this.solo = solo;
|
||||||
|
|
||||||
this.updateGainVolume();
|
this.updateGainVolume();
|
||||||
|
this.updatePanValue();
|
||||||
|
|
||||||
console.log(`KGAudioPlayerBus created - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
console.log(`KGAudioPlayerBus created - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||||
}
|
}
|
||||||
@@ -46,12 +55,15 @@ export class KGAudioPlayerBus {
|
|||||||
*/
|
*/
|
||||||
public static async create(
|
public static async create(
|
||||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||||
|
pan: number = 0,
|
||||||
muted: boolean = false,
|
muted: boolean = false,
|
||||||
solo: boolean = false
|
solo: boolean = false
|
||||||
): Promise<KGAudioPlayerBus> {
|
): Promise<KGAudioPlayerBus> {
|
||||||
try {
|
try {
|
||||||
const gainNode = new Tone.Gain(1);
|
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');
|
console.log('KGAudioPlayerBus created successfully');
|
||||||
return bus;
|
return bus;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,10 +184,40 @@ export class KGAudioPlayerBus {
|
|||||||
console.log(`Set audio player bus volume to ${volume}`);
|
console.log(`Set audio player bus volume to ${volume}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setAutomationVolume(volume: number | null): void {
|
||||||
|
this.automationVolume = volume;
|
||||||
|
this.updateGainVolume();
|
||||||
|
}
|
||||||
|
|
||||||
public getVolume(): number {
|
public getVolume(): number {
|
||||||
return this.volume;
|
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 {
|
public setMuted(muted: boolean): void {
|
||||||
this.muted = muted;
|
this.muted = muted;
|
||||||
this.updateGainVolume();
|
this.updateGainVolume();
|
||||||
@@ -200,8 +242,9 @@ export class KGAudioPlayerBus {
|
|||||||
*/
|
*/
|
||||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||||
try {
|
try {
|
||||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error applying effective volume for audio player bus:', 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 {
|
public connect(destination: Tone.InputNode): void {
|
||||||
try {
|
try {
|
||||||
this.gainNode.connect(destination);
|
this.pannerNode.connect(destination);
|
||||||
console.log('Connected audio player bus to destination');
|
console.log('Connected audio player bus to destination');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error connecting audio player bus:', error);
|
console.error('Error connecting audio player bus:', error);
|
||||||
@@ -233,7 +276,7 @@ export class KGAudioPlayerBus {
|
|||||||
|
|
||||||
public disconnect(): void {
|
public disconnect(): void {
|
||||||
try {
|
try {
|
||||||
this.gainNode.disconnect();
|
this.pannerNode.disconnect();
|
||||||
console.log('Disconnected audio player bus');
|
console.log('Disconnected audio player bus');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error disconnecting audio player bus:', error);
|
console.error('Error disconnecting audio player bus:', error);
|
||||||
@@ -250,6 +293,7 @@ export class KGAudioPlayerBus {
|
|||||||
}
|
}
|
||||||
this.audioBuffers.clear();
|
this.audioBuffers.clear();
|
||||||
this.gainNode.dispose();
|
this.gainNode.dispose();
|
||||||
|
this.pannerNode.dispose();
|
||||||
console.log('Disposed KGAudioPlayerBus');
|
console.log('Disposed KGAudioPlayerBus');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error disposing KGAudioPlayerBus:', error);
|
console.error('Error disposing KGAudioPlayerBus:', error);
|
||||||
@@ -260,17 +304,32 @@ export class KGAudioPlayerBus {
|
|||||||
|
|
||||||
private updateGainVolume(): void {
|
private updateGainVolume(): void {
|
||||||
try {
|
try {
|
||||||
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
const effectiveVolume = this.automationVolume ?? this.volume;
|
||||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error updating gain volume:', 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 =====
|
// ===== DEBUGGING =====
|
||||||
|
|
||||||
public getState(): {
|
public getState(): {
|
||||||
volume: number;
|
volume: number;
|
||||||
|
pan: number;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
solo: boolean;
|
solo: boolean;
|
||||||
bufferCount: number;
|
bufferCount: number;
|
||||||
@@ -278,6 +337,7 @@ export class KGAudioPlayerBus {
|
|||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
volume: this.volume,
|
volume: this.volume,
|
||||||
|
pan: this.pan,
|
||||||
muted: this.muted,
|
muted: this.muted,
|
||||||
solo: this.solo,
|
solo: this.solo,
|
||||||
bufferCount: this.audioBuffers.size,
|
bufferCount: this.audioBuffers.size,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
type BakedMidiAutomationPoint,
|
type BakedMidiAutomationPoint,
|
||||||
type MidiAutomationPoint,
|
type MidiAutomationPoint,
|
||||||
} from '../../util/midiAutomationUtil';
|
} from '../../util/midiAutomationUtil';
|
||||||
|
import {
|
||||||
|
bakeTrackAutomationPointsInWindow,
|
||||||
|
getTrackAutomationDefaultValue,
|
||||||
|
resolveTrackAutomationValueAtBeat,
|
||||||
|
} from '../../util/trackAutomationUtil';
|
||||||
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||||
import { KGAudioInterface } from './KGAudioInterface';
|
import { KGAudioInterface } from './KGAudioInterface';
|
||||||
@@ -121,6 +126,8 @@ export class KGOfflineRenderer {
|
|||||||
volume: number;
|
volume: number;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
solo: boolean;
|
solo: boolean;
|
||||||
|
volumeAutomation: MidiAutomationPoint[];
|
||||||
|
panAutomation: MidiAutomationPoint[];
|
||||||
regions: Array<{
|
regions: Array<{
|
||||||
startBeat: number;
|
startBeat: number;
|
||||||
notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
|
notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
|
||||||
@@ -134,6 +141,8 @@ export class KGOfflineRenderer {
|
|||||||
volume: number;
|
volume: number;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
solo: boolean;
|
solo: boolean;
|
||||||
|
volumeAutomation: MidiAutomationPoint[];
|
||||||
|
panAutomation: MidiAutomationPoint[];
|
||||||
regions: Array<{
|
regions: Array<{
|
||||||
startBeat: number;
|
startBeat: number;
|
||||||
lengthBeats: 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') {
|
} else if (track.getType() === 'Wave') {
|
||||||
const volume = audioInterface.getTrackVolume(trackId);
|
const volume = audioInterface.getTrackVolume(trackId);
|
||||||
const muted = audioInterface.getTrackMuted(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.
|
// Track volumes are stored in dB across the app, with 0 meaning unity gain.
|
||||||
sampler.volume.value = getOfflineTrackVolumeDb(trackInfo.volume, trackInfo.muted);
|
sampler.volume.value = 0;
|
||||||
sampler.connect(masterGain);
|
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(
|
const mergedExpressionEvents = normalizeMidiAutomationPoints(
|
||||||
[1, 2, 7, 11].flatMap(controller => trackInfo.controllerEventsByType[controller])
|
[1, 2, 7, 11].flatMap(controller => trackInfo.controllerEventsByType[controller])
|
||||||
);
|
);
|
||||||
@@ -396,7 +425,21 @@ export class KGOfflineRenderer {
|
|||||||
if (!shouldPlay(trackInfo, hasSoloedTracks)) continue;
|
if (!shouldPlay(trackInfo, hasSoloedTracks)) continue;
|
||||||
|
|
||||||
const trackGain = new Tone.Gain(getOfflineTrackGain(trackInfo.volume, trackInfo.muted));
|
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) {
|
for (const regionInfo of trackInfo.regions) {
|
||||||
const regionStartBeat = regionInfo.startBeat;
|
const regionStartBeat = regionInfo.startBeat;
|
||||||
@@ -552,6 +595,15 @@ function setOfflineGainValue(gainNode: Tone.Gain, value: number, time: number):
|
|||||||
gainNode.gain.value = value;
|
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(
|
function createOfflinePitchBendAwareSource(
|
||||||
sampler: Tone.Sampler,
|
sampler: Tone.Sampler,
|
||||||
audioBuffers: Tone.ToneAudioBuffers,
|
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 {
|
export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number {
|
||||||
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
return isSilent ? -Infinity : volumeDb;
|
return isSilent ? -Infinity : volumeDb;
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export { AddAudioTrackCommand } from './track/AddAudioTrackCommand';
|
|||||||
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
||||||
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
||||||
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
|
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
|
// Region commands
|
||||||
export { CreateRegionCommand } from './region/CreateRegionCommand';
|
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 { upgradeToV7 } from './upgradeToV7';
|
||||||
import { upgradeToV8 } from './upgradeToV8';
|
import { upgradeToV8 } from './upgradeToV8';
|
||||||
import { upgradeToV9 } from './upgradeToV9';
|
import { upgradeToV9 } from './upgradeToV9';
|
||||||
|
import { upgradeToV10 } from './upgradeToV10';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upgrade the given project to the latest structure version, one version at a time.
|
* 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);
|
workingProject = upgradeToV9(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 10: {
|
||||||
|
workingProject = upgradeToV10(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
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);
|
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).getPitchBends()).toEqual([]);
|
||||||
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128);
|
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 { KGAudioRegion } from '../region/KGAudioRegion';
|
||||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { WithDefault } from '../../types/projectTypes';
|
import { WithDefault } from '../../types/projectTypes';
|
||||||
|
import { KGTrackAutomationPoint, type TrackAutomationType } from './KGTrackAutomationPoint';
|
||||||
|
import { clampTrackAutomationValue } from '../../util/trackAutomationUtil';
|
||||||
|
|
||||||
// Track type enum
|
// Track type enum
|
||||||
export enum TrackType {
|
export enum TrackType {
|
||||||
@@ -49,6 +51,14 @@ export class KGTrack {
|
|||||||
})
|
})
|
||||||
protected regions: KGRegion[] = [];
|
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) {
|
constructor(name: string = 'Untitled Track', id: number = 0, type: TrackType = TrackType.MIDI, volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
@@ -114,6 +124,45 @@ export class KGTrack {
|
|||||||
this.regions = regions;
|
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
|
// Add a single region
|
||||||
public addRegion(region: KGRegion): void {
|
public addRegion(region: KGRegion): void {
|
||||||
this.regions.push(region);
|
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 { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||||
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand';
|
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand';
|
||||||
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
||||||
|
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update CSS custom property for time signature numerator
|
* Update CSS custom property for time signature numerator
|
||||||
@@ -77,6 +78,7 @@ interface ProjectState {
|
|||||||
selectedNoteIds: string[];
|
selectedNoteIds: string[];
|
||||||
selectedPitchBendIds: string[];
|
selectedPitchBendIds: string[];
|
||||||
selectedControllerEventIds: string[];
|
selectedControllerEventIds: string[];
|
||||||
|
selectedTrackAutomationPointIds: string[];
|
||||||
selectedRegionIds: string[];
|
selectedRegionIds: string[];
|
||||||
selectedTrackId: string | null;
|
selectedTrackId: string | null;
|
||||||
|
|
||||||
@@ -86,6 +88,9 @@ interface ProjectState {
|
|||||||
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
|
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
|
||||||
hybridAudioRegionId: string | null;
|
hybridAudioRegionId: string | null;
|
||||||
automationRedrawVersion: number;
|
automationRedrawVersion: number;
|
||||||
|
activeTrackAutomationTrackId: string | null;
|
||||||
|
activeTrackAutomationType: TrackAutomationType | null;
|
||||||
|
trackAutomationRedrawVersion: number;
|
||||||
|
|
||||||
// ChatBox state
|
// ChatBox state
|
||||||
showChatBox: boolean;
|
showChatBox: boolean;
|
||||||
@@ -162,6 +167,7 @@ interface ProjectState {
|
|||||||
syncSelectionFromCore: () => void;
|
syncSelectionFromCore: () => void;
|
||||||
clearAllSelections: () => void;
|
clearAllSelections: () => void;
|
||||||
setSelectedTrack: (trackId: string | null) => void;
|
setSelectedTrack: (trackId: string | null) => void;
|
||||||
|
setTrackAutomationView: (trackId: string | null, automationType: TrackAutomationType | null) => void;
|
||||||
|
|
||||||
// Piano roll actions
|
// Piano roll actions
|
||||||
setShowPianoRoll: (show: boolean) => void;
|
setShowPianoRoll: (show: boolean) => void;
|
||||||
@@ -170,6 +176,7 @@ interface ProjectState {
|
|||||||
openSpectrogramViewer: (regionId: string) => void;
|
openSpectrogramViewer: (regionId: string) => void;
|
||||||
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
||||||
bumpAutomationRedrawVersion: () => void;
|
bumpAutomationRedrawVersion: () => void;
|
||||||
|
bumpTrackAutomationRedrawVersion: () => void;
|
||||||
|
|
||||||
// Project state cleanup
|
// Project state cleanup
|
||||||
cleanupProjectState: () => void;
|
cleanupProjectState: () => void;
|
||||||
@@ -291,6 +298,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const controllerEventIds = selectedItems
|
const controllerEventIds = selectedItems
|
||||||
.filter(item => item instanceof KGMidiControllerEvent)
|
.filter(item => item instanceof KGMidiControllerEvent)
|
||||||
.map(item => item.getId());
|
.map(item => item.getId());
|
||||||
|
const trackAutomationPointIds = selectedItems
|
||||||
|
.filter(item => item instanceof KGTrackAutomationPoint)
|
||||||
|
.map(item => item.getId());
|
||||||
const regionIds = selectedItems
|
const regionIds = selectedItems
|
||||||
.filter(item => item instanceof KGRegion)
|
.filter(item => item instanceof KGRegion)
|
||||||
.map(item => item.getId());
|
.map(item => item.getId());
|
||||||
@@ -299,6 +309,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
selectedNoteIds: noteIds,
|
selectedNoteIds: noteIds,
|
||||||
selectedPitchBendIds: pitchBendIds,
|
selectedPitchBendIds: pitchBendIds,
|
||||||
selectedControllerEventIds: controllerEventIds,
|
selectedControllerEventIds: controllerEventIds,
|
||||||
|
selectedTrackAutomationPointIds: trackAutomationPointIds,
|
||||||
selectedRegionIds: regionIds
|
selectedRegionIds: regionIds
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -368,6 +379,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
selectedNoteIds: [],
|
selectedNoteIds: [],
|
||||||
selectedPitchBendIds: [],
|
selectedPitchBendIds: [],
|
||||||
selectedControllerEventIds: [],
|
selectedControllerEventIds: [],
|
||||||
|
selectedTrackAutomationPointIds: [],
|
||||||
selectedRegionIds: [],
|
selectedRegionIds: [],
|
||||||
selectedTrackId: initialSelectedTrackId,
|
selectedTrackId: initialSelectedTrackId,
|
||||||
|
|
||||||
@@ -377,6 +389,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
pianoRollMode: 'midi-edit' as const,
|
pianoRollMode: 'midi-edit' as const,
|
||||||
hybridAudioRegionId: null,
|
hybridAudioRegionId: null,
|
||||||
automationRedrawVersion: 0,
|
automationRedrawVersion: 0,
|
||||||
|
activeTrackAutomationTrackId: null,
|
||||||
|
activeTrackAutomationType: null,
|
||||||
|
trackAutomationRedrawVersion: 0,
|
||||||
|
|
||||||
// Initial ChatBox state
|
// Initial ChatBox state
|
||||||
showChatBox: initialChatBoxState,
|
showChatBox: initialChatBoxState,
|
||||||
@@ -829,6 +844,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
selectedMode: projectToLoad.getSelectedMode(),
|
selectedMode: projectToLoad.getSelectedMode(),
|
||||||
isLooping: projectToLoad.getIsLooping(),
|
isLooping: projectToLoad.getIsLooping(),
|
||||||
loopingRange: projectToLoad.getLoopingRange(),
|
loopingRange: projectToLoad.getLoopingRange(),
|
||||||
|
activeTrackAutomationTrackId: null,
|
||||||
|
activeTrackAutomationType: null,
|
||||||
|
trackAutomationRedrawVersion: 0,
|
||||||
playheadPosition: 0, // Ensure store state is also updated
|
playheadPosition: 0, // Ensure store state is also updated
|
||||||
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
|
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
|
||||||
});
|
});
|
||||||
@@ -1232,6 +1250,30 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Update selected track id
|
// Update selected track id
|
||||||
set({ selectedTrackId: trackId });
|
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
|
// Piano roll actions
|
||||||
setShowPianoRoll: (show: boolean) => {
|
setShowPianoRoll: (show: boolean) => {
|
||||||
@@ -1256,6 +1298,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
bumpAutomationRedrawVersion: () => {
|
bumpAutomationRedrawVersion: () => {
|
||||||
set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 }));
|
set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 }));
|
||||||
},
|
},
|
||||||
|
bumpTrackAutomationRedrawVersion: () => {
|
||||||
|
set(state => ({ trackAutomationRedrawVersion: state.trackAutomationRedrawVersion + 1 }));
|
||||||
|
},
|
||||||
|
|
||||||
// Project state cleanup - used when starting new/loading projects
|
// Project state cleanup - used when starting new/loading projects
|
||||||
cleanupProjectState: () => {
|
cleanupProjectState: () => {
|
||||||
@@ -1263,7 +1308,14 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set({ showPianoRoll: false });
|
set({ showPianoRoll: false });
|
||||||
|
|
||||||
// Clear active region and hybrid state
|
// 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
|
// Clear any selected items
|
||||||
KGCore.instance().clearSelectedItems();
|
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