feat: implemented track level automation

This commit is contained in:
Xiaohan-Tian
2026-05-08 15:44:07 -07:00
parent 6e5d9466f4
commit 31e02b4149
24 changed files with 1931 additions and 36 deletions
+49 -4
View File
@@ -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 => {
+62
View File
@@ -238,3 +238,65 @@
/* 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;
+25 -3
View File
@@ -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>
); );
}; };
+58 -1
View File
@@ -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">
+1 -1
View File
@@ -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, {
+70 -8
View File
@@ -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
}; };
+141 -3
View File
@@ -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
*/ */
+67 -7
View File
@@ -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,
+124 -5
View File
@@ -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;
+6
View File
@@ -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);
});
});
+21
View File
@@ -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);
}); });
+49
View File
@@ -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);
+68
View File
@@ -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';
}
}
+53 -1
View File
@@ -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
}); });
@@ -1233,6 +1251,30 @@ export const useProjectStore = create<ProjectState>((set, get) => {
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) => {
set({ showPianoRoll: show }); set({ showPianoRoll: show });
@@ -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();
+42
View File
@@ -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);
});
});
+195
View File
@@ -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;
}