feat: implemented global tempo (bpm) track

This commit is contained in:
Xiaohan-Tian
2026-05-23 23:18:47 -07:00
parent 8eb3f5d84d
commit 7d275802c9
23 changed files with 1700 additions and 131 deletions
+5 -11
View File
@@ -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', () => {
+146 -5
View File
@@ -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}
+20 -5
View File
@@ -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
if (activeTempoRegion) {
KGCore.instance().executeCommand(new UpdateTempoRegionCommand(activeTempoRegion.getId(), newBpm));
bumpAudioWaveformRedrawVersion();
refreshProjectState();
} else {
setBpm(newBpm); 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;
+17 -5
View File
@@ -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>) => {
+2
View File
@@ -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}
/> />
); );
})} })}
+31 -2
View File
@@ -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
View File
@@ -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();
+53 -44
View File
@@ -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;
} }
/** /**
+23 -11
View File
@@ -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"`;
}
}
+4
View File
@@ -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
View File
@@ -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' },
], ],
}, },
+90
View File
@@ -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
View File
@@ -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
+75
View File
@@ -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
View File
@@ -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);
}