feat: implemented global tempo (bpm) track
This commit is contained in:
@@ -332,25 +332,19 @@ describe('MainContent', () => {
|
|||||||
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the tempo and chord global track add buttons as visual-only controls', () => {
|
it('routes the tempo global track add button through a command and keeps chord visual-only', () => {
|
||||||
render(<MainContent />);
|
render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||||
|
|
||||||
const addButtons = [
|
fireEvent.click(screen.getByRole('button', { name: 'Add Tempo global track item' }));
|
||||||
screen.getByRole('button', { name: 'Add Tempo global track item' }),
|
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||||
screen.getByRole('button', { name: 'Add Chord global track item' }),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(addButtons).toHaveLength(2);
|
fireEvent.click(screen.getByRole('button', { name: 'Add Chord global track item' }));
|
||||||
|
|
||||||
addButtons.forEach(button => {
|
|
||||||
fireEvent.click(button);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(storeState.addTrack).not.toHaveBeenCalled();
|
expect(storeState.addTrack).not.toHaveBeenCalled();
|
||||||
expect(storeState.addAudioTrack).not.toHaveBeenCalled();
|
expect(storeState.addAudioTrack).not.toHaveBeenCalled();
|
||||||
expect(executeCommandMock).not.toHaveBeenCalled();
|
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes the signature global track add button through a command', () => {
|
it('routes the signature global track add button through a command', () => {
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { KGTrack } from '../core/track/KGTrack';
|
|||||||
import { KGRegion } from '../core/region/KGRegion';
|
import { KGRegion } from '../core/region/KGRegion';
|
||||||
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||||
|
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
|
import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
|
||||||
import TrackInfoPanel from './track/TrackInfoPanel';
|
import TrackInfoPanel from './track/TrackInfoPanel';
|
||||||
import TrackGridPanel from './track/TrackGridPanel';
|
import TrackGridPanel from './track/TrackGridPanel';
|
||||||
import GlobalKeySignatureLane from './global-track/GlobalKeySignatureLane';
|
import GlobalKeySignatureLane from './global-track/GlobalKeySignatureLane';
|
||||||
|
import GlobalTempoLane from './global-track/GlobalTempoLane';
|
||||||
import GlobalMarkerLane from './global-track/GlobalMarkerLane';
|
import GlobalMarkerLane from './global-track/GlobalMarkerLane';
|
||||||
import PianoRoll from './piano-roll/PianoRoll';
|
import PianoRoll from './piano-roll/PianoRoll';
|
||||||
import { TrackCreateDialog } from './common';
|
import { TrackCreateDialog } from './common';
|
||||||
@@ -27,19 +29,25 @@ import {
|
|||||||
ChangeLoopSettingsCommand,
|
ChangeLoopSettingsCommand,
|
||||||
CreateGlobalMarkerRegionCommand,
|
CreateGlobalMarkerRegionCommand,
|
||||||
CreateKeySignatureRegionCommand,
|
CreateKeySignatureRegionCommand,
|
||||||
|
CreateTempoRegionCommand,
|
||||||
DeleteKeySignatureRegionCommand,
|
DeleteKeySignatureRegionCommand,
|
||||||
DeleteMultipleKeySignatureRegionsCommand,
|
DeleteMultipleKeySignatureRegionsCommand,
|
||||||
|
DeleteMultipleTempoRegionsCommand,
|
||||||
|
DeleteTempoRegionCommand,
|
||||||
DeleteMultipleGlobalRegionsCommand,
|
DeleteMultipleGlobalRegionsCommand,
|
||||||
DeleteTrackAutomationPointsCommand,
|
DeleteTrackAutomationPointsCommand,
|
||||||
MoveGlobalRegionCommand,
|
MoveGlobalRegionCommand,
|
||||||
ResizeKeySignatureRegionCommand,
|
ResizeKeySignatureRegionCommand,
|
||||||
|
ResizeTempoRegionCommand,
|
||||||
ResizeGlobalRegionCommand,
|
ResizeGlobalRegionCommand,
|
||||||
UpdateKeySignatureRegionCommand,
|
UpdateKeySignatureRegionCommand,
|
||||||
UpdateGlobalRegionTextCommand,
|
UpdateGlobalRegionTextCommand,
|
||||||
|
UpdateTempoRegionCommand,
|
||||||
} from '../core/commands';
|
} from '../core/commands';
|
||||||
import { DEFAULT_MARKER_REGION_NAME, getSortedKeySignatureRegions } from '../util/globalTrackUtil';
|
import { DEFAULT_MARKER_REGION_NAME, getAudioRegionDisplayLengthBeats, getSortedKeySignatureRegions, getSortedTempoRegions } from '../util/globalTrackUtil';
|
||||||
import { FaPlus } from 'react-icons/fa';
|
import { FaPlus } from 'react-icons/fa';
|
||||||
import { FaSquareArrowUpRight } from 'react-icons/fa6';
|
import { FaSquareArrowUpRight } from 'react-icons/fa6';
|
||||||
|
import { TIME_CONSTANTS } from '../constants/coreConstants';
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
onTrackClick?: () => void;
|
onTrackClick?: () => void;
|
||||||
@@ -98,6 +106,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
activeTrackAutomationType,
|
activeTrackAutomationType,
|
||||||
selectedTrackAutomationPointIds,
|
selectedTrackAutomationPointIds,
|
||||||
bumpTrackAutomationRedrawVersion,
|
bumpTrackAutomationRedrawVersion,
|
||||||
|
bumpAudioWaveformRedrawVersion,
|
||||||
refreshProjectState,
|
refreshProjectState,
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
@@ -119,6 +128,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null);
|
const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null);
|
||||||
const [editingGlobalRegionText, setEditingGlobalRegionText] = useState('');
|
const [editingGlobalRegionText, setEditingGlobalRegionText] = useState('');
|
||||||
const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState<string | null>(null);
|
const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState<string | null>(null);
|
||||||
|
const [editingTempoRegionId, setEditingTempoRegionId] = useState<string | null>(null);
|
||||||
|
const [editingTempoText, setEditingTempoText] = useState('');
|
||||||
|
|
||||||
// Use the region operations hook
|
// Use the region operations hook
|
||||||
const { deleteSelectedRegions } = useRegionOperations({
|
const { deleteSelectedRegions } = useRegionOperations({
|
||||||
@@ -173,6 +184,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
);
|
);
|
||||||
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null;
|
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null;
|
||||||
const signatureRegions = signatureTrack ? getSortedKeySignatureRegions(signatureTrack, timeSignature.numerator) : [];
|
const signatureRegions = signatureTrack ? getSortedKeySignatureRegions(signatureTrack, timeSignature.numerator) : [];
|
||||||
|
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null;
|
||||||
|
const tempoRegions = tempoTrack ? getSortedTempoRegions(tempoTrack, timeSignature.numerator) : [];
|
||||||
|
|
||||||
const findProjectRegionById = useCallback((regionId: string): KGRegion | null => {
|
const findProjectRegionById = useCallback((regionId: string): KGRegion | null => {
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
@@ -205,14 +218,22 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const signatureRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGKeySignatureRegion);
|
const signatureRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGKeySignatureRegion);
|
||||||
|
const tempoRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGTempoRegion);
|
||||||
const markerRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGMarkerRegion);
|
const markerRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGMarkerRegion);
|
||||||
|
|
||||||
if (signatureRegionIds.length > 0 && markerRegionIds.length === 0) {
|
if (signatureRegionIds.length > 0 && markerRegionIds.length === 0 && tempoRegionIds.length === 0) {
|
||||||
KGCore.instance().executeCommand(
|
KGCore.instance().executeCommand(
|
||||||
signatureRegionIds.length === 1
|
signatureRegionIds.length === 1
|
||||||
? new DeleteKeySignatureRegionCommand(signatureRegionIds[0])
|
? new DeleteKeySignatureRegionCommand(signatureRegionIds[0])
|
||||||
: new DeleteMultipleKeySignatureRegionsCommand(signatureRegionIds)
|
: new DeleteMultipleKeySignatureRegionsCommand(signatureRegionIds)
|
||||||
);
|
);
|
||||||
|
} else if (tempoRegionIds.length > 0 && markerRegionIds.length === 0 && signatureRegionIds.length === 0) {
|
||||||
|
KGCore.instance().executeCommand(
|
||||||
|
tempoRegionIds.length === 1
|
||||||
|
? new DeleteTempoRegionCommand(tempoRegionIds[0])
|
||||||
|
: new DeleteMultipleTempoRegionsCommand(tempoRegionIds)
|
||||||
|
);
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
} else if (markerRegionIds.length > 0 && signatureRegionIds.length === 0) {
|
} else if (markerRegionIds.length > 0 && signatureRegionIds.length === 0) {
|
||||||
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(markerRegionIds));
|
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(markerRegionIds));
|
||||||
} else {
|
} else {
|
||||||
@@ -226,13 +247,17 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
if (editingKeySignatureRegionId && selectedGlobalRegionIds.includes(editingKeySignatureRegionId)) {
|
if (editingKeySignatureRegionId && selectedGlobalRegionIds.includes(editingKeySignatureRegionId)) {
|
||||||
setEditingKeySignatureRegionId(null);
|
setEditingKeySignatureRegionId(null);
|
||||||
}
|
}
|
||||||
|
if (editingTempoRegionId && selectedGlobalRegionIds.includes(editingTempoRegionId)) {
|
||||||
|
setEditingTempoRegionId(null);
|
||||||
|
setEditingTempoText('');
|
||||||
|
}
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting global marker regions:', error);
|
console.error('Error deleting global marker regions:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [editingGlobalRegionId, editingKeySignatureRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
}, [bumpAudioWaveformRedrawVersion, editingGlobalRegionId, editingKeySignatureRegionId, editingTempoRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||||
|
|
||||||
// Register the delete function with the global manager
|
// Register the delete function with the global manager
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -430,7 +455,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
// Calculate bar number and length from beats
|
// Calculate bar number and length from beats
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1;
|
const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1;
|
||||||
const length = region.getLength() / beatsPerBar;
|
const lengthBeats = region instanceof KGAudioRegion
|
||||||
|
? getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), region)
|
||||||
|
: region.getLength();
|
||||||
|
const length = lengthBeats / beatsPerBar;
|
||||||
|
|
||||||
// Create a RegionUI object
|
// Create a RegionUI object
|
||||||
updatedRegions.push({
|
updatedRegions.push({
|
||||||
@@ -447,7 +475,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
|
|
||||||
// Update the regions state
|
// Update the regions state
|
||||||
setRegions(updatedRegions);
|
setRegions(updatedRegions);
|
||||||
}, [tracks, timeSignature]);
|
}, [globalTracks, tracks, timeSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showPianoRoll || !activeRegionId) {
|
if (!showPianoRoll || !activeRegionId) {
|
||||||
@@ -914,6 +942,16 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setEditingKeySignatureRegionId(regionId);
|
setEditingKeySignatureRegionId(regionId);
|
||||||
}, [findProjectRegionById]);
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
|
const beginEditingTempoRegion = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGTempoRegion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingTempoRegionId(regionId);
|
||||||
|
setEditingTempoText(region.getBpm().toString());
|
||||||
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
const commitGlobalRegionEdit = useCallback((regionId: string) => {
|
const commitGlobalRegionEdit = useCallback((regionId: string) => {
|
||||||
const region = findProjectRegionById(regionId);
|
const region = findProjectRegionById(regionId);
|
||||||
if (!(region instanceof KGMarkerRegion)) {
|
if (!(region instanceof KGMarkerRegion)) {
|
||||||
@@ -1044,6 +1082,84 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
}
|
}
|
||||||
}, [refreshProjectState]);
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
|
const commitTempoRegionEdit = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGTempoRegion)) {
|
||||||
|
setEditingTempoRegionId(null);
|
||||||
|
setEditingTempoText('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = editingTempoText.trim();
|
||||||
|
setEditingTempoRegionId(null);
|
||||||
|
setEditingTempoText('');
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextBpm = parseInt(trimmed, 10);
|
||||||
|
if (Number.isNaN(nextBpm)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBpm <= TIME_CONSTANTS.MIN_BPM || nextBpm >= TIME_CONSTANTS.MAX_BPM || nextBpm === region.getBpm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new UpdateTempoRegionCommand(regionId, nextBpm));
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating tempo region:', error);
|
||||||
|
}
|
||||||
|
}, [bumpAudioWaveformRedrawVersion, editingTempoText, findProjectRegionById, refreshProjectState]);
|
||||||
|
|
||||||
|
const createTempoAtBar = useCallback((requestedStartBar: number) => {
|
||||||
|
const normalizedStartBar = Math.max(0, Math.min(requestedStartBar, maxBars - 1));
|
||||||
|
const existingRegionAtStart = tempoRegions.find(region => region.getStartBar() === normalizedStartBar);
|
||||||
|
if (existingRegionAtStart) {
|
||||||
|
selectGlobalRegion(existingRegionAtStart.getId(), { shiftKey: false });
|
||||||
|
beginEditingTempoRegion(existingRegionAtStart.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new CreateTempoRegionCommand(normalizedStartBar);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
refreshProjectState();
|
||||||
|
|
||||||
|
const createdRegion = command.getCreatedRegion();
|
||||||
|
if (!createdRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
|
||||||
|
setEditingTempoRegionId(createdRegion.getId());
|
||||||
|
setEditingTempoText(createdRegion.getBpm().toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating tempo region:', error);
|
||||||
|
}
|
||||||
|
}, [beginEditingTempoRegion, bumpAudioWaveformRedrawVersion, maxBars, refreshProjectState, selectGlobalRegion, tempoRegions]);
|
||||||
|
|
||||||
|
const createTempoAtPlayheadBar = useCallback(() => {
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const startBar = Math.floor(playheadPosition / beatsPerBar);
|
||||||
|
createTempoAtBar(startBar);
|
||||||
|
}, [createTempoAtBar, playheadPosition, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const resizeTempoRegion = useCallback((regionId: string, edge: 'start' | 'end', bar: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new ResizeTempoRegionCommand(regionId, edge, Math.round(bar)));
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resizing tempo region:', error);
|
||||||
|
}
|
||||||
|
}, [bumpAudioWaveformRedrawVersion, refreshProjectState]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add keyboard event listener for region deletion
|
* Add keyboard event listener for region deletion
|
||||||
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
||||||
@@ -1397,6 +1513,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (track.id === 'tempo') {
|
||||||
|
createTempoAtPlayheadBar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (track.id === 'signature') {
|
if (track.id === 'signature') {
|
||||||
createKeySignatureAtPlayheadBar();
|
createKeySignatureAtPlayheadBar();
|
||||||
}
|
}
|
||||||
@@ -1433,6 +1554,26 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
onMoveRegion={moveGlobalMarkerRegion}
|
onMoveRegion={moveGlobalMarkerRegion}
|
||||||
onResizeRegion={resizeGlobalMarkerRegion}
|
onResizeRegion={resizeGlobalMarkerRegion}
|
||||||
/>
|
/>
|
||||||
|
) : track.id === 'tempo' ? (
|
||||||
|
<GlobalTempoLane
|
||||||
|
key={track.id}
|
||||||
|
tempoRegions={tempoRegions}
|
||||||
|
maxBars={maxBars}
|
||||||
|
barWidthMultiplier={barWidthMultiplier}
|
||||||
|
selectedRegionIds={selectedRegionIds}
|
||||||
|
editingRegionId={editingTempoRegionId}
|
||||||
|
editingText={editingTempoText}
|
||||||
|
onEditingTextChange={setEditingTempoText}
|
||||||
|
onCommitEdit={commitTempoRegionEdit}
|
||||||
|
onCancelEdit={() => {
|
||||||
|
setEditingTempoRegionId(null);
|
||||||
|
setEditingTempoText('');
|
||||||
|
}}
|
||||||
|
onBeginEdit={beginEditingTempoRegion}
|
||||||
|
onSelectRegion={selectGlobalRegion}
|
||||||
|
onCreateAtBar={createTempoAtBar}
|
||||||
|
onResizeRegion={resizeTempoRegion}
|
||||||
|
/>
|
||||||
) : track.id === 'signature' ? (
|
) : track.id === 'signature' ? (
|
||||||
<GlobalKeySignatureLane
|
<GlobalKeySignatureLane
|
||||||
key={track.id}
|
key={track.id}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { KGProject, type KeySignature } from '../core/KGProject';
|
|||||||
import { GlobalTrackType } from '../core/global-track';
|
import { GlobalTrackType } from '../core/global-track';
|
||||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||||
|
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
@@ -38,7 +39,7 @@ import PianoIcon from './common/icons/PianoIcon';
|
|||||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
import MetronomeIcon from './common/icons/MetronomeIcon';
|
||||||
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
|
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
|
||||||
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
||||||
import { UpdateKeySignatureRegionCommand } from '../core/commands';
|
import { UpdateKeySignatureRegionCommand, UpdateTempoRegionCommand } from '../core/commands';
|
||||||
|
|
||||||
const Toolbar: React.FC = () => {
|
const Toolbar: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -60,7 +61,7 @@ const Toolbar: React.FC = () => {
|
|||||||
selectedRegionIds, selectedTrackId,
|
selectedRegionIds, selectedTrackId,
|
||||||
// Playhead and refresh
|
// Playhead and refresh
|
||||||
playheadPosition, refreshProjectState,
|
playheadPosition, refreshProjectState,
|
||||||
requestMainContentScroll, requestPianoRollScroll
|
requestMainContentScroll, requestPianoRollScroll, bumpAudioWaveformRedrawVersion
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
// State for main content tools
|
// State for main content tools
|
||||||
@@ -74,10 +75,18 @@ const Toolbar: React.FC = () => {
|
|||||||
const signatureRegions = (signatureTrack?.getRegions() ?? [])
|
const signatureRegions = (signatureTrack?.getRegions() ?? [])
|
||||||
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
|
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
|
||||||
.sort((left, right) => left.getStartBar() - right.getStartBar());
|
.sort((left, right) => left.getStartBar() - right.getStartBar());
|
||||||
|
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null;
|
||||||
|
const tempoRegions = (tempoTrack?.getRegions() ?? [])
|
||||||
|
.filter((region): region is KGTempoRegion => region instanceof KGTempoRegion)
|
||||||
|
.sort((left, right) => left.getStartBar() - right.getStartBar());
|
||||||
const playheadBar = Math.floor(playheadPosition / timeSignature.numerator);
|
const playheadBar = Math.floor(playheadPosition / timeSignature.numerator);
|
||||||
const activeKeySignatureRegion = signatureRegions.find(
|
const activeKeySignatureRegion = signatureRegions.find(
|
||||||
region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar()
|
region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar()
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
const activeTempoRegion = tempoRegions.find(
|
||||||
|
region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar()
|
||||||
|
) ?? null;
|
||||||
|
const displayedBpm = activeTempoRegion?.getBpm() ?? bpm;
|
||||||
const displayedKeySignature = activeKeySignatureRegion?.getKeySignature() ?? keySignature;
|
const displayedKeySignature = activeKeySignatureRegion?.getKeySignature() ?? keySignature;
|
||||||
|
|
||||||
// State for export dropdown
|
// State for export dropdown
|
||||||
@@ -650,7 +659,7 @@ const Toolbar: React.FC = () => {
|
|||||||
console.log("BPM clicked, current BPM:", bpm);
|
console.log("BPM clicked, current BPM:", bpm);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBpmStr = await showPrompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, bpm.toString());
|
const newBpmStr = await showPrompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, displayedBpm.toString());
|
||||||
|
|
||||||
// Check if user cancelled
|
// Check if user cancelled
|
||||||
if (newBpmStr === null) {
|
if (newBpmStr === null) {
|
||||||
@@ -673,11 +682,17 @@ const Toolbar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update BPM
|
// Update BPM
|
||||||
setBpm(newBpm);
|
if (activeTempoRegion) {
|
||||||
|
KGCore.instance().executeCommand(new UpdateTempoRegionCommand(activeTempoRegion.getId(), newBpm));
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
refreshProjectState();
|
||||||
|
} else {
|
||||||
|
setBpm(newBpm);
|
||||||
|
}
|
||||||
setStatus(`BPM changed to ${newBpm}`);
|
setStatus(`BPM changed to ${newBpm}`);
|
||||||
|
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log(`BPM updated from ${bpm} to ${newBpm}`);
|
console.log(`BPM updated from ${displayedBpm} to ${newBpm}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1168,7 +1183,7 @@ const Toolbar: React.FC = () => {
|
|||||||
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="transport-item">
|
<div className="transport-item">
|
||||||
<span className='current-bpm' onClick={handleBpmClick} style={{ cursor: 'pointer' }}>{bpm}</span>
|
<span className='current-bpm' onClick={handleBpmClick} style={{ cursor: 'pointer' }}>{displayedBpm}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="transport-item">
|
<div className="transport-item">
|
||||||
<span className='current-time-signature' onClick={handleTimeSignatureClick} style={{ cursor: 'pointer' }}>{timeSignature.numerator + "/" + timeSignature.denominator}</span>
|
<span className='current-time-signature' onClick={handleTimeSignatureClick} style={{ cursor: 'pointer' }}>{timeSignature.numerator + "/" + timeSignature.denominator}</span>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import GlobalTempoLane from './GlobalTempoLane';
|
||||||
|
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||||
|
|
||||||
|
describe('GlobalTempoLane', () => {
|
||||||
|
const baseRegion = new KGTempoRegion('tempo-1', 'global-tempo', 1, 128, 0, 4, 4);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.documentElement.style.setProperty('--track-grid-bar-width', '40');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens inline editing for an existing region', () => {
|
||||||
|
const onBeginEdit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<GlobalTempoLane
|
||||||
|
tempoRegions={[baseRegion]}
|
||||||
|
maxBars={8}
|
||||||
|
barWidthMultiplier={1}
|
||||||
|
selectedRegionIds={[]}
|
||||||
|
editingRegionId={null}
|
||||||
|
editingText=""
|
||||||
|
onEditingTextChange={vi.fn()}
|
||||||
|
onCommitEdit={vi.fn()}
|
||||||
|
onCancelEdit={vi.fn()}
|
||||||
|
onBeginEdit={onBeginEdit}
|
||||||
|
onSelectRegion={vi.fn()}
|
||||||
|
onCreateAtBar={vi.fn()}
|
||||||
|
onResizeRegion={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.doubleClick(screen.getByText('128 BPM'));
|
||||||
|
expect(onBeginEdit).toHaveBeenCalledWith('tempo-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new region at a bar-aligned position on empty-lane double click', () => {
|
||||||
|
const onCreateAtBar = vi.fn();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<GlobalTempoLane
|
||||||
|
tempoRegions={[baseRegion]}
|
||||||
|
maxBars={8}
|
||||||
|
barWidthMultiplier={1}
|
||||||
|
selectedRegionIds={[]}
|
||||||
|
editingRegionId={null}
|
||||||
|
editingText=""
|
||||||
|
onEditingTextChange={vi.fn()}
|
||||||
|
onCommitEdit={vi.fn()}
|
||||||
|
onCancelEdit={vi.fn()}
|
||||||
|
onBeginEdit={vi.fn()}
|
||||||
|
onSelectRegion={vi.fn()}
|
||||||
|
onCreateAtBar={onCreateAtBar}
|
||||||
|
onResizeRegion={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lane = container.querySelector('.global-tempo-lane') as HTMLDivElement;
|
||||||
|
vi.spyOn(lane, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 320,
|
||||||
|
bottom: 24,
|
||||||
|
width: 320,
|
||||||
|
height: 24,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.doubleClick(lane, { clientX: 159, clientY: 10 });
|
||||||
|
expect(onCreateAtBar).toHaveBeenCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips non-digit characters during inline editing', () => {
|
||||||
|
const onEditingTextChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<GlobalTempoLane
|
||||||
|
tempoRegions={[baseRegion]}
|
||||||
|
maxBars={8}
|
||||||
|
barWidthMultiplier={1}
|
||||||
|
selectedRegionIds={[]}
|
||||||
|
editingRegionId="tempo-1"
|
||||||
|
editingText="128"
|
||||||
|
onEditingTextChange={onEditingTextChange}
|
||||||
|
onCommitEdit={vi.fn()}
|
||||||
|
onCancelEdit={vi.fn()}
|
||||||
|
onBeginEdit={vi.fn()}
|
||||||
|
onSelectRegion={vi.fn()}
|
||||||
|
onCreateAtBar={vi.fn()}
|
||||||
|
onResizeRegion={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue('128'), { target: { value: '12a8!' } });
|
||||||
|
expect(onEditingTextChange).toHaveBeenCalledWith('128');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||||
|
import type { RegionClickOptions } from '../interfaces';
|
||||||
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
|
import { TIME_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants';
|
||||||
|
|
||||||
|
interface GlobalTempoLaneProps {
|
||||||
|
tempoRegions: KGTempoRegion[];
|
||||||
|
maxBars: number;
|
||||||
|
barWidthMultiplier: number;
|
||||||
|
selectedRegionIds: string[];
|
||||||
|
editingRegionId: string | null;
|
||||||
|
editingText: string;
|
||||||
|
onEditingTextChange: (value: string) => void;
|
||||||
|
onCommitEdit: (regionId: string) => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onBeginEdit: (regionId: string) => void;
|
||||||
|
onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
|
||||||
|
onCreateAtBar: (startBar: number) => void;
|
||||||
|
onResizeRegion: (regionId: string, edge: 'start' | 'end', bar: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResizeEdge = 'start' | 'end' | null;
|
||||||
|
|
||||||
|
const REGION_EDGE_HITBOX_PX = 8;
|
||||||
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
|
|
||||||
|
const GlobalTempoLane: React.FC<GlobalTempoLaneProps> = ({
|
||||||
|
tempoRegions,
|
||||||
|
maxBars,
|
||||||
|
barWidthMultiplier,
|
||||||
|
selectedRegionIds,
|
||||||
|
editingRegionId,
|
||||||
|
editingText,
|
||||||
|
onEditingTextChange,
|
||||||
|
onCommitEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onBeginEdit,
|
||||||
|
onSelectRegion,
|
||||||
|
onCreateAtBar,
|
||||||
|
onResizeRegion,
|
||||||
|
}) => {
|
||||||
|
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [previewBars, setPreviewBars] = useState<Record<string, { startBar: number; lengthBars: number }>>({});
|
||||||
|
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
|
||||||
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||||
|
const interactionRef = useRef<{
|
||||||
|
mode: 'resize' | null;
|
||||||
|
regionId: string;
|
||||||
|
resizeEdge: ResizeEdge;
|
||||||
|
initialMouseX: number;
|
||||||
|
moved: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const barWidth = useMemo(
|
||||||
|
() => TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier,
|
||||||
|
[barWidthMultiplier]
|
||||||
|
);
|
||||||
|
|
||||||
|
const regionOrder = useMemo(
|
||||||
|
() => tempoRegions.map(region => region.getId()),
|
||||||
|
[tempoRegions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRegionIndex = (regionId: string) => regionOrder.findIndex(candidateId => candidateId === regionId);
|
||||||
|
const canResizeEdge = (regionId: string, edge: 'start' | 'end') => {
|
||||||
|
const regionIndex = getRegionIndex(regionId);
|
||||||
|
if (regionIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edge === 'start') {
|
||||||
|
return regionIndex > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return regionIndex < tempoRegions.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRenderedBarState = (region: KGTempoRegion) => (
|
||||||
|
previewBars[region.getId()] ?? {
|
||||||
|
startBar: region.getStartBar(),
|
||||||
|
lengthBars: region.getLengthBars(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const getResizeEdgeFromMouseEvent = (event: React.MouseEvent<HTMLDivElement, MouseEvent>): ResizeEdge => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const offsetX = event.clientX - rect.left;
|
||||||
|
|
||||||
|
if (offsetX <= REGION_EDGE_HITBOX_PX) {
|
||||||
|
return 'start';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rect.width - offsetX <= REGION_EDGE_HITBOX_PX) {
|
||||||
|
return 'end';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBarFromClientX = (clientX: number) => {
|
||||||
|
if (!laneRef.current) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = laneRef.current.getBoundingClientRect();
|
||||||
|
const relativeX = clientX - rect.left;
|
||||||
|
return Math.max(0, Math.min(maxBars, Math.round(relativeX / barWidth)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCreateBarFromClientX = (clientX: number) => {
|
||||||
|
if (!laneRef.current) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = laneRef.current.getBoundingClientRect();
|
||||||
|
const relativeX = clientX - rect.left;
|
||||||
|
return Math.max(0, Math.min(maxBars - 1, Math.floor(relativeX / barWidth)));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (isModifierKeyPressed(event)) {
|
||||||
|
setIsModifierPressed(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onCancelEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (!isModifierKeyPressed(event)) {
|
||||||
|
setIsModifierPressed(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
};
|
||||||
|
}, [onCancelEdit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!interactionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interaction = interactionRef.current;
|
||||||
|
const deltaX = event.clientX - interaction.initialMouseX;
|
||||||
|
if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
|
||||||
|
interaction.moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.mode !== 'resize' || !interaction.resizeEdge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRegion = tempoRegions.find(region => region.getId() === interaction.regionId);
|
||||||
|
if (!targetRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = getRegionIndex(interaction.regionId);
|
||||||
|
const desiredBoundaryBar = getBarFromClientX(event.clientX);
|
||||||
|
|
||||||
|
if (interaction.resizeEdge === 'start' && targetIndex > 0) {
|
||||||
|
const previousRegion = tempoRegions[targetIndex - 1];
|
||||||
|
const targetEndBar = targetRegion.getEndBar();
|
||||||
|
const clampedBoundaryBar = Math.max(
|
||||||
|
previousRegion.getStartBar() + 1,
|
||||||
|
Math.min(desiredBoundaryBar, targetEndBar - 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
setPreviewBars({
|
||||||
|
[previousRegion.getId()]: {
|
||||||
|
startBar: previousRegion.getStartBar(),
|
||||||
|
lengthBars: clampedBoundaryBar - previousRegion.getStartBar(),
|
||||||
|
},
|
||||||
|
[targetRegion.getId()]: {
|
||||||
|
startBar: clampedBoundaryBar,
|
||||||
|
lengthBars: targetEndBar - clampedBoundaryBar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.resizeEdge === 'end' && targetIndex < tempoRegions.length - 1) {
|
||||||
|
const nextRegion = tempoRegions[targetIndex + 1];
|
||||||
|
const nextEndBar = nextRegion.getEndBar();
|
||||||
|
const clampedBoundaryBar = Math.max(
|
||||||
|
targetRegion.getStartBar() + 1,
|
||||||
|
Math.min(desiredBoundaryBar, nextEndBar - 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
setPreviewBars({
|
||||||
|
[targetRegion.getId()]: {
|
||||||
|
startBar: targetRegion.getStartBar(),
|
||||||
|
lengthBars: clampedBoundaryBar - targetRegion.getStartBar(),
|
||||||
|
},
|
||||||
|
[nextRegion.getId()]: {
|
||||||
|
startBar: clampedBoundaryBar,
|
||||||
|
lengthBars: nextEndBar - clampedBoundaryBar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (event: MouseEvent) => {
|
||||||
|
if (!interactionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interaction = interactionRef.current;
|
||||||
|
interactionRef.current = null;
|
||||||
|
const resizeEdge = interaction.resizeEdge;
|
||||||
|
const shouldResize = interaction.moved && resizeEdge !== null;
|
||||||
|
setPreviewBars({});
|
||||||
|
|
||||||
|
if (!shouldResize) {
|
||||||
|
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onResizeRegion(interaction.regionId, resizeEdge, getBarFromClientX(event.clientX));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [getBarFromClientX, onResizeRegion, onSelectRegion, tempoRegions]);
|
||||||
|
|
||||||
|
const handleLaneMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(event.target instanceof HTMLElement) || event.target.closest('.global-tempo-region')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isModifierKeyPressed(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onCreateAtBar(getCreateBarFromClientX(event.clientX));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLaneDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!(event.target instanceof HTMLElement) || event.target.closest('.global-tempo-region')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onCreateAtBar(getCreateBarFromClientX(event.clientX));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={laneRef}
|
||||||
|
className={`global-marker-lane global-tempo-lane${isModifierPressed ? ' pencil-cursor' : ''}`}
|
||||||
|
onMouseDown={handleLaneMouseDown}
|
||||||
|
onDoubleClick={handleLaneDoubleClick}
|
||||||
|
>
|
||||||
|
{tempoRegions.map(region => {
|
||||||
|
const rendered = getRenderedBarState(region);
|
||||||
|
const isSelected = selectedRegionIds.includes(region.getId());
|
||||||
|
const isEditing = editingRegionId === region.getId();
|
||||||
|
const left = rendered.startBar * barWidth;
|
||||||
|
const clampedEndBar = Math.max(rendered.startBar, Math.min(rendered.startBar + rendered.lengthBars, maxBars));
|
||||||
|
const widthBars = Math.max(0, clampedEndBar - rendered.startBar);
|
||||||
|
const width = Math.max(barWidth, widthBars * barWidth);
|
||||||
|
|
||||||
|
if (rendered.startBar >= maxBars || widthBars <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={region.getId()}
|
||||||
|
className={`global-marker-region global-tempo-region${isSelected ? ' selected' : ''}`}
|
||||||
|
style={{
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
cursor: isEditing ? 'text' : hoverEdges[region.getId()] ? 'col-resize' : 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoverEdges(prev => ({ ...prev, [region.getId()]: null }))}
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
if (isEditing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEdge = getResizeEdgeFromMouseEvent(event);
|
||||||
|
const normalizedEdge = nextEdge && canResizeEdge(region.getId(), nextEdge) ? nextEdge : null;
|
||||||
|
setHoverEdges(prev => ({ ...prev, [region.getId()]: normalizedEdge }));
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHoverEdges(prev => ({ ...prev, [region.getId()]: null }))}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.button !== 0 || isEditing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEdge = getResizeEdgeFromMouseEvent(event);
|
||||||
|
const normalizedEdge = nextEdge && canResizeEdge(region.getId(), nextEdge) ? nextEdge : null;
|
||||||
|
if (!normalizedEdge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
interactionRef.current = {
|
||||||
|
mode: 'resize',
|
||||||
|
regionId: region.getId(),
|
||||||
|
resizeEdge: normalizedEdge,
|
||||||
|
initialMouseX: event.clientX,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onSelectRegion(region.getId(), { shiftKey: false });
|
||||||
|
onBeginEdit(region.getId());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className="global-marker-input"
|
||||||
|
value={editingText}
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
onChange={(event) => {
|
||||||
|
const digitsOnly = event.target.value.replace(/\D+/g, '');
|
||||||
|
onEditingTextChange(digitsOnly);
|
||||||
|
}}
|
||||||
|
onBlur={() => onCommitEdit(region.getId())}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
onCommitEdit(region.getId());
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancelEdit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="global-marker-label">{region.getDisplayName()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalTempoLane;
|
||||||
@@ -10,6 +10,8 @@ import { useProjectStore } from '../../stores/projectStore';
|
|||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
|
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
|
||||||
import type { RegionPreviewContentStyle } from '../interfaces';
|
import type { RegionPreviewContentStyle } from '../interfaces';
|
||||||
|
import { KGCore } from '../../core/KGCore';
|
||||||
|
import { beatRangeToSeconds } from '../../util/globalTrackUtil';
|
||||||
|
|
||||||
const DRAG_START_THRESHOLD_PX = 4;
|
const DRAG_START_THRESHOLD_PX = 4;
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ interface RegionItemProps {
|
|||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
isAudioRegion?: boolean;
|
isAudioRegion?: boolean;
|
||||||
previewContentStyle?: RegionPreviewContentStyle;
|
previewContentStyle?: RegionPreviewContentStyle;
|
||||||
|
redrawVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RegionItem: React.FC<RegionItemProps> = ({
|
const RegionItem: React.FC<RegionItemProps> = ({
|
||||||
@@ -76,6 +79,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
isPreview = false,
|
isPreview = false,
|
||||||
isAudioRegion = false,
|
isAudioRegion = false,
|
||||||
previewContentStyle,
|
previewContentStyle,
|
||||||
|
redrawVersion = 0,
|
||||||
}) => {
|
}) => {
|
||||||
// Get selection state and time signature from store
|
// Get selection state and time signature from store
|
||||||
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
||||||
@@ -273,10 +277,18 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
const clipStartOffsetSeconds = audioRegion ? audioRegion.getClipStartOffsetSeconds() : 0;
|
const clipStartOffsetSeconds = audioRegion ? audioRegion.getClipStartOffsetSeconds() : 0;
|
||||||
const clipStartSample = Math.floor(clipStartOffsetSeconds * sampleRate);
|
const clipStartSample = Math.floor(clipStartOffsetSeconds * sampleRate);
|
||||||
|
|
||||||
// Calculate visible duration from region length in beats
|
const currentProject = KGCore.instance().getCurrentProject();
|
||||||
const secondsPerBeat = 60 / bpm;
|
|
||||||
const regionLengthBeats = audioRegion ? audioRegion.getLength() : 0;
|
const regionLengthBeats = audioRegion ? audioRegion.getLength() : 0;
|
||||||
const visibleDurationSeconds = regionLengthBeats * secondsPerBeat;
|
const visibleDurationSeconds = audioRegion
|
||||||
|
? Math.min(
|
||||||
|
beatRangeToSeconds(
|
||||||
|
currentProject,
|
||||||
|
audioRegion.getStartFromBeat(),
|
||||||
|
audioRegion.getStartFromBeat() + regionLengthBeats
|
||||||
|
),
|
||||||
|
Math.max(0, audioRegion.getAudioDurationSeconds() - clipStartOffsetSeconds)
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
const visibleSamples = Math.floor(visibleDurationSeconds * sampleRate);
|
const visibleSamples = Math.floor(visibleDurationSeconds * sampleRate);
|
||||||
|
|
||||||
// Clamp to buffer boundaries
|
// Clamp to buffer boundaries
|
||||||
@@ -383,7 +395,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
renderNotesOnCanvas();
|
renderNotesOnCanvas();
|
||||||
}
|
}
|
||||||
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length, previewContentStyle?.width]);
|
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length, previewContentStyle?.width, redrawVersion]);
|
||||||
|
|
||||||
// Re-render canvas when region content size changes
|
// Re-render canvas when region content size changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -406,7 +418,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
resizeObserver.unobserve(previewContentRef.current);
|
resizeObserver.unobserve(previewContentRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, previewContentStyle?.width]);
|
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, previewContentStyle?.width, redrawVersion]);
|
||||||
|
|
||||||
// Handle mouse movement to detect edge proximity
|
// Handle mouse movement to detect edge proximity
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
||||||
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
||||||
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
|
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
|
||||||
|
const audioWaveformRedrawVersion = useProjectStore(state => state.audioWaveformRedrawVersion);
|
||||||
const recordingMode = useProjectStore(state => state.recordingMode);
|
const recordingMode = useProjectStore(state => state.recordingMode);
|
||||||
const recordingTargetTrackIndex = useProjectStore(state => state.recordingTargetTrackIndex);
|
const recordingTargetTrackIndex = useProjectStore(state => state.recordingTargetTrackIndex);
|
||||||
const recordingCommitStartBeatAbsolute = useProjectStore(state => state.recordingCommitStartBeatAbsolute);
|
const recordingCommitStartBeatAbsolute = useProjectStore(state => state.recordingCommitStartBeatAbsolute);
|
||||||
@@ -796,6 +797,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
audioRegion={audioRegion}
|
audioRegion={audioRegion}
|
||||||
audioBuffer={audioBuffer}
|
audioBuffer={audioBuffer}
|
||||||
previewContentStyle={tempPreviewRegionContentStyles[region.id]}
|
previewContentStyle={tempPreviewRegionContentStyles[region.id]}
|
||||||
|
redrawVersion={audioWaveformRedrawVersion}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { showAlert } from '../../util/dialogUtil';
|
|||||||
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
|
import { getAudioRegionDisplayLengthBeats } from '../../util/globalTrackUtil';
|
||||||
|
|
||||||
interface TrackGridPanelProps {
|
interface TrackGridPanelProps {
|
||||||
tracks: KGTrack[];
|
tracks: KGTrack[];
|
||||||
@@ -64,6 +65,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
||||||
const refreshProjectState = useProjectStore(state => state.refreshProjectState);
|
const refreshProjectState = useProjectStore(state => state.refreshProjectState);
|
||||||
|
const bumpAudioWaveformRedrawVersion = useProjectStore(state => state.bumpAudioWaveformRedrawVersion);
|
||||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||||
const [previewRegionStyles, setPreviewRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
const [previewRegionStyles, setPreviewRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
||||||
@@ -356,12 +358,16 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
|
|
||||||
const created = cmd.getCreatedRegion();
|
const created = cmd.getCreatedRegion();
|
||||||
if (created && onExternalDropComplete) {
|
if (created && onExternalDropComplete) {
|
||||||
|
const displayLengthInBars = Math.max(
|
||||||
|
1,
|
||||||
|
getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), created) / beatsPerBar
|
||||||
|
);
|
||||||
const regionUI: RegionUI = {
|
const regionUI: RegionUI = {
|
||||||
id: created.getId(),
|
id: created.getId(),
|
||||||
trackId: track.getId().toString(),
|
trackId: track.getId().toString(),
|
||||||
trackIndex,
|
trackIndex,
|
||||||
barNumber,
|
barNumber,
|
||||||
length: lengthInBars,
|
length: displayLengthInBars,
|
||||||
name: created.getName(),
|
name: created.getName(),
|
||||||
};
|
};
|
||||||
onExternalDropComplete(trackIndex, regionUI);
|
onExternalDropComplete(trackIndex, regionUI);
|
||||||
@@ -571,6 +577,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const sourceTrack = tracks.find(t => {
|
const sourceTrack = tracks.find(t => {
|
||||||
return t.getRegions().some(r => r.getId() === regionId);
|
return t.getRegions().some(r => r.getId() === regionId);
|
||||||
});
|
});
|
||||||
|
const movedRegionWasAudio = sourceTrack?.getRegions().find(r => r.getId() === regionId) instanceof KGAudioRegion;
|
||||||
if (sourceTrack && sourceTrack.getType() !== targetTrack.getType()) {
|
if (sourceTrack && sourceTrack.getType() !== targetTrack.getType()) {
|
||||||
// Snap back — don't execute the move
|
// Snap back — don't execute the move
|
||||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
@@ -592,6 +599,12 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
|
|
||||||
KGCore.instance().executeCommand(command, { rethrow: true });
|
KGCore.instance().executeCommand(command, { rethrow: true });
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
|
if (bulkRegionIds.some(selectedId => {
|
||||||
|
const candidate = tracks.flatMap(track => track.getRegions()).find(region => region.getId() === selectedId);
|
||||||
|
return candidate instanceof KGAudioRegion;
|
||||||
|
})) {
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +638,9 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const movedRegion = command.getTargetRegion();
|
const movedRegion = command.getTargetRegion();
|
||||||
console.log(`Verified region: ${movedRegion ? 'found' : 'not found'}, startBeat=${movedRegion?.getStartFromBeat()}, trackId=${movedRegion?.getTrackId()}`);
|
console.log(`Verified region: ${movedRegion ? 'found' : 'not found'}, startBeat=${movedRegion?.getStartFromBeat()}, trackId=${movedRegion?.getTrackId()}`);
|
||||||
}
|
}
|
||||||
|
if (movedRegionWasAudio) {
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error moving region:', error);
|
console.error('Error moving region:', error);
|
||||||
await showAlert(error instanceof Error ? error.message : 'Unable to move the selected regions.');
|
await showAlert(error instanceof Error ? error.message : 'Unable to move the selected regions.');
|
||||||
@@ -681,6 +697,12 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
);
|
);
|
||||||
KGCore.instance().executeCommand(command, { rethrow: true });
|
KGCore.instance().executeCommand(command, { rethrow: true });
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
|
if (bulkRegionIds.some(selectedId => {
|
||||||
|
const candidate = tracks.flatMap(track => track.getRegions()).find(region => region.getId() === selectedId);
|
||||||
|
return candidate instanceof KGAudioRegion;
|
||||||
|
})) {
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +714,9 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
region.trackIndex
|
region.trackIndex
|
||||||
);
|
);
|
||||||
KGCore.instance().executeCommand(command, { rethrow: true });
|
KGCore.instance().executeCommand(command, { rethrow: true });
|
||||||
|
if (coreRegion instanceof KGAudioRegion) {
|
||||||
|
bumpAudioWaveformRedrawVersion();
|
||||||
|
}
|
||||||
|
|
||||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
console.log(`Fine-moved region ${regionId}: startFromBeat=${newStartFromBeat}`);
|
console.log(`Fine-moved region ${regionId}: startFromBeat=${newStartFromBeat}`);
|
||||||
@@ -793,12 +818,16 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
|
|
||||||
const created = cmd.getCreatedRegion();
|
const created = cmd.getCreatedRegion();
|
||||||
if (created && onExternalDropComplete) {
|
if (created && onExternalDropComplete) {
|
||||||
|
const displayLengthInBars = Math.max(
|
||||||
|
1,
|
||||||
|
getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), created as unknown as KGAudioRegion) / beatsPerBar
|
||||||
|
);
|
||||||
const regionUI: RegionUI = {
|
const regionUI: RegionUI = {
|
||||||
id: created.getId(),
|
id: created.getId(),
|
||||||
trackId: track.getId().toString(),
|
trackId: track.getId().toString(),
|
||||||
trackIndex,
|
trackIndex,
|
||||||
barNumber,
|
barNumber,
|
||||||
length: lengthInBars,
|
length: displayLengthInBars,
|
||||||
name: created.getName(),
|
name: created.getName(),
|
||||||
};
|
};
|
||||||
onExternalDropComplete(trackIndex, regionUI);
|
onExternalDropComplete(trackIndex, regionUI);
|
||||||
|
|||||||
+9
-32
@@ -11,6 +11,7 @@ import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
|||||||
import { KGRegion } from './region/KGRegion';
|
import { KGRegion } from './region/KGRegion';
|
||||||
import { generateUniqueId } from '../util/miscUtil';
|
import { generateUniqueId } from '../util/miscUtil';
|
||||||
import { KGCommand, KGCommandHistory } from './commands';
|
import { KGCommand, KGCommandHistory } from './commands';
|
||||||
|
import { getEffectiveBpmAtBeat } from '../util/globalTrackUtil';
|
||||||
|
|
||||||
interface PlaybackStartOptions {
|
interface PlaybackStartOptions {
|
||||||
preserveLoopPreroll?: boolean;
|
preserveLoopPreroll?: boolean;
|
||||||
@@ -150,7 +151,7 @@ export class KGCore {
|
|||||||
try {
|
try {
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
if (audioInterface.getIsInitialized()) {
|
if (audioInterface.getIsInitialized()) {
|
||||||
audioInterface.setBpm(project.getBpm());
|
audioInterface.setBpm(getEffectiveBpmAtBeat(project, 0));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing project with audio interface:', error);
|
console.error('Error syncing project with audio interface:', error);
|
||||||
@@ -253,9 +254,6 @@ export class KGCore {
|
|||||||
audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
|
audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
|
||||||
allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
|
allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync BPM and transport settings
|
|
||||||
audioInterface.setBpm(this.currentProject.getBpm());
|
|
||||||
audioInterface.setTransportPosition(this.playheadPosition);
|
audioInterface.setTransportPosition(this.playheadPosition);
|
||||||
|
|
||||||
console.log("Playback prepared successfully");
|
console.log("Playback prepared successfully");
|
||||||
@@ -388,22 +386,8 @@ export class KGCore {
|
|||||||
this.stopPlaybackUpdates();
|
this.stopPlaybackUpdates();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const audioInterface = KGAudioInterface.instance();
|
||||||
// Calculate current playhead position based on elapsed time
|
let newPosition = audioInterface.getTransportPosition();
|
||||||
const elapsedMs = performance.now() - this.playbackStartTime;
|
|
||||||
|
|
||||||
// Get playback delay from config
|
|
||||||
const configManager = ConfigManager.instance();
|
|
||||||
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
|
|
||||||
const playbackDelayMs = playbackDelaySeconds * 1000;
|
|
||||||
|
|
||||||
// Subtract the delay from elapsed time for visual sync
|
|
||||||
// During the initial delay period, playhead stays at start position
|
|
||||||
const adjustedElapsedMs = Math.max(0, elapsedMs - playbackDelayMs);
|
|
||||||
|
|
||||||
const bpm = this.currentProject.getBpm();
|
|
||||||
const beatsPerMs = bpm / (60 * 1000);
|
|
||||||
let newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs);
|
|
||||||
|
|
||||||
// Handle looping or end-of-project
|
// Handle looping or end-of-project
|
||||||
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
||||||
@@ -415,10 +399,10 @@ export class KGCore {
|
|||||||
|
|
||||||
const loopStartBeats = startBar * beatsPerBar;
|
const loopStartBeats = startBar * beatsPerBar;
|
||||||
const loopEndBeats = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
|
const loopEndBeats = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
|
||||||
const loopLengthBeats = loopEndBeats - loopStartBeats;
|
const previousPosition = this.playheadPosition;
|
||||||
|
|
||||||
// Wrap playhead position within loop range
|
// Tone.Transport position wraps back to loop start. Preserve the loop-end callback behavior.
|
||||||
if (newPosition >= loopEndBeats) {
|
if (this.loopBoundaryReachedCallback && previousPosition < loopEndBeats && newPosition < previousPosition) {
|
||||||
if (this.loopBoundaryReachedCallback) {
|
if (this.loopBoundaryReachedCallback) {
|
||||||
const callback = this.loopBoundaryReachedCallback;
|
const callback = this.loopBoundaryReachedCallback;
|
||||||
this.loopBoundaryReachedCallback = null;
|
this.loopBoundaryReachedCallback = null;
|
||||||
@@ -426,16 +410,9 @@ export class KGCore {
|
|||||||
callback(loopEndBeats);
|
callback(loopEndBeats);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate how far we've overshot and wrap back
|
|
||||||
const overshot = newPosition - loopEndBeats;
|
|
||||||
newPosition = loopStartBeats + (overshot % loopLengthBeats);
|
|
||||||
|
|
||||||
// Reset timing reference to prevent drift accumulation
|
|
||||||
const newElapsedBeats = newPosition - loopStartBeats;
|
|
||||||
this.playbackStartTime = performance.now() - (newElapsedBeats / beatsPerMs) - playbackDelayMs;
|
|
||||||
this.playbackStartPosition = loopStartBeats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newPosition = Math.max(loopStartBeats, Math.min(newPosition, loopEndBeats));
|
||||||
} else {
|
} else {
|
||||||
// Non-looping mode: stop at project end
|
// Non-looping mode: stop at project end
|
||||||
const maxBars = this.currentProject.getMaxBars();
|
const maxBars = this.currentProject.getMaxBars();
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import type { KGAudioRegion } from '../region/KGAudioRegion';
|
|||||||
import { KGCore } from '../KGCore';
|
import { KGCore } from '../KGCore';
|
||||||
import { ConfigManager } from '../config/ConfigManager';
|
import { ConfigManager } from '../config/ConfigManager';
|
||||||
import { KGMetronome } from './KGMetronome';
|
import { KGMetronome } from './KGMetronome';
|
||||||
|
import { GlobalTrackType } from '../global-track';
|
||||||
|
import { beatRangeToSeconds, beatToSeconds, findGlobalTrackByType, getEffectiveBpmAtBeat, getSortedTempoRegions, secondsToBeat } from '../../util/globalTrackUtil';
|
||||||
|
|
||||||
interface PreparePlaybackOptions {
|
interface PreparePlaybackOptions {
|
||||||
allowStartBeforeLoopStart?: boolean;
|
allowStartBeforeLoopStart?: boolean;
|
||||||
@@ -445,14 +447,14 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Set project BPM and time signature FIRST (this affects timing calculations)
|
// Set project BPM and time signature FIRST (this affects timing calculations)
|
||||||
Tone.Transport.bpm.value = project.getBpm();
|
Tone.Transport.bpm.value = getEffectiveBpmAtBeat(project, Math.max(startPosition, 0));
|
||||||
const timeSignature = project.getTimeSignature();
|
const timeSignature = project.getTimeSignature();
|
||||||
const secondsPerBeat = 60 / project.getBpm();
|
const secondsPerBeat = 60 / Math.max(1, getEffectiveBpmAtBeat(project, Math.max(startPosition, 0)));
|
||||||
const resumeSafetyOffsetBeats =
|
const resumeSafetyOffsetBeats =
|
||||||
KGAudioInterface.AUDIO_RESUME_SAFETY_OFFSET_SECONDS / secondsPerBeat;
|
KGAudioInterface.AUDIO_RESUME_SAFETY_OFFSET_SECONDS / secondsPerBeat;
|
||||||
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
||||||
|
|
||||||
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
|
console.log(`Setting Tone.js BPM to ${Tone.Transport.bpm.value}, actual value: ${Tone.Transport.bpm.value}`);
|
||||||
|
|
||||||
// Configure loop settings
|
// Configure loop settings
|
||||||
const isLooping = project.getIsLooping();
|
const isLooping = project.getIsLooping();
|
||||||
@@ -486,8 +488,10 @@ export class KGAudioInterface {
|
|||||||
console.log("Loop mode disabled");
|
console.log("Loop mode disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.scheduleTempoChanges(project, Math.max(startPosition, scheduleStartBeat), scheduleEndBeat);
|
||||||
|
|
||||||
if (startPosition < 0) {
|
if (startPosition < 0) {
|
||||||
this.delayedTransportStartSeconds = Math.abs(startPosition) * secondsPerBeat;
|
this.delayedTransportStartSeconds = Math.abs(startPosition) * (60 / project.getBpm());
|
||||||
this.virtualPrerollStartBeat = startPosition;
|
this.virtualPrerollStartBeat = startPosition;
|
||||||
this.virtualPrerollStartAudioTime = null;
|
this.virtualPrerollStartAudioTime = null;
|
||||||
} else {
|
} else {
|
||||||
@@ -513,7 +517,7 @@ export class KGAudioInterface {
|
|||||||
const automationWindowStartBeat = isLooping ? Math.max(startPosition, scheduleStartBeat) : startPosition;
|
const automationWindowStartBeat = isLooping ? Math.max(startPosition, scheduleStartBeat) : startPosition;
|
||||||
|
|
||||||
this.applyTrackAutomationAtBeat(track, automationWindowStartBeat);
|
this.applyTrackAutomationAtBeat(track, automationWindowStartBeat);
|
||||||
this.scheduleTrackAutomation(track, automationWindowStartBeat, scheduleEndBeat, interpolationIntervalMs, project.getBpm());
|
this.scheduleTrackAutomation(track, automationWindowStartBeat, scheduleEndBeat, interpolationIntervalMs, getEffectiveBpmAtBeat(project, automationWindowStartBeat));
|
||||||
|
|
||||||
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()}`);
|
||||||
|
|
||||||
@@ -622,7 +626,7 @@ export class KGAudioInterface {
|
|||||||
scheduleEndBeat,
|
scheduleEndBeat,
|
||||||
{
|
{
|
||||||
maxIntervalMs: interpolationIntervalMs,
|
maxIntervalMs: interpolationIntervalMs,
|
||||||
bpm: project.getBpm(),
|
bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
|
||||||
defaultValue: MIDI_PITCH_BEND_CENTER,
|
defaultValue: MIDI_PITCH_BEND_CENTER,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -648,7 +652,7 @@ export class KGAudioInterface {
|
|||||||
scheduleEndBeat,
|
scheduleEndBeat,
|
||||||
{
|
{
|
||||||
maxIntervalMs: interpolationIntervalMs,
|
maxIntervalMs: interpolationIntervalMs,
|
||||||
bpm: project.getBpm(),
|
bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
|
||||||
defaultValue: 127,
|
defaultValue: 127,
|
||||||
interpolationMode: 'linear',
|
interpolationMode: 'linear',
|
||||||
quantizeValue: clampMidiControllerValue,
|
quantizeValue: clampMidiControllerValue,
|
||||||
@@ -676,7 +680,7 @@ export class KGAudioInterface {
|
|||||||
scheduleEndBeat,
|
scheduleEndBeat,
|
||||||
{
|
{
|
||||||
maxIntervalMs: interpolationIntervalMs,
|
maxIntervalMs: interpolationIntervalMs,
|
||||||
bpm: project.getBpm(),
|
bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
interpolationMode: 'step',
|
interpolationMode: 'step',
|
||||||
quantizeValue: clampMidiControllerValue,
|
quantizeValue: clampMidiControllerValue,
|
||||||
@@ -699,20 +703,19 @@ export class KGAudioInterface {
|
|||||||
});
|
});
|
||||||
|
|
||||||
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
|
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
|
||||||
const noteDurationBeats = absoluteEndBeat - absoluteStartBeat;
|
|
||||||
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
|
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
|
||||||
const noteDuration = this.beatsToToneTime(noteDurationBeats);
|
const noteDurationSeconds = beatRangeToSeconds(project, absoluteStartBeat, absoluteEndBeat);
|
||||||
const velocity = note.getVelocity() / 127;
|
const velocity = note.getVelocity() / 127;
|
||||||
const noteName = pitchToNoteNameString(note.getPitch());
|
const noteName = pitchToNoteNameString(note.getPitch());
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Scheduling note ${noteName} at beat ${Number(absoluteStartBeat.toFixed ? absoluteStartBeat.toFixed(3) : absoluteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
|
`Scheduling note ${noteName} at beat ${Number(absoluteStartBeat.toFixed ? absoluteStartBeat.toFixed(3) : absoluteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDurationSeconds).toFixed(3))}, delay: ${playbackDelay}s`
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventId = Tone.Transport.schedule((time) => {
|
const eventId = Tone.Transport.schedule((time) => {
|
||||||
const hasSoloedTracks = this.hasSoloedTracks();
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||||
audioBus.triggerPitchBendAwareAttack(note.getPitch(), time + playbackDelay, velocity, Tone.Time(noteDuration).toSeconds());
|
audioBus.triggerPitchBendAwareAttack(note.getPitch(), time + playbackDelay, velocity, noteDurationSeconds);
|
||||||
}
|
}
|
||||||
}, noteStartTime);
|
}, noteStartTime);
|
||||||
|
|
||||||
@@ -740,17 +743,15 @@ export class KGAudioInterface {
|
|||||||
// Skip regions that start before playback start position
|
// Skip regions that start before playback start position
|
||||||
if (regionStartBeat < startPosition) {
|
if (regionStartBeat < startPosition) {
|
||||||
// Region starts before playhead — calculate offset into the audio file
|
// Region starts before playhead — calculate offset into the audio file
|
||||||
const offsetBeats = startPosition - regionStartBeat;
|
const offsetSeconds = beatRangeToSeconds(project, regionStartBeat, startPosition);
|
||||||
const offsetSeconds = offsetBeats * secondsPerBeat;
|
const remainingSeconds = beatRangeToSeconds(project, startPosition, regionEndBeat);
|
||||||
const remainingBeats = regionEndBeat - startPosition;
|
|
||||||
const remainingSeconds = remainingBeats * secondsPerBeat;
|
|
||||||
const audioFileId = audioRegion.getAudioFileId();
|
const audioFileId = audioRegion.getAudioFileId();
|
||||||
|
|
||||||
// Cap duration at loop boundary to prevent overlap on loop re-trigger
|
// Cap duration at loop boundary to prevent overlap on loop re-trigger
|
||||||
let effectiveRemainingSeconds = remainingSeconds;
|
let effectiveRemainingSeconds = remainingSeconds;
|
||||||
if (isLooping) {
|
if (isLooping) {
|
||||||
const maxDurationBeats = scheduleEndBeat - startPosition;
|
const maxDurationBeats = scheduleEndBeat - startPosition;
|
||||||
const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
|
const maxDurationSeconds = beatRangeToSeconds(project, startPosition, startPosition + maxDurationBeats);
|
||||||
effectiveRemainingSeconds = Math.min(remainingSeconds, maxDurationSeconds);
|
effectiveRemainingSeconds = Math.min(remainingSeconds, maxDurationSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,7 +770,7 @@ export class KGAudioInterface {
|
|||||||
startPosition + resumeSafetyOffsetBeats,
|
startPosition + resumeSafetyOffsetBeats,
|
||||||
regionEndBeat
|
regionEndBeat
|
||||||
);
|
);
|
||||||
const extraOffsetSeconds = (safeResumeBeat - startPosition) * secondsPerBeat;
|
const extraOffsetSeconds = beatRangeToSeconds(project, startPosition, safeResumeBeat);
|
||||||
const adjustedOffsetSeconds = clipStartOffsetSeconds + offsetSeconds + extraOffsetSeconds;
|
const adjustedOffsetSeconds = clipStartOffsetSeconds + offsetSeconds + extraOffsetSeconds;
|
||||||
const adjustedRemainingSeconds = Math.max(
|
const adjustedRemainingSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -800,7 +801,7 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
const audioFileId = audioRegion.getAudioFileId();
|
const audioFileId = audioRegion.getAudioFileId();
|
||||||
// Effective duration: region length in seconds, capped at available audio after clip offset
|
// Effective duration: region length in seconds, capped at available audio after clip offset
|
||||||
const regionLengthSeconds = region.getLength() * secondsPerBeat;
|
const regionLengthSeconds = beatRangeToSeconds(project, regionStartBeat, regionEndBeat);
|
||||||
let effectiveDurationSeconds = Math.min(
|
let effectiveDurationSeconds = Math.min(
|
||||||
regionLengthSeconds,
|
regionLengthSeconds,
|
||||||
audioDurationSeconds - clipStartOffsetSeconds
|
audioDurationSeconds - clipStartOffsetSeconds
|
||||||
@@ -814,7 +815,7 @@ export class KGAudioInterface {
|
|||||||
// Cap duration at loop boundary to prevent overlap on loop re-trigger
|
// Cap duration at loop boundary to prevent overlap on loop re-trigger
|
||||||
if (isLooping) {
|
if (isLooping) {
|
||||||
const maxDurationBeats = scheduleEndBeat - regionStartBeat;
|
const maxDurationBeats = scheduleEndBeat - regionStartBeat;
|
||||||
const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
|
const maxDurationSeconds = beatRangeToSeconds(project, regionStartBeat, regionStartBeat + maxDurationBeats);
|
||||||
effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds);
|
effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1176,8 +1177,12 @@ export class KGAudioInterface {
|
|||||||
return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
|
return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = Tone.Transport.position;
|
const transportSeconds = Number(Tone.Transport.seconds);
|
||||||
return this.toneTimeToBeats(position);
|
if (Number.isFinite(transportSeconds)) {
|
||||||
|
return secondsToBeat(KGCore.instance().getCurrentProject(), transportSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toneTimeToBeats(Tone.Transport.position);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting transport position:', error);
|
console.error('Error getting transport position:', error);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1472,6 +1477,30 @@ export class KGAudioInterface {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleTempoChanges(project: KGProject, windowStartBeat: number, windowEndBeat: number): void {
|
||||||
|
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempoRegions = getSortedTempoRegions(tempoTrack, project.getTimeSignature().numerator);
|
||||||
|
if (tempoRegions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tempoRegions.forEach((region) => {
|
||||||
|
const regionStartBeat = region.getStartBar() * project.getTimeSignature().numerator;
|
||||||
|
if (regionStartBeat <= windowStartBeat || regionStartBeat >= windowEndBeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = Tone.Transport.schedule(() => {
|
||||||
|
Tone.Transport.bpm.value = region.getBpm();
|
||||||
|
}, this.beatsToToneTime(regionStartBeat));
|
||||||
|
this.scheduledEvents.add(eventId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private clearTrackAutomationOverrides(): void {
|
private clearTrackAutomationOverrides(): void {
|
||||||
this.trackAudioBuses.forEach(audioBus => {
|
this.trackAudioBuses.forEach(audioBus => {
|
||||||
audioBus.setAutomationVolume(null);
|
audioBus.setAutomationVolume(null);
|
||||||
@@ -1527,35 +1556,15 @@ export class KGAudioInterface {
|
|||||||
* This approach handles triplets and all subdivisions correctly
|
* This approach handles triplets and all subdivisions correctly
|
||||||
*/
|
*/
|
||||||
private beatsToToneTime(beats: number): Tone.Unit.Time {
|
private beatsToToneTime(beats: number): Tone.Unit.Time {
|
||||||
const project = KGCore.instance().getCurrentProject();
|
return beatToSeconds(KGCore.instance().getCurrentProject(), beats) as Tone.Unit.Time;
|
||||||
const bpm = project.getBpm();
|
|
||||||
|
|
||||||
// Calculate seconds per beat - BPM is always quarter note beats per minute
|
|
||||||
// Time signature denominator doesn't affect BPM, only subdivision
|
|
||||||
const secondsPerBeat = 60 / bpm;
|
|
||||||
|
|
||||||
// Convert beats directly to seconds
|
|
||||||
const totalSeconds = beats * secondsPerBeat;
|
|
||||||
|
|
||||||
return totalSeconds as Tone.Unit.Time;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Tone.js time format to beats
|
* Convert Tone.js time format to beats
|
||||||
*/
|
*/
|
||||||
private toneTimeToBeats(toneTime: Tone.Unit.Time): number {
|
private toneTimeToBeats(toneTime: Tone.Unit.Time): number {
|
||||||
const project = KGCore.instance().getCurrentProject();
|
|
||||||
const bpm = project.getBpm();
|
|
||||||
|
|
||||||
// Calculate seconds per beat - BPM is always quarter note beats per minute
|
|
||||||
// Time signature denominator doesn't affect BPM, only subdivision
|
|
||||||
const secondsPerBeat = 60 / bpm;
|
|
||||||
|
|
||||||
// Tone.Time() can handle both numbers and strings
|
|
||||||
const seconds = Tone.Time(toneTime).toSeconds();
|
const seconds = Tone.Time(toneTime).toSeconds();
|
||||||
const beats = seconds / secondsPerBeat;
|
return secondsToBeat(KGCore.instance().getCurrentProject(), seconds);
|
||||||
|
|
||||||
return beats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { KGToneBuffersPool } from './KGToneBuffersPool';
|
|||||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||||
import { KGAudioInterface } from './KGAudioInterface';
|
import { KGAudioInterface } from './KGAudioInterface';
|
||||||
import { KGAudioBus } from './KGAudioBus';
|
import { KGAudioBus } from './KGAudioBus';
|
||||||
|
import { beatRangeToSeconds, beatToSeconds, findGlobalTrackByType, getEffectiveBpmAtBeat, getSortedTempoRegions } from '../../util/globalTrackUtil';
|
||||||
|
import { GlobalTrackType } from '../global-track';
|
||||||
import { ConfigManager } from '../config/ConfigManager';
|
import { ConfigManager } from '../config/ConfigManager';
|
||||||
import { Mp3Encoder } from '@breezystack/lamejs';
|
import { Mp3Encoder } from '@breezystack/lamejs';
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ export class KGOfflineRenderer {
|
|||||||
const tailSeconds = options?.tailSeconds ?? 2;
|
const tailSeconds = options?.tailSeconds ?? 2;
|
||||||
|
|
||||||
// Calculate render duration in seconds
|
// Calculate render duration in seconds
|
||||||
const bpm = project.getBpm();
|
const bpm = getEffectiveBpmAtBeat(project, 0);
|
||||||
const secondsPerBeat = 60 / bpm;
|
const secondsPerBeat = 60 / bpm;
|
||||||
const timeSignature = project.getTimeSignature();
|
const timeSignature = project.getTimeSignature();
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
@@ -279,7 +281,7 @@ export class KGOfflineRenderer {
|
|||||||
// else: no content found, keep the full project range as fallback
|
// else: no content found, keep the full project range as fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationSeconds = (renderEndBeat - renderStartBeat) * secondsPerBeat + tailSeconds;
|
const durationSeconds = beatRangeToSeconds(project, renderStartBeat, renderEndBeat) + tailSeconds;
|
||||||
|
|
||||||
console.log(`Offline render: ${durationSeconds}s (beats ${renderStartBeat}-${renderEndBeat}), ${sampleRate}Hz, ${channels}ch`);
|
console.log(`Offline render: ${durationSeconds}s (beats ${renderStartBeat}-${renderEndBeat}), ${sampleRate}Hz, ${channels}ch`);
|
||||||
|
|
||||||
@@ -289,8 +291,20 @@ export class KGOfflineRenderer {
|
|||||||
const masterGain = new Tone.Gain(1).toDestination();
|
const masterGain = new Tone.Gain(1).toDestination();
|
||||||
|
|
||||||
// Set BPM and time signature on offline transport
|
// Set BPM and time signature on offline transport
|
||||||
context.transport.bpm.value = bpm;
|
context.transport.bpm.value = getEffectiveBpmAtBeat(project, renderStartBeat);
|
||||||
context.transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
context.transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
||||||
|
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
const tempoRegions = tempoTrack ? getSortedTempoRegions(tempoTrack, timeSignature.numerator) : [];
|
||||||
|
tempoRegions.forEach((region) => {
|
||||||
|
const regionStartBeat = region.getStartBar() * timeSignature.numerator;
|
||||||
|
if (regionStartBeat <= renderStartBeat || regionStartBeat >= renderEndBeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.transport.schedule((time) => {
|
||||||
|
context.transport.bpm.setValueAtTime(region.getBpm(), time);
|
||||||
|
}, beatToSeconds(project, regionStartBeat) - beatToSeconds(project, renderStartBeat));
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Create MIDI track samplers ----
|
// ---- Create MIDI track samplers ----
|
||||||
const samplerPromises: Promise<void>[] = [];
|
const samplerPromises: Promise<void>[] = [];
|
||||||
@@ -343,7 +357,7 @@ export class KGOfflineRenderer {
|
|||||||
renderEndBeat,
|
renderEndBeat,
|
||||||
{
|
{
|
||||||
maxIntervalMs: interpolationIntervalMs,
|
maxIntervalMs: interpolationIntervalMs,
|
||||||
bpm: project.getBpm(),
|
bpm: getEffectiveBpmAtBeat(project, renderStartBeat),
|
||||||
defaultValue: MIDI_PITCH_BEND_CENTER,
|
defaultValue: MIDI_PITCH_BEND_CENTER,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -353,7 +367,7 @@ export class KGOfflineRenderer {
|
|||||||
renderEndBeat,
|
renderEndBeat,
|
||||||
{
|
{
|
||||||
maxIntervalMs: interpolationIntervalMs,
|
maxIntervalMs: interpolationIntervalMs,
|
||||||
bpm: project.getBpm(),
|
bpm: getEffectiveBpmAtBeat(project, renderStartBeat),
|
||||||
defaultValue: 127,
|
defaultValue: 127,
|
||||||
interpolationMode: 'linear',
|
interpolationMode: 'linear',
|
||||||
quantizeValue: clampMidiControllerValue,
|
quantizeValue: clampMidiControllerValue,
|
||||||
@@ -366,14 +380,13 @@ export class KGOfflineRenderer {
|
|||||||
// Skip notes outside render range
|
// Skip notes outside render range
|
||||||
if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue;
|
if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue;
|
||||||
|
|
||||||
const offsetBeat = note.startBeat - renderStartBeat;
|
const noteStartTime = beatToSeconds(project, note.startBeat) - beatToSeconds(project, renderStartBeat);
|
||||||
const noteStartTime = offsetBeat * secondsPerBeat;
|
|
||||||
const sustainedEndBeat = resolveSustainExtendedEndBeat(
|
const sustainedEndBeat = resolveSustainExtendedEndBeat(
|
||||||
trackInfo.controllerEventsByType[64],
|
trackInfo.controllerEventsByType[64],
|
||||||
note.endBeat,
|
note.endBeat,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
const noteDuration = Math.max(0, sustainedEndBeat - note.startBeat) * secondsPerBeat;
|
const noteDuration = beatRangeToSeconds(project, note.startBeat, sustainedEndBeat);
|
||||||
const velocity = note.velocity / 127;
|
const velocity = note.velocity / 127;
|
||||||
const initialNormalizedPitchBend = midiPitchBendToNormalized(
|
const initialNormalizedPitchBend = midiPitchBendToNormalized(
|
||||||
resolveMidiAutomationValueAtBeat(trackInfo.pitchBends, note.startBeat, MIDI_PITCH_BEND_CENTER)
|
resolveMidiAutomationValueAtBeat(trackInfo.pitchBends, note.startBeat, MIDI_PITCH_BEND_CENTER)
|
||||||
@@ -451,13 +464,12 @@ export class KGOfflineRenderer {
|
|||||||
|
|
||||||
const clipStartOffsetSeconds = regionInfo.clipStartOffsetSeconds;
|
const clipStartOffsetSeconds = regionInfo.clipStartOffsetSeconds;
|
||||||
const audioDurationSeconds = regionInfo.audioDurationSeconds;
|
const audioDurationSeconds = regionInfo.audioDurationSeconds;
|
||||||
const regionLengthSeconds = regionInfo.lengthBeats * secondsPerBeat;
|
const regionLengthSeconds = beatRangeToSeconds(project, regionStartBeat, regionEndBeat);
|
||||||
const effectiveDurationSeconds = Math.min(regionLengthSeconds, audioDurationSeconds - clipStartOffsetSeconds);
|
const effectiveDurationSeconds = Math.min(regionLengthSeconds, audioDurationSeconds - clipStartOffsetSeconds);
|
||||||
|
|
||||||
if (effectiveDurationSeconds <= 0) continue;
|
if (effectiveDurationSeconds <= 0) continue;
|
||||||
|
|
||||||
const offsetBeat = regionStartBeat - renderStartBeat;
|
const regionStartTime = Math.max(0, beatToSeconds(project, regionStartBeat) - beatToSeconds(project, renderStartBeat));
|
||||||
const regionStartTime = Math.max(0, offsetBeat * secondsPerBeat);
|
|
||||||
|
|
||||||
// Create buffer source NOW while the offline context is still active.
|
// Create buffer source NOW while the offline context is still active.
|
||||||
// Schedule callbacks fire during rendering after Tone.js restores the
|
// Schedule callbacks fire during rendering after Tone.js restores the
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||||
|
import { generateUniqueId } from '../../../util/miscUtil';
|
||||||
|
import {
|
||||||
|
cloneTempoRegions,
|
||||||
|
findGlobalTrackByType,
|
||||||
|
findTempoRegionAtBar,
|
||||||
|
getEffectiveBpmAtBar,
|
||||||
|
getSongEndBar,
|
||||||
|
getSortedTempoRegions,
|
||||||
|
} from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export class CreateTempoRegionCommand extends KGCommand {
|
||||||
|
private readonly startBar: number;
|
||||||
|
private readonly regionId: string;
|
||||||
|
private createdRegion: KGTempoRegion | null = null;
|
||||||
|
private previousRegions: KGTempoRegion[] = [];
|
||||||
|
|
||||||
|
constructor(startBar: number, regionId?: string) {
|
||||||
|
super();
|
||||||
|
this.startBar = startBar;
|
||||||
|
this.regionId = regionId ?? generateUniqueId('KGTempoRegion');
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRegions = getSortedTempoRegions(track, beatsPerBar);
|
||||||
|
this.previousRegions = cloneTempoRegions(existingRegions, beatsPerBar);
|
||||||
|
|
||||||
|
const songEndBar = getSongEndBar(project);
|
||||||
|
const clampedStartBar = Math.max(0, Math.min(this.startBar, Math.max(0, songEndBar - 1)));
|
||||||
|
|
||||||
|
if (existingRegions.length === 0) {
|
||||||
|
this.createdRegion = new KGTempoRegion(
|
||||||
|
this.regionId,
|
||||||
|
track.getId(),
|
||||||
|
track.getTrackIndex(),
|
||||||
|
getEffectiveBpmAtBar(project, clampedStartBar),
|
||||||
|
0,
|
||||||
|
Math.max(1, songEndBar),
|
||||||
|
beatsPerBar
|
||||||
|
);
|
||||||
|
track.setRegions([this.createdRegion]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containingRegion = findTempoRegionAtBar(project, clampedStartBar);
|
||||||
|
if (!containingRegion) {
|
||||||
|
throw new Error(`No tempo region covers bar ${clampedStartBar}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionStartBar = containingRegion.getStartBar();
|
||||||
|
const regionEndBar = containingRegion.getEndBar();
|
||||||
|
if (clampedStartBar <= regionStartBar || clampedStartBar >= regionEndBar) {
|
||||||
|
throw new Error(`Bar ${clampedStartBar} is not a valid split point`);
|
||||||
|
}
|
||||||
|
|
||||||
|
containingRegion.setLengthBars(clampedStartBar - regionStartBar, beatsPerBar);
|
||||||
|
this.createdRegion = new KGTempoRegion(
|
||||||
|
this.regionId,
|
||||||
|
track.getId(),
|
||||||
|
track.getTrackIndex(),
|
||||||
|
containingRegion.getBpm(),
|
||||||
|
clampedStartBar,
|
||||||
|
regionEndBar - clampedStartBar,
|
||||||
|
beatsPerBar
|
||||||
|
);
|
||||||
|
track.setRegions([...existingRegions, this.createdRegion].sort((left, right) => left.getStartBar() - right.getStartBar()));
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found during undo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
track.setRegions(cloneTempoRegions(this.previousRegions, beatsPerBar));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Create tempo change at bar ${this.startBar + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCreatedRegion(): KGTempoRegion | null {
|
||||||
|
return this.createdRegion;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||||
|
import {
|
||||||
|
cloneTempoRegions,
|
||||||
|
findGlobalTrackByType,
|
||||||
|
getSortedTempoRegions,
|
||||||
|
} from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export class DeleteTempoRegionCommand extends KGCommand {
|
||||||
|
private readonly regionId: string;
|
||||||
|
private previousRegions: KGTempoRegion[] = [];
|
||||||
|
private deletedBpm = '';
|
||||||
|
|
||||||
|
constructor(regionId: string) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = getSortedTempoRegions(track, beatsPerBar);
|
||||||
|
this.previousRegions = cloneTempoRegions(regions, beatsPerBar);
|
||||||
|
|
||||||
|
const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
throw new Error(`Tempo region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRegion = regions[targetIndex];
|
||||||
|
this.deletedBpm = `${targetRegion.getBpm()} BPM`;
|
||||||
|
|
||||||
|
if (regions.length === 1) {
|
||||||
|
track.setRegions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRegions = [...regions];
|
||||||
|
const deletedLengthBars = targetRegion.getLengthBars();
|
||||||
|
|
||||||
|
if (targetIndex === 0) {
|
||||||
|
const nextRegion = nextRegions[1];
|
||||||
|
nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
|
||||||
|
nextRegions.splice(0, 1);
|
||||||
|
track.setRegions(nextRegions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRegion = nextRegions[targetIndex - 1];
|
||||||
|
previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
|
||||||
|
nextRegions.splice(targetIndex, 1);
|
||||||
|
track.setRegions(nextRegions);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found during undo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
track.setRegions(cloneTempoRegions(this.previousRegions, beatsPerBar));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Delete tempo "${this.deletedBpm || this.regionId}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteMultipleTempoRegionsCommand extends KGCommand {
|
||||||
|
private readonly regionIds: string[];
|
||||||
|
private previousRegions: KGTempoRegion[] = [];
|
||||||
|
|
||||||
|
constructor(regionIds: string[]) {
|
||||||
|
super();
|
||||||
|
this.regionIds = regionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = getSortedTempoRegions(track, beatsPerBar);
|
||||||
|
this.previousRegions = cloneTempoRegions(regions, beatsPerBar);
|
||||||
|
|
||||||
|
let workingRegions = cloneTempoRegions(regions, beatsPerBar);
|
||||||
|
|
||||||
|
for (const regionId of this.regionIds) {
|
||||||
|
const targetIndex = workingRegions.findIndex(region => region.getId() === regionId);
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedRegion = workingRegions[targetIndex];
|
||||||
|
const deletedLengthBars = deletedRegion.getLengthBars();
|
||||||
|
|
||||||
|
if (workingRegions.length === 1) {
|
||||||
|
workingRegions = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex === 0) {
|
||||||
|
const nextRegion = workingRegions[1];
|
||||||
|
nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
|
||||||
|
workingRegions.splice(0, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRegion = workingRegions[targetIndex - 1];
|
||||||
|
previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
|
||||||
|
workingRegions.splice(targetIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
track.setRegions(workingRegions);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found during undo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
track.setRegions(cloneTempoRegions(this.previousRegions, beatsPerBar));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return this.regionIds.length === 1 ? 'Delete tempo change' : `Delete ${this.regionIds.length} tempo changes`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGProject } from '../../KGProject';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||||
|
import { CreateTempoRegionCommand } from './CreateTempoRegionCommand';
|
||||||
|
import { DeleteTempoRegionCommand } from './DeleteTempoRegionCommand';
|
||||||
|
import { ResizeTempoRegionCommand } from './ResizeTempoRegionCommand';
|
||||||
|
import { UpdateTempoRegionCommand } from './UpdateTempoRegionCommand';
|
||||||
|
|
||||||
|
describe('global tempo region commands', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const project = new KGProject('Tempo', 8, 0, 120);
|
||||||
|
const mockCore = KGCore.instance() as unknown as {
|
||||||
|
getCurrentProject: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
mockCore.getCurrentProject.mockReturnValue(project);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTempoTrack = () => {
|
||||||
|
const tempoTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
|
||||||
|
.find(track => track.getType() === GlobalTrackType.Tempo);
|
||||||
|
|
||||||
|
if (!tempoTrack) {
|
||||||
|
throw new Error('Tempo track missing in test setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempoTrack;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates the first explicit region as full-song coverage', () => {
|
||||||
|
const command = new CreateTempoRegionCommand(3);
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
const tempoTrack = getTempoTrack();
|
||||||
|
const regions = tempoTrack.getRegions() as KGTempoRegion[];
|
||||||
|
|
||||||
|
expect(regions).toHaveLength(1);
|
||||||
|
expect(regions[0].getStartBar()).toBe(0);
|
||||||
|
expect(regions[0].getLengthBars()).toBe(8);
|
||||||
|
expect(regions[0].getBpm()).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates additional regions by splitting the covered span and inheriting BPM', () => {
|
||||||
|
const tempoTrack = getTempoTrack();
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('left', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 0, 8, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const command = new CreateTempoRegionCommand(5);
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
const regions = tempoTrack.getRegions() as KGTempoRegion[];
|
||||||
|
expect(regions).toHaveLength(2);
|
||||||
|
expect(regions[0].getLengthBars()).toBe(5);
|
||||||
|
expect(regions[1].getStartBar()).toBe(5);
|
||||||
|
expect(regions[1].getLengthBars()).toBe(3);
|
||||||
|
expect(regions[1].getBpm()).toBe(128);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resizes a shared boundary and keeps the track gapless', () => {
|
||||||
|
const tempoTrack = getTempoTrack();
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('left', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 4, 4),
|
||||||
|
new KGTempoRegion('right', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 4, 4, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const command = new ResizeTempoRegionCommand('left', 'end', 6);
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
const regions = tempoTrack.getRegions() as KGTempoRegion[];
|
||||||
|
expect(regions[0].getLengthBars()).toBe(6);
|
||||||
|
expect(regions[1].getStartBar()).toBe(6);
|
||||||
|
expect(regions[1].getLengthBars()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a middle region by extending the previous region', () => {
|
||||||
|
const tempoTrack = getTempoTrack();
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('first', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 2, 4),
|
||||||
|
new KGTempoRegion('middle', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 2, 3, 4),
|
||||||
|
new KGTempoRegion('last', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 5, 3, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const command = new DeleteTempoRegionCommand('middle');
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
const regions = tempoTrack.getRegions() as KGTempoRegion[];
|
||||||
|
expect(regions).toHaveLength(2);
|
||||||
|
expect(regions[0].getLengthBars()).toBe(5);
|
||||||
|
expect(regions[1].getStartBar()).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows deleting the last remaining region', () => {
|
||||||
|
const tempoTrack = getTempoTrack();
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('only', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const command = new DeleteTempoRegionCommand('only');
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
expect(tempoTrack.getRegions()).toHaveLength(0);
|
||||||
|
command.undo();
|
||||||
|
expect(tempoTrack.getRegions()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the region tempo with undo support', () => {
|
||||||
|
const tempoTrack = getTempoTrack();
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('region', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const command = new UpdateTempoRegionCommand('region', 150);
|
||||||
|
command.execute();
|
||||||
|
expect((tempoTrack.getRegions()[0] as KGTempoRegion).getBpm()).toBe(150);
|
||||||
|
command.undo();
|
||||||
|
expect((tempoTrack.getRegions()[0] as KGTempoRegion).getBpm()).toBe(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||||
|
import type { GlobalRegionResizeEdge } from './ResizeGlobalRegionCommand';
|
||||||
|
import {
|
||||||
|
cloneTempoRegions,
|
||||||
|
findGlobalTrackByType,
|
||||||
|
getSortedTempoRegions,
|
||||||
|
} from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export class ResizeTempoRegionCommand extends KGCommand {
|
||||||
|
private readonly regionId: string;
|
||||||
|
private readonly edge: GlobalRegionResizeEdge;
|
||||||
|
private readonly desiredBar: number;
|
||||||
|
private previousRegions: KGTempoRegion[] = [];
|
||||||
|
|
||||||
|
constructor(regionId: string, edge: GlobalRegionResizeEdge, desiredBar: number) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
this.edge = edge;
|
||||||
|
this.desiredBar = desiredBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = getSortedTempoRegions(track, beatsPerBar);
|
||||||
|
this.previousRegions = cloneTempoRegions(regions, beatsPerBar);
|
||||||
|
|
||||||
|
const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
throw new Error(`Tempo region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRegion = regions[targetIndex];
|
||||||
|
if (this.edge === 'start') {
|
||||||
|
if (targetIndex === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRegion = regions[targetIndex - 1];
|
||||||
|
const targetEndBar = targetRegion.getEndBar();
|
||||||
|
const clampedBoundaryBar = Math.max(
|
||||||
|
previousRegion.getStartBar() + 1,
|
||||||
|
Math.min(this.desiredBar, targetEndBar - 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
previousRegion.setLengthBars(clampedBoundaryBar - previousRegion.getStartBar(), beatsPerBar);
|
||||||
|
targetRegion.setBarRange(clampedBoundaryBar, targetEndBar - clampedBoundaryBar, beatsPerBar);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex === regions.length - 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRegion = regions[targetIndex + 1];
|
||||||
|
const nextRegionEndBar = nextRegion.getEndBar();
|
||||||
|
const clampedBoundaryBar = Math.max(
|
||||||
|
targetRegion.getStartBar() + 1,
|
||||||
|
Math.min(this.desiredBar, nextRegionEndBar - 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
targetRegion.setLengthBars(clampedBoundaryBar - targetRegion.getStartBar(), beatsPerBar);
|
||||||
|
nextRegion.setBarRange(clampedBoundaryBar, nextRegionEndBar - clampedBoundaryBar, beatsPerBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found during undo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
track.setRegions(cloneTempoRegions(this.previousRegions, beatsPerBar));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Resize tempo boundary for "${this.regionId}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||||
|
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export class UpdateTempoRegionCommand extends KGCommand {
|
||||||
|
private readonly regionId: string;
|
||||||
|
private readonly nextBpm: number;
|
||||||
|
private previousBpm: number | null = null;
|
||||||
|
private targetRegion: KGTempoRegion | null = null;
|
||||||
|
|
||||||
|
constructor(regionId: string, nextBpm: number) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
this.nextBpm = nextBpm;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error('Tempo global track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = track.getRegions().find(candidate => candidate.getId() === this.regionId);
|
||||||
|
if (!(region instanceof KGTempoRegion)) {
|
||||||
|
throw new Error(`Tempo region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetRegion = region;
|
||||||
|
this.previousBpm = region.getBpm();
|
||||||
|
region.setBpm(this.nextBpm);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.targetRegion || this.previousBpm === null) {
|
||||||
|
throw new Error('Cannot undo tempo update without previous state');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetRegion.setBpm(this.previousBpm);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Change tempo to "${this.nextBpm} BPM"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,13 +37,17 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
|
|||||||
// Global region commands
|
// Global region commands
|
||||||
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
|
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
|
||||||
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
|
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
|
||||||
|
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
|
||||||
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
|
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
|
||||||
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
|
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
|
||||||
export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignatureRegionCommand';
|
export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignatureRegionCommand';
|
||||||
|
export { ResizeTempoRegionCommand } from './global-region/ResizeTempoRegionCommand';
|
||||||
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
|
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
|
||||||
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
|
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
|
||||||
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
|
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
|
||||||
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
|
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
|
||||||
|
export { DeleteTempoRegionCommand, DeleteMultipleTempoRegionsCommand } from './global-region/DeleteTempoRegionCommand';
|
||||||
|
export { UpdateTempoRegionCommand } from './global-region/UpdateTempoRegionCommand';
|
||||||
|
|
||||||
// Note commands
|
// Note commands
|
||||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
|
|||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGProject, type KeySignature } from '../../KGProject';
|
import { KGProject, type KeySignature } from '../../KGProject';
|
||||||
import type { TimeSignature } from '../../../types/projectTypes';
|
import type { TimeSignature } from '../../../types/projectTypes';
|
||||||
|
import { normalizeTempoRegionsForProject } from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining properties that can be updated on a project
|
* Interface defining properties that can be updated on a project
|
||||||
@@ -59,6 +60,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
// Update maxBars
|
// Update maxBars
|
||||||
if (this.newProperties.maxBars !== undefined && this.newProperties.maxBars !== this.originalProperties.maxBars) {
|
if (this.newProperties.maxBars !== undefined && this.newProperties.maxBars !== this.originalProperties.maxBars) {
|
||||||
this.targetProject.setMaxBars(this.newProperties.maxBars);
|
this.targetProject.setMaxBars(this.newProperties.maxBars);
|
||||||
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
this.changedProperties.add('maxBars');
|
this.changedProperties.add('maxBars');
|
||||||
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${this.newProperties.maxBars}`);
|
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${this.newProperties.maxBars}`);
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
// Compare time signatures
|
// Compare time signatures
|
||||||
if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) {
|
if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) {
|
||||||
this.targetProject.setTimeSignature(newTS);
|
this.targetProject.setTimeSignature(newTS);
|
||||||
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
this.changedProperties.add('timeSignature');
|
this.changedProperties.add('timeSignature');
|
||||||
updatedProperties.push(`timeSignature: ${originalTS.numerator}/${originalTS.denominator} → ${newTS.numerator}/${newTS.denominator}`);
|
updatedProperties.push(`timeSignature: ${originalTS.numerator}/${originalTS.denominator} → ${newTS.numerator}/${newTS.denominator}`);
|
||||||
}
|
}
|
||||||
@@ -128,6 +131,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
// Restore maxBars (only if it was changed)
|
// Restore maxBars (only if it was changed)
|
||||||
if (this.changedProperties.has('maxBars') && this.originalProperties.maxBars !== undefined) {
|
if (this.changedProperties.has('maxBars') && this.originalProperties.maxBars !== undefined) {
|
||||||
this.targetProject.setMaxBars(this.originalProperties.maxBars);
|
this.targetProject.setMaxBars(this.originalProperties.maxBars);
|
||||||
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`);
|
restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +150,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
// Restore time signature (only if it was changed)
|
// Restore time signature (only if it was changed)
|
||||||
if (this.changedProperties.has('timeSignature') && this.originalProperties.timeSignature !== undefined) {
|
if (this.changedProperties.has('timeSignature') && this.originalProperties.timeSignature !== undefined) {
|
||||||
this.targetProject.setTimeSignature(this.originalProperties.timeSignature);
|
this.targetProject.setTimeSignature(this.originalProperties.timeSignature);
|
||||||
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
const ts = this.originalProperties.timeSignature;
|
const ts = this.originalProperties.timeSignature;
|
||||||
restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`);
|
restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Expose, Type } from 'class-transformer';
|
|||||||
import { KGGlobalRegion } from '../region/KGGlobalRegion';
|
import { KGGlobalRegion } from '../region/KGGlobalRegion';
|
||||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||||
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||||
|
import { KGTempoRegion } from '../region/KGTempoRegion';
|
||||||
|
|
||||||
export enum GlobalTrackType {
|
export enum GlobalTrackType {
|
||||||
Marker = 'marker',
|
Marker = 'marker',
|
||||||
@@ -33,6 +34,7 @@ export class KGGlobalTrack {
|
|||||||
subTypes: [
|
subTypes: [
|
||||||
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
|
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
|
||||||
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
|
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
|
||||||
|
{ value: KGTempoRegion, name: 'KGTempoRegion' },
|
||||||
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
|
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import { KGGlobalRegion } from './KGGlobalRegion';
|
||||||
|
|
||||||
|
export class KGTempoRegion extends KGGlobalRegion {
|
||||||
|
@Expose()
|
||||||
|
protected override __type: string = 'KGTempoRegion';
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
private bpm: number = 120;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
private startBar: number = 0;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
private lengthBars: number = 1;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
trackId: string,
|
||||||
|
trackIndex: number,
|
||||||
|
bpm: number,
|
||||||
|
startBar: number = 0,
|
||||||
|
lengthBars: number = 1,
|
||||||
|
beatsPerBar: number = 4
|
||||||
|
) {
|
||||||
|
super(id, trackId, trackIndex, `${bpm} BPM`, startBar * beatsPerBar, lengthBars * beatsPerBar);
|
||||||
|
this.__type = 'KGTempoRegion';
|
||||||
|
this.bpm = bpm;
|
||||||
|
this.startBar = startBar;
|
||||||
|
this.lengthBars = lengthBars;
|
||||||
|
this.syncBeatsFromBars(beatsPerBar);
|
||||||
|
super.setName(this.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBpm(): number {
|
||||||
|
return this.bpm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBpm(bpm: number): void {
|
||||||
|
this.bpm = bpm;
|
||||||
|
super.setName(this.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStartBar(): number {
|
||||||
|
return this.startBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLengthBars(): number {
|
||||||
|
return this.lengthBars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEndBar(): number {
|
||||||
|
return this.startBar + this.lengthBars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setStartBar(startBar: number, beatsPerBar: number): void {
|
||||||
|
this.startBar = startBar;
|
||||||
|
this.syncBeatsFromBars(beatsPerBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLengthBars(lengthBars: number, beatsPerBar: number): void {
|
||||||
|
this.lengthBars = lengthBars;
|
||||||
|
this.syncBeatsFromBars(beatsPerBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBarRange(startBar: number, lengthBars: number, beatsPerBar: number): void {
|
||||||
|
this.startBar = startBar;
|
||||||
|
this.lengthBars = lengthBars;
|
||||||
|
this.syncBeatsFromBars(beatsPerBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
public syncBeatsFromBars(beatsPerBar: number): void {
|
||||||
|
super.setStartFromBeat(this.startBar * beatsPerBar);
|
||||||
|
super.setLength(this.lengthBars * beatsPerBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
public syncBarsFromBeats(beatsPerBar: number): void {
|
||||||
|
this.startBar = Math.floor(this.getStartFromBeat() / beatsPerBar);
|
||||||
|
this.lengthBars = Math.max(1, Math.round(this.getLength() / beatsPerBar));
|
||||||
|
super.setName(this.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDisplayName(): string {
|
||||||
|
return `${this.bpm} BPM`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override getCurrentType(): string {
|
||||||
|
return 'KGTempoRegion';
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-13
@@ -27,6 +27,7 @@ import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationD
|
|||||||
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
||||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
||||||
import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
|
import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
|
||||||
|
import { beatToSeconds } from '../util/globalTrackUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update CSS custom property for time signature numerator
|
* Update CSS custom property for time signature numerator
|
||||||
@@ -54,6 +55,16 @@ function updateBarWidthMultiplierCSS(multiplier: number): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCurrentTime(project: KGProject, beat: number): string {
|
||||||
|
const seconds = beatToSeconds(project, beat);
|
||||||
|
const bpmForLegacyFormatting = seconds > 0 ? (beat / seconds) * 60 : project.getBpm();
|
||||||
|
return beatsToTimeString(beat, bpmForLegacyFormatting, project.getTimeSignature());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectGlobalTracks(project: KGProject): KGGlobalTrack[] {
|
||||||
|
return (project.getGlobalTracks?.() ?? []) as KGGlobalTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
type SidePanelType = 'kgone' | 'chat' | 'eventList';
|
type SidePanelType = 'kgone' | 'chat' | 'eventList';
|
||||||
|
|
||||||
function getSidePanelVisibilityState(activePanel: SidePanelType | null) {
|
function getSidePanelVisibilityState(activePanel: SidePanelType | null) {
|
||||||
@@ -106,6 +117,7 @@ interface ProjectState {
|
|||||||
activeTrackAutomationTrackId: string | null;
|
activeTrackAutomationTrackId: string | null;
|
||||||
activeTrackAutomationType: TrackAutomationType | null;
|
activeTrackAutomationType: TrackAutomationType | null;
|
||||||
trackAutomationRedrawVersion: number;
|
trackAutomationRedrawVersion: number;
|
||||||
|
audioWaveformRedrawVersion: number;
|
||||||
|
|
||||||
// ChatBox state
|
// ChatBox state
|
||||||
showChatBox: boolean;
|
showChatBox: boolean;
|
||||||
@@ -203,6 +215,7 @@ interface ProjectState {
|
|||||||
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
||||||
bumpAutomationRedrawVersion: () => void;
|
bumpAutomationRedrawVersion: () => void;
|
||||||
bumpTrackAutomationRedrawVersion: () => void;
|
bumpTrackAutomationRedrawVersion: () => void;
|
||||||
|
bumpAudioWaveformRedrawVersion: () => void;
|
||||||
|
|
||||||
// Project state cleanup
|
// Project state cleanup
|
||||||
cleanupProjectState: () => void;
|
cleanupProjectState: () => void;
|
||||||
@@ -318,9 +331,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Set up playhead update callback to keep store in sync during playback
|
// Set up playhead update callback to keep store in sync during playback
|
||||||
KGCore.instance().setPlayheadUpdateCallback((position: number) => {
|
KGCore.instance().setPlayheadUpdateCallback((position: number) => {
|
||||||
const { bpm, timeSignature } = get();
|
const { bpm, timeSignature } = get();
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set(state => ({
|
set(state => ({
|
||||||
playheadPosition: position,
|
playheadPosition: position,
|
||||||
currentTime: beatsToTimeString(position, bpm, timeSignature),
|
currentTime: formatCurrentTime(project, position),
|
||||||
recordingAudioPreviewCurrentBeat: state.recordingMode === 'audio'
|
recordingAudioPreviewCurrentBeat: state.recordingMode === 'audio'
|
||||||
? Math.max(state.recordingCommitStartBeatAbsolute, position)
|
? Math.max(state.recordingCommitStartBeatAbsolute, position)
|
||||||
: state.recordingAudioPreviewCurrentBeat,
|
: state.recordingAudioPreviewCurrentBeat,
|
||||||
@@ -407,7 +421,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
projectName: currentProject.getName(),
|
projectName: currentProject.getName(),
|
||||||
savedProjectName: currentProject.getName(),
|
savedProjectName: currentProject.getName(),
|
||||||
tracks: currentProject.getTracks() as KGTrack[],
|
tracks: currentProject.getTracks() as KGTrack[],
|
||||||
globalTracks: currentProject.getGlobalTracks() as KGGlobalTrack[],
|
globalTracks: getProjectGlobalTracks(currentProject),
|
||||||
currentStatus: KGCore.instance().getStatus() || 'Unknown',
|
currentStatus: KGCore.instance().getStatus() || 'Unknown',
|
||||||
maxBars: currentProject.getMaxBars(),
|
maxBars: currentProject.getMaxBars(),
|
||||||
barWidthMultiplier: currentProject.getBarWidthMultiplier(),
|
barWidthMultiplier: currentProject.getBarWidthMultiplier(),
|
||||||
@@ -422,7 +436,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
isPlaying: KGCore.instance().getIsPlaying(),
|
isPlaying: KGCore.instance().getIsPlaying(),
|
||||||
isPreparingPlayback: false,
|
isPreparingPlayback: false,
|
||||||
autoScrollEnabled: true,
|
autoScrollEnabled: true,
|
||||||
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
|
currentTime: formatCurrentTime(currentProject, KGCore.instance().getPlayheadPosition()),
|
||||||
|
|
||||||
// Initial selection state
|
// Initial selection state
|
||||||
selectedNoteIds: [],
|
selectedNoteIds: [],
|
||||||
@@ -443,6 +457,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
activeTrackAutomationTrackId: null,
|
activeTrackAutomationTrackId: null,
|
||||||
activeTrackAutomationType: null,
|
activeTrackAutomationType: null,
|
||||||
trackAutomationRedrawVersion: 0,
|
trackAutomationRedrawVersion: 0,
|
||||||
|
audioWaveformRedrawVersion: 0,
|
||||||
|
|
||||||
// Initial ChatBox state
|
// Initial ChatBox state
|
||||||
showChatBox: initialChatBoxState,
|
showChatBox: initialChatBoxState,
|
||||||
@@ -522,7 +537,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set({
|
set({
|
||||||
tracks: [...project.getTracks()] as KGTrack[],
|
tracks: [...project.getTracks()] as KGTrack[],
|
||||||
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
globalTracks: [...getProjectGlobalTracks(project)],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-select the newly created track and open instrument selection panel
|
// Auto-select the newly created track and open instrument selection panel
|
||||||
@@ -547,7 +562,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set({
|
set({
|
||||||
tracks: [...project.getTracks()] as KGTrack[],
|
tracks: [...project.getTracks()] as KGTrack[],
|
||||||
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
globalTracks: [...getProjectGlobalTracks(project)],
|
||||||
});
|
});
|
||||||
|
|
||||||
const newTrackId = command.getTrackId().toString();
|
const newTrackId = command.getTrackId().toString();
|
||||||
@@ -670,7 +685,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const remainingTracks = [...project.getTracks()] as KGTrack[];
|
const remainingTracks = [...project.getTracks()] as KGTrack[];
|
||||||
set({
|
set({
|
||||||
tracks: remainingTracks,
|
tracks: remainingTracks,
|
||||||
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
globalTracks: [...getProjectGlobalTracks(project)],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-select another track if any remain
|
// Auto-select another track if any remain
|
||||||
@@ -758,7 +773,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set({
|
set({
|
||||||
tracks: [...project.getTracks()] as KGTrack[],
|
tracks: [...project.getTracks()] as KGTrack[],
|
||||||
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
globalTracks: [...getProjectGlobalTracks(project)],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Updated track ${trackId} properties`);
|
console.log(`Updated track ${trackId} properties`);
|
||||||
@@ -790,7 +805,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set({
|
set({
|
||||||
tracks: [...project.getTracks()] as KGTrack[],
|
tracks: [...project.getTracks()] as KGTrack[],
|
||||||
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
globalTracks: [...getProjectGlobalTracks(project)],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Reordered track from index ${sourceIndex} to ${destinationIndex}`);
|
console.log(`Reordered track from index ${sourceIndex} to ${destinationIndex}`);
|
||||||
@@ -920,7 +935,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
projectName: projectToLoad.getName(),
|
projectName: projectToLoad.getName(),
|
||||||
savedProjectName: savedName ?? projectToLoad.getName(),
|
savedProjectName: savedName ?? projectToLoad.getName(),
|
||||||
tracks: [...tracks],
|
tracks: [...tracks],
|
||||||
globalTracks: [...projectToLoad.getGlobalTracks()] as KGGlobalTrack[],
|
globalTracks: [...getProjectGlobalTracks(projectToLoad)],
|
||||||
maxBars,
|
maxBars,
|
||||||
barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
|
barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
|
||||||
timeSignature,
|
timeSignature,
|
||||||
@@ -947,7 +962,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
recordingAudioPreviewCurrentBeat: 0,
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
recordingAudioPreviewFileName: null,
|
recordingAudioPreviewFileName: null,
|
||||||
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: formatCurrentTime(projectToLoad, 0) // Reset time display
|
||||||
});
|
});
|
||||||
|
|
||||||
// After loading a project, auto-select the first track and open Instrument Selection
|
// After loading a project, auto-select the first track and open Instrument Selection
|
||||||
@@ -978,7 +993,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
KGCore.instance().setPlayheadPosition(position);
|
KGCore.instance().setPlayheadPosition(position);
|
||||||
set({
|
set({
|
||||||
playheadPosition: position,
|
playheadPosition: position,
|
||||||
currentTime: beatsToTimeString(position, bpm, timeSignature)
|
currentTime: formatCurrentTime(KGCore.instance().getCurrentProject(), position)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1457,6 +1472,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Update the store state
|
// Update the store state
|
||||||
set({ bpm });
|
set({ bpm });
|
||||||
|
get().bumpAudioWaveformRedrawVersion();
|
||||||
|
|
||||||
console.log(`Set BPM to ${bpm}`);
|
console.log(`Set BPM to ${bpm}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1634,6 +1650,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
bumpTrackAutomationRedrawVersion: () => {
|
bumpTrackAutomationRedrawVersion: () => {
|
||||||
set(state => ({ trackAutomationRedrawVersion: state.trackAutomationRedrawVersion + 1 }));
|
set(state => ({ trackAutomationRedrawVersion: state.trackAutomationRedrawVersion + 1 }));
|
||||||
},
|
},
|
||||||
|
bumpAudioWaveformRedrawVersion: () => {
|
||||||
|
set(state => ({ audioWaveformRedrawVersion: state.audioWaveformRedrawVersion + 1 }));
|
||||||
|
},
|
||||||
|
|
||||||
// Project state cleanup - used when starting new/loading projects
|
// Project state cleanup - used when starting new/loading projects
|
||||||
cleanupProjectState: () => {
|
cleanupProjectState: () => {
|
||||||
@@ -1650,6 +1669,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
activeTrackAutomationTrackId: null,
|
activeTrackAutomationTrackId: null,
|
||||||
activeTrackAutomationType: null,
|
activeTrackAutomationType: null,
|
||||||
trackAutomationRedrawVersion: 0,
|
trackAutomationRedrawVersion: 0,
|
||||||
|
audioWaveformRedrawVersion: 0,
|
||||||
recordingAudioPreviewPeaks: [],
|
recordingAudioPreviewPeaks: [],
|
||||||
recordingAudioPreviewCurrentBeat: 0,
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
});
|
});
|
||||||
@@ -1807,6 +1827,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Use centralized refresh method
|
// Use centralized refresh method
|
||||||
get().refreshProjectState();
|
get().refreshProjectState();
|
||||||
get().bumpTrackAutomationRedrawVersion();
|
get().bumpTrackAutomationRedrawVersion();
|
||||||
|
get().bumpAudioWaveformRedrawVersion();
|
||||||
console.log('Undo completed');
|
console.log('Undo completed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1817,6 +1838,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Use centralized refresh method
|
// Use centralized refresh method
|
||||||
get().refreshProjectState();
|
get().refreshProjectState();
|
||||||
get().bumpTrackAutomationRedrawVersion();
|
get().bumpTrackAutomationRedrawVersion();
|
||||||
|
get().bumpAudioWaveformRedrawVersion();
|
||||||
console.log('Redo completed');
|
console.log('Redo completed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1841,13 +1863,14 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set({
|
set({
|
||||||
projectName: project.getName(),
|
projectName: project.getName(),
|
||||||
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
|
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
|
||||||
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
globalTracks: [...getProjectGlobalTracks(project)],
|
||||||
maxBars: project.getMaxBars(),
|
maxBars: project.getMaxBars(),
|
||||||
barWidthMultiplier: project.getBarWidthMultiplier(),
|
barWidthMultiplier: project.getBarWidthMultiplier(),
|
||||||
timeSignature: project.getTimeSignature(),
|
timeSignature: project.getTimeSignature(),
|
||||||
bpm: project.getBpm(),
|
bpm: project.getBpm(),
|
||||||
keySignature: project.getKeySignature(),
|
keySignature: project.getKeySignature(),
|
||||||
selectedMode: project.getSelectedMode()
|
selectedMode: project.getSelectedMode(),
|
||||||
|
currentTime: formatCurrentTime(project, core.getPlayheadPosition()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync CSS variables that affect layout
|
// Sync CSS variables that affect layout
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { KGProject } from '../core/KGProject';
|
||||||
|
import { GlobalTrackType } from '../core/global-track';
|
||||||
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
|
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||||
|
import { beatRangeToSeconds, beatToSeconds, getAudioRegionDisplayLengthBeats, getEffectiveBpmAtBeat, normalizeTempoRegionsForProject, secondsToBeat } from './globalTrackUtil';
|
||||||
|
|
||||||
|
describe('globalTrackUtil tempo helpers', () => {
|
||||||
|
it('falls back to project bpm when no tempo regions exist', () => {
|
||||||
|
const project = new KGProject('Tempo', 8, 0, 120);
|
||||||
|
expect(getEffectiveBpmAtBeat(project, 6)).toBe(120);
|
||||||
|
expect(beatToSeconds(project, 4)).toBeCloseTo(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves effective bpm and time across tempo regions', () => {
|
||||||
|
const project = new KGProject('Tempo', 8, 0, 120);
|
||||||
|
const tempoTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
throw new Error('Tempo track missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 2, 4),
|
||||||
|
new KGTempoRegion('b', tempoTrack.getId(), tempoTrack.getTrackIndex(), 60, 2, 6, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getEffectiveBpmAtBeat(project, 2)).toBe(120);
|
||||||
|
expect(getEffectiveBpmAtBeat(project, 10)).toBe(60);
|
||||||
|
expect(beatToSeconds(project, 8)).toBeCloseTo(4);
|
||||||
|
expect(beatToSeconds(project, 12)).toBeCloseTo(8);
|
||||||
|
expect(beatRangeToSeconds(project, 8, 12)).toBeCloseTo(4);
|
||||||
|
expect(secondsToBeat(project, 8)).toBeCloseTo(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes trailing tempo coverage when song length grows and shrinks', () => {
|
||||||
|
const project = new KGProject('Tempo', 8, 0, 120);
|
||||||
|
const tempoTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
throw new Error('Tempo track missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 4, 4),
|
||||||
|
new KGTempoRegion('b', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 4, 4, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
project.setMaxBars(10);
|
||||||
|
normalizeTempoRegionsForProject(project);
|
||||||
|
let regions = tempoTrack.getRegions() as KGTempoRegion[];
|
||||||
|
expect(regions[1].getEndBar()).toBe(10);
|
||||||
|
|
||||||
|
project.setMaxBars(6);
|
||||||
|
normalizeTempoRegionsForProject(project);
|
||||||
|
regions = tempoTrack.getRegions() as KGTempoRegion[];
|
||||||
|
expect(regions).toHaveLength(2);
|
||||||
|
expect(regions[1].getStartBar()).toBe(4);
|
||||||
|
expect(regions[1].getEndBar()).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects audio duration through the tempo map for display length', () => {
|
||||||
|
const project = new KGProject('Tempo', 16, 0, 120);
|
||||||
|
const tempoTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
throw new Error('Tempo track missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 4, 4),
|
||||||
|
new KGTempoRegion('b', tempoTrack.getId(), tempoTrack.getTrackIndex(), 60, 4, 12, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const region = new KGAudioRegion('audio', 'track-1', 0, 'Audio', 0, 48, 'file', 'file.wav', 24, 0);
|
||||||
|
expect(getAudioRegionDisplayLengthBeats(project, region)).toBeCloseTo(32);
|
||||||
|
});
|
||||||
|
});
|
||||||
+208
-1
@@ -7,6 +7,8 @@ import {
|
|||||||
} from '../core/global-track';
|
} from '../core/global-track';
|
||||||
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||||
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
|
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||||
|
|
||||||
export const DEFAULT_MARKER_REGION_NAME = 'Marker';
|
export const DEFAULT_MARKER_REGION_NAME = 'Marker';
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ export function getSongEndBeat(project: KGProject): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findGlobalTrackByType(project: KGProject, type: GlobalTrackType): KGGlobalTrack | null {
|
export function findGlobalTrackByType(project: KGProject, type: GlobalTrackType): KGGlobalTrack | null {
|
||||||
return project.getGlobalTracks().find(track => track.getType() === type) ?? null;
|
return project.getGlobalTracks?.().find(track => track.getType() === type) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findGlobalTrackContainingRegion(
|
export function findGlobalTrackContainingRegion(
|
||||||
@@ -90,6 +92,29 @@ export function getSongEndBar(project: KGProject): number {
|
|||||||
return project.getMaxBars();
|
return project.getMaxBars();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSortedTempoRegions(track: KGGlobalTrack, beatsPerBar: number): KGTempoRegion[] {
|
||||||
|
return track.getRegions()
|
||||||
|
.filter((region): region is KGTempoRegion => region instanceof KGTempoRegion)
|
||||||
|
.map((region) => {
|
||||||
|
region.syncBarsFromBeats(beatsPerBar);
|
||||||
|
region.syncBeatsFromBars(beatsPerBar);
|
||||||
|
return region;
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.getStartBar() - right.getStartBar());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneTempoRegions(regions: KGTempoRegion[], beatsPerBar: number): KGTempoRegion[] {
|
||||||
|
return regions.map(region => new KGTempoRegion(
|
||||||
|
region.getId(),
|
||||||
|
region.getTrackId(),
|
||||||
|
region.getTrackIndex(),
|
||||||
|
region.getBpm(),
|
||||||
|
region.getStartBar(),
|
||||||
|
region.getLengthBars(),
|
||||||
|
beatsPerBar
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export function getSortedKeySignatureRegions(track: KGGlobalTrack, beatsPerBar: number): KGKeySignatureRegion[] {
|
export function getSortedKeySignatureRegions(track: KGGlobalTrack, beatsPerBar: number): KGKeySignatureRegion[] {
|
||||||
return track.getRegions()
|
return track.getRegions()
|
||||||
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
|
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
|
||||||
@@ -130,10 +155,192 @@ export function findKeySignatureRegionAtBeat(project: KGProject, beat: number):
|
|||||||
return findKeySignatureRegionAtBar(project, bar);
|
return findKeySignatureRegionAtBar(project, bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findTempoRegionAtBar(project: KGProject, bar: number): KGTempoRegion | null {
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
return getSortedTempoRegions(track, beatsPerBar)
|
||||||
|
.find(region => bar >= region.getStartBar() && bar < region.getEndBar()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findTempoRegionAtBeat(project: KGProject, beat: number): KGTempoRegion | null {
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
const bar = Math.floor(beat / beatsPerBar);
|
||||||
|
return findTempoRegionAtBar(project, bar);
|
||||||
|
}
|
||||||
|
|
||||||
export function getEffectiveKeySignatureAtBeat(project: KGProject, beat: number): KeySignature {
|
export function getEffectiveKeySignatureAtBeat(project: KGProject, beat: number): KeySignature {
|
||||||
return findKeySignatureRegionAtBeat(project, beat)?.getKeySignature() ?? project.getKeySignature();
|
return findKeySignatureRegionAtBeat(project, beat)?.getKeySignature() ?? project.getKeySignature();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEffectiveBpmAtBar(project: KGProject, bar: number): number {
|
||||||
|
return findTempoRegionAtBar(project, bar)?.getBpm() ?? project.getBpm();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveBpmAtBeat(project: KGProject, beat: number): number {
|
||||||
|
return findTempoRegionAtBeat(project, beat)?.getBpm() ?? project.getBpm();
|
||||||
|
}
|
||||||
|
|
||||||
export function getClampedKeySignatureRegionEndBar(region: KGKeySignatureRegion, maxBars: number): number {
|
export function getClampedKeySignatureRegionEndBar(region: KGKeySignatureRegion, maxBars: number): number {
|
||||||
return Math.max(region.getStartBar(), Math.min(region.getEndBar(), maxBars));
|
return Math.max(region.getStartBar(), Math.min(region.getEndBar(), maxBars));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getClampedTempoRegionEndBar(region: KGTempoRegion, maxBars: number): number {
|
||||||
|
return Math.max(region.getStartBar(), Math.min(region.getEndBar(), maxBars));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTempoRegionsForProject(project: KGProject): void {
|
||||||
|
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!track) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
const songEndBar = getSongEndBar(project);
|
||||||
|
const regions = getSortedTempoRegions(track, beatsPerBar);
|
||||||
|
if (regions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: KGTempoRegion[] = [];
|
||||||
|
let currentBar = 0;
|
||||||
|
|
||||||
|
for (const region of regions) {
|
||||||
|
if (currentBar >= songEndBar) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBar = Math.max(currentBar, region.getStartBar());
|
||||||
|
const endBar = Math.max(startBar + 1, Math.min(region.getEndBar(), songEndBar));
|
||||||
|
if (endBar <= startBar) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.push(new KGTempoRegion(
|
||||||
|
region.getId(),
|
||||||
|
track.getId(),
|
||||||
|
track.getTrackIndex(),
|
||||||
|
region.getBpm(),
|
||||||
|
startBar,
|
||||||
|
endBar - startBar,
|
||||||
|
beatsPerBar
|
||||||
|
));
|
||||||
|
currentBar = endBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
track.setRegions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRegion = normalized[0];
|
||||||
|
if (firstRegion.getStartBar() > 0) {
|
||||||
|
firstRegion.setBarRange(0, firstRegion.getEndBar(), beatsPerBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastRegion = normalized[normalized.length - 1];
|
||||||
|
if (lastRegion.getEndBar() < songEndBar) {
|
||||||
|
lastRegion.setLengthBars(songEndBar - lastRegion.getStartBar(), beatsPerBar);
|
||||||
|
} else if (lastRegion.getEndBar() > songEndBar) {
|
||||||
|
lastRegion.setLengthBars(songEndBar - lastRegion.getStartBar(), beatsPerBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
track.setRegions(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beatToSeconds(project: KGProject, beat: number): number {
|
||||||
|
const clampedBeat = Math.max(0, beat);
|
||||||
|
const timeSignature = project.getTimeSignature?.();
|
||||||
|
const beatsPerBar = timeSignature?.numerator ?? 4;
|
||||||
|
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
return clampedBeat * 60 / project.getBpm();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempoRegions = getSortedTempoRegions(tempoTrack, beatsPerBar);
|
||||||
|
if (tempoRegions.length === 0) {
|
||||||
|
return clampedBeat * 60 / project.getBpm();
|
||||||
|
}
|
||||||
|
|
||||||
|
let seconds = 0;
|
||||||
|
let traversedBeat = 0;
|
||||||
|
|
||||||
|
for (const region of tempoRegions) {
|
||||||
|
const regionStartBeat = region.getStartBar() * beatsPerBar;
|
||||||
|
const regionEndBeat = region.getEndBar() * beatsPerBar;
|
||||||
|
const effectiveStartBeat = Math.max(traversedBeat, regionStartBeat);
|
||||||
|
if (clampedBeat <= effectiveStartBeat) {
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coveredEndBeat = Math.min(clampedBeat, regionEndBeat);
|
||||||
|
if (coveredEndBeat > effectiveStartBeat) {
|
||||||
|
seconds += (coveredEndBeat - effectiveStartBeat) * (60 / region.getBpm());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clampedBeat <= regionEndBeat) {
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
traversedBeat = regionEndBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seconds + Math.max(0, clampedBeat - traversedBeat) * (60 / project.getBpm());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beatRangeToSeconds(project: KGProject, startBeat: number, endBeat: number): number {
|
||||||
|
if (endBeat <= startBeat) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return beatToSeconds(project, endBeat) - beatToSeconds(project, startBeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondsToBeat(project: KGProject, seconds: number): number {
|
||||||
|
const clampedSeconds = Math.max(0, seconds);
|
||||||
|
const timeSignature = project.getTimeSignature?.();
|
||||||
|
const beatsPerBar = timeSignature?.numerator ?? 4;
|
||||||
|
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
return clampedSeconds / (60 / project.getBpm());
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempoRegions = getSortedTempoRegions(tempoTrack, beatsPerBar);
|
||||||
|
if (tempoRegions.length === 0) {
|
||||||
|
return clampedSeconds / (60 / project.getBpm());
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingSeconds = clampedSeconds;
|
||||||
|
let traversedBeat = 0;
|
||||||
|
|
||||||
|
for (const region of tempoRegions) {
|
||||||
|
const regionStartBeat = region.getStartBar() * beatsPerBar;
|
||||||
|
const regionEndBeat = region.getEndBar() * beatsPerBar;
|
||||||
|
const effectiveStartBeat = Math.max(traversedBeat, regionStartBeat);
|
||||||
|
const regionDurationSeconds = (regionEndBeat - effectiveStartBeat) * (60 / region.getBpm());
|
||||||
|
|
||||||
|
if (remainingSeconds <= regionDurationSeconds) {
|
||||||
|
return effectiveStartBeat + (remainingSeconds / (60 / region.getBpm()));
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingSeconds -= regionDurationSeconds;
|
||||||
|
traversedBeat = regionEndBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return traversedBeat + remainingSeconds / (60 / project.getBpm());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAudioRegionDisplayLengthBeats(project: KGProject, region: KGAudioRegion): number {
|
||||||
|
if (!project.getTimeSignature?.() || !project.getBpm?.()) {
|
||||||
|
return region.getLength();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBeat = region.getStartFromBeat();
|
||||||
|
const beatBoundSeconds = beatRangeToSeconds(project, startBeat, startBeat + region.getLength());
|
||||||
|
const availableAudioSeconds = Math.max(0, region.getAudioDurationSeconds() - region.getClipStartOffsetSeconds());
|
||||||
|
const visibleSeconds = Math.min(beatBoundSeconds, availableAudioSeconds);
|
||||||
|
return Math.max(0, secondsToBeat(project, beatToSeconds(project, startBeat) + visibleSeconds) - startBeat);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user