{timeSignature.numerator + "/" + timeSignature.denominator}
diff --git a/src/components/global-track/GlobalTempoLane.test.tsx b/src/components/global-track/GlobalTempoLane.test.tsx
new file mode 100644
index 0000000..cad457c
--- /dev/null
+++ b/src/components/global-track/GlobalTempoLane.test.tsx
@@ -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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ fireEvent.change(screen.getByDisplayValue('128'), { target: { value: '12a8!' } });
+ expect(onEditingTextChange).toHaveBeenCalledWith('128');
+ });
+});
diff --git a/src/components/global-track/GlobalTempoLane.tsx b/src/components/global-track/GlobalTempoLane.tsx
new file mode 100644
index 0000000..51f3b66
--- /dev/null
+++ b/src/components/global-track/GlobalTempoLane.tsx
@@ -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
= ({
+ tempoRegions,
+ maxBars,
+ barWidthMultiplier,
+ selectedRegionIds,
+ editingRegionId,
+ editingText,
+ onEditingTextChange,
+ onCommitEdit,
+ onCancelEdit,
+ onBeginEdit,
+ onSelectRegion,
+ onCreateAtBar,
+ onResizeRegion,
+}) => {
+ const laneRef = useRef(null);
+ const [previewBars, setPreviewBars] = useState>({});
+ const [hoverEdges, setHoverEdges] = useState>({});
+ 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): 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) => {
+ 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) => {
+ if (!(event.target instanceof HTMLElement) || event.target.closest('.global-tempo-region')) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ onCreateAtBar(getCreateBarFromClientX(event.clientX));
+ };
+
+ return (
+
+ {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 (
+
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 ? (
+ {
+ 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
+ />
+ ) : (
+ {region.getDisplayName()}
+ )}
+
+ );
+ })}
+
+ );
+};
+
+export default GlobalTempoLane;
diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx
index f9eef8b..2d9ce37 100644
--- a/src/components/track/RegionItem.tsx
+++ b/src/components/track/RegionItem.tsx
@@ -10,6 +10,8 @@ import { useProjectStore } from '../../stores/projectStore';
import { KGMainContentState } from '../../core/state/KGMainContentState';
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
import type { RegionPreviewContentStyle } from '../interfaces';
+import { KGCore } from '../../core/KGCore';
+import { beatRangeToSeconds } from '../../util/globalTrackUtil';
const DRAG_START_THRESHOLD_PX = 4;
@@ -48,6 +50,7 @@ interface RegionItemProps {
isPreview?: boolean;
isAudioRegion?: boolean;
previewContentStyle?: RegionPreviewContentStyle;
+ redrawVersion?: number;
}
const RegionItem: React.FC = ({
@@ -76,6 +79,7 @@ const RegionItem: React.FC = ({
isPreview = false,
isAudioRegion = false,
previewContentStyle,
+ redrawVersion = 0,
}) => {
// Get selection state and time signature from store
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
@@ -273,10 +277,18 @@ const RegionItem: React.FC = ({
const clipStartOffsetSeconds = audioRegion ? audioRegion.getClipStartOffsetSeconds() : 0;
const clipStartSample = Math.floor(clipStartOffsetSeconds * sampleRate);
- // Calculate visible duration from region length in beats
- const secondsPerBeat = 60 / bpm;
+ const currentProject = KGCore.instance().getCurrentProject();
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);
// Clamp to buffer boundaries
@@ -383,7 +395,7 @@ const RegionItem: React.FC = ({
} else {
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
useEffect(() => {
@@ -406,7 +418,7 @@ const RegionItem: React.FC = ({
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
const handleMouseMove = (e: React.MouseEvent) => {
diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx
index 0641618..b1a84a7 100644
--- a/src/components/track/TrackGridItem.tsx
+++ b/src/components/track/TrackGridItem.tsx
@@ -91,6 +91,7 @@ const TrackGridItem: React.FC = ({
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
+ const audioWaveformRedrawVersion = useProjectStore(state => state.audioWaveformRedrawVersion);
const recordingMode = useProjectStore(state => state.recordingMode);
const recordingTargetTrackIndex = useProjectStore(state => state.recordingTargetTrackIndex);
const recordingCommitStartBeatAbsolute = useProjectStore(state => state.recordingCommitStartBeatAbsolute);
@@ -796,6 +797,7 @@ const TrackGridItem: React.FC = ({
audioRegion={audioRegion}
audioBuffer={audioBuffer}
previewContentStyle={tempPreviewRegionContentStyles[region.id]}
+ redrawVersion={audioWaveformRedrawVersion}
/>
);
})}
diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx
index 87705c7..93644e4 100644
--- a/src/components/track/TrackGridPanel.tsx
+++ b/src/components/track/TrackGridPanel.tsx
@@ -18,6 +18,7 @@ import { showAlert } from '../../util/dialogUtil';
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
import * as Tone from 'tone';
import { useProjectStore } from '../../stores/projectStore';
+import { getAudioRegionDisplayLengthBeats } from '../../util/globalTrackUtil';
interface TrackGridPanelProps {
tracks: KGTrack[];
@@ -64,6 +65,7 @@ const TrackGridPanel: React.FC = ({
}) => {
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
const refreshProjectState = useProjectStore(state => state.refreshProjectState);
+ const bumpAudioWaveformRedrawVersion = useProjectStore(state => state.bumpAudioWaveformRedrawVersion);
const gridContainerRef = useRef(null);
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
const [previewRegionStyles, setPreviewRegionStyles] = useState>({});
@@ -356,12 +358,16 @@ const TrackGridPanel: React.FC = ({
const created = cmd.getCreatedRegion();
if (created && onExternalDropComplete) {
+ const displayLengthInBars = Math.max(
+ 1,
+ getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), created) / beatsPerBar
+ );
const regionUI: RegionUI = {
id: created.getId(),
trackId: track.getId().toString(),
trackIndex,
barNumber,
- length: lengthInBars,
+ length: displayLengthInBars,
name: created.getName(),
};
onExternalDropComplete(trackIndex, regionUI);
@@ -571,6 +577,7 @@ const TrackGridPanel: React.FC = ({
const sourceTrack = tracks.find(t => {
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()) {
// Snap back — don't execute the move
if (DEBUG_MODE.TRACK_GRID_PANEL) {
@@ -592,6 +599,12 @@ const TrackGridPanel: React.FC = ({
KGCore.instance().executeCommand(command, { rethrow: true });
refreshProjectState();
+ if (bulkRegionIds.some(selectedId => {
+ const candidate = tracks.flatMap(track => track.getRegions()).find(region => region.getId() === selectedId);
+ return candidate instanceof KGAudioRegion;
+ })) {
+ bumpAudioWaveformRedrawVersion();
+ }
return;
}
@@ -625,6 +638,9 @@ const TrackGridPanel: React.FC = ({
const movedRegion = command.getTargetRegion();
console.log(`Verified region: ${movedRegion ? 'found' : 'not found'}, startBeat=${movedRegion?.getStartFromBeat()}, trackId=${movedRegion?.getTrackId()}`);
}
+ if (movedRegionWasAudio) {
+ bumpAudioWaveformRedrawVersion();
+ }
} catch (error) {
console.error('Error moving region:', error);
await showAlert(error instanceof Error ? error.message : 'Unable to move the selected regions.');
@@ -681,6 +697,12 @@ const TrackGridPanel: React.FC = ({
);
KGCore.instance().executeCommand(command, { rethrow: true });
refreshProjectState();
+ if (bulkRegionIds.some(selectedId => {
+ const candidate = tracks.flatMap(track => track.getRegions()).find(region => region.getId() === selectedId);
+ return candidate instanceof KGAudioRegion;
+ })) {
+ bumpAudioWaveformRedrawVersion();
+ }
return;
}
@@ -692,6 +714,9 @@ const TrackGridPanel: React.FC = ({
region.trackIndex
);
KGCore.instance().executeCommand(command, { rethrow: true });
+ if (coreRegion instanceof KGAudioRegion) {
+ bumpAudioWaveformRedrawVersion();
+ }
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Fine-moved region ${regionId}: startFromBeat=${newStartFromBeat}`);
@@ -793,12 +818,16 @@ const TrackGridPanel: React.FC = ({
const created = cmd.getCreatedRegion();
if (created && onExternalDropComplete) {
+ const displayLengthInBars = Math.max(
+ 1,
+ getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), created as unknown as KGAudioRegion) / beatsPerBar
+ );
const regionUI: RegionUI = {
id: created.getId(),
trackId: track.getId().toString(),
trackIndex,
barNumber,
- length: lengthInBars,
+ length: displayLengthInBars,
name: created.getName(),
};
onExternalDropComplete(trackIndex, regionUI);
diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts
index 06dbfe8..87447ae 100644
--- a/src/core/KGCore.ts
+++ b/src/core/KGCore.ts
@@ -11,6 +11,7 @@ import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
import { KGRegion } from './region/KGRegion';
import { generateUniqueId } from '../util/miscUtil';
import { KGCommand, KGCommandHistory } from './commands';
+import { getEffectiveBpmAtBeat } from '../util/globalTrackUtil';
interface PlaybackStartOptions {
preserveLoopPreroll?: boolean;
@@ -150,7 +151,7 @@ export class KGCore {
try {
const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized()) {
- audioInterface.setBpm(project.getBpm());
+ audioInterface.setBpm(getEffectiveBpmAtBeat(project, 0));
}
} catch (error) {
console.error('Error syncing project with audio interface:', error);
@@ -253,9 +254,6 @@ export class KGCore {
audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
});
-
- // Sync BPM and transport settings
- audioInterface.setBpm(this.currentProject.getBpm());
audioInterface.setTransportPosition(this.playheadPosition);
console.log("Playback prepared successfully");
@@ -388,22 +386,8 @@ export class KGCore {
this.stopPlaybackUpdates();
return;
}
-
- // Calculate current playhead position based on elapsed time
- 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);
+ const audioInterface = KGAudioInterface.instance();
+ let newPosition = audioInterface.getTransportPosition();
// Handle looping or end-of-project
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
@@ -415,10 +399,10 @@ export class KGCore {
const loopStartBeats = startBar * beatsPerBar;
const loopEndBeats = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
- const loopLengthBeats = loopEndBeats - loopStartBeats;
+ const previousPosition = this.playheadPosition;
- // Wrap playhead position within loop range
- if (newPosition >= loopEndBeats) {
+ // Tone.Transport position wraps back to loop start. Preserve the loop-end callback behavior.
+ if (this.loopBoundaryReachedCallback && previousPosition < loopEndBeats && newPosition < previousPosition) {
if (this.loopBoundaryReachedCallback) {
const callback = this.loopBoundaryReachedCallback;
this.loopBoundaryReachedCallback = null;
@@ -426,16 +410,9 @@ export class KGCore {
callback(loopEndBeats);
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 {
// Non-looping mode: stop at project end
const maxBars = this.currentProject.getMaxBars();
diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts
index 180f88c..323c4cb 100644
--- a/src/core/audio-interface/KGAudioInterface.ts
+++ b/src/core/audio-interface/KGAudioInterface.ts
@@ -35,6 +35,8 @@ import type { KGAudioRegion } from '../region/KGAudioRegion';
import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager';
import { KGMetronome } from './KGMetronome';
+import { GlobalTrackType } from '../global-track';
+import { beatRangeToSeconds, beatToSeconds, findGlobalTrackByType, getEffectiveBpmAtBeat, getSortedTempoRegions, secondsToBeat } from '../../util/globalTrackUtil';
interface PreparePlaybackOptions {
allowStartBeforeLoopStart?: boolean;
@@ -445,14 +447,14 @@ export class KGAudioInterface {
try {
// 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 secondsPerBeat = 60 / project.getBpm();
+ const secondsPerBeat = 60 / Math.max(1, getEffectiveBpmAtBeat(project, Math.max(startPosition, 0)));
const resumeSafetyOffsetBeats =
KGAudioInterface.AUDIO_RESUME_SAFETY_OFFSET_SECONDS / secondsPerBeat;
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
const isLooping = project.getIsLooping();
@@ -486,8 +488,10 @@ export class KGAudioInterface {
console.log("Loop mode disabled");
}
+ this.scheduleTempoChanges(project, Math.max(startPosition, scheduleStartBeat), scheduleEndBeat);
+
if (startPosition < 0) {
- this.delayedTransportStartSeconds = Math.abs(startPosition) * secondsPerBeat;
+ this.delayedTransportStartSeconds = Math.abs(startPosition) * (60 / project.getBpm());
this.virtualPrerollStartBeat = startPosition;
this.virtualPrerollStartAudioTime = null;
} else {
@@ -513,7 +517,7 @@ export class KGAudioInterface {
const automationWindowStartBeat = isLooping ? Math.max(startPosition, scheduleStartBeat) : startPosition;
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()}`);
@@ -622,7 +626,7 @@ export class KGAudioInterface {
scheduleEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
- bpm: project.getBpm(),
+ bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
defaultValue: MIDI_PITCH_BEND_CENTER,
}
);
@@ -648,7 +652,7 @@ export class KGAudioInterface {
scheduleEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
- bpm: project.getBpm(),
+ bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
defaultValue: 127,
interpolationMode: 'linear',
quantizeValue: clampMidiControllerValue,
@@ -676,7 +680,7 @@ export class KGAudioInterface {
scheduleEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
- bpm: project.getBpm(),
+ bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
defaultValue: 0,
interpolationMode: 'step',
quantizeValue: clampMidiControllerValue,
@@ -699,20 +703,19 @@ export class KGAudioInterface {
});
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
- const noteDurationBeats = absoluteEndBeat - absoluteStartBeat;
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
- const noteDuration = this.beatsToToneTime(noteDurationBeats);
+ const noteDurationSeconds = beatRangeToSeconds(project, absoluteStartBeat, absoluteEndBeat);
const velocity = note.getVelocity() / 127;
const noteName = pitchToNoteNameString(note.getPitch());
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 hasSoloedTracks = this.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);
@@ -740,17 +743,15 @@ export class KGAudioInterface {
// Skip regions that start before playback start position
if (regionStartBeat < startPosition) {
// Region starts before playhead — calculate offset into the audio file
- const offsetBeats = startPosition - regionStartBeat;
- const offsetSeconds = offsetBeats * secondsPerBeat;
- const remainingBeats = regionEndBeat - startPosition;
- const remainingSeconds = remainingBeats * secondsPerBeat;
+ const offsetSeconds = beatRangeToSeconds(project, regionStartBeat, startPosition);
+ const remainingSeconds = beatRangeToSeconds(project, startPosition, regionEndBeat);
const audioFileId = audioRegion.getAudioFileId();
// Cap duration at loop boundary to prevent overlap on loop re-trigger
let effectiveRemainingSeconds = remainingSeconds;
if (isLooping) {
const maxDurationBeats = scheduleEndBeat - startPosition;
- const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
+ const maxDurationSeconds = beatRangeToSeconds(project, startPosition, startPosition + maxDurationBeats);
effectiveRemainingSeconds = Math.min(remainingSeconds, maxDurationSeconds);
}
@@ -769,7 +770,7 @@ export class KGAudioInterface {
startPosition + resumeSafetyOffsetBeats,
regionEndBeat
);
- const extraOffsetSeconds = (safeResumeBeat - startPosition) * secondsPerBeat;
+ const extraOffsetSeconds = beatRangeToSeconds(project, startPosition, safeResumeBeat);
const adjustedOffsetSeconds = clipStartOffsetSeconds + offsetSeconds + extraOffsetSeconds;
const adjustedRemainingSeconds = Math.max(
0,
@@ -800,7 +801,7 @@ export class KGAudioInterface {
const audioFileId = audioRegion.getAudioFileId();
// 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(
regionLengthSeconds,
audioDurationSeconds - clipStartOffsetSeconds
@@ -814,7 +815,7 @@ export class KGAudioInterface {
// Cap duration at loop boundary to prevent overlap on loop re-trigger
if (isLooping) {
const maxDurationBeats = scheduleEndBeat - regionStartBeat;
- const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
+ const maxDurationSeconds = beatRangeToSeconds(project, regionStartBeat, regionStartBeat + maxDurationBeats);
effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds);
}
@@ -1176,8 +1177,12 @@ export class KGAudioInterface {
return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
}
- const position = Tone.Transport.position;
- return this.toneTimeToBeats(position);
+ const transportSeconds = Number(Tone.Transport.seconds);
+ if (Number.isFinite(transportSeconds)) {
+ return secondsToBeat(KGCore.instance().getCurrentProject(), transportSeconds);
+ }
+
+ return this.toneTimeToBeats(Tone.Transport.position);
} catch (error) {
console.error('Error getting transport position:', error);
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 {
this.trackAudioBuses.forEach(audioBus => {
audioBus.setAutomationVolume(null);
@@ -1527,35 +1556,15 @@ export class KGAudioInterface {
* This approach handles triplets and all subdivisions correctly
*/
private beatsToToneTime(beats: number): Tone.Unit.Time {
- 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;
-
- // Convert beats directly to seconds
- const totalSeconds = beats * secondsPerBeat;
-
- return totalSeconds as Tone.Unit.Time;
+ return beatToSeconds(KGCore.instance().getCurrentProject(), beats) as Tone.Unit.Time;
}
/**
* Convert Tone.js time format to beats
*/
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 beats = seconds / secondsPerBeat;
-
- return beats;
+ return secondsToBeat(KGCore.instance().getCurrentProject(), seconds);
}
/**
diff --git a/src/core/audio-interface/KGOfflineRenderer.ts b/src/core/audio-interface/KGOfflineRenderer.ts
index 2cdc143..a3a4a45 100644
--- a/src/core/audio-interface/KGOfflineRenderer.ts
+++ b/src/core/audio-interface/KGOfflineRenderer.ts
@@ -25,6 +25,8 @@ import { KGToneBuffersPool } from './KGToneBuffersPool';
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
import { KGAudioInterface } from './KGAudioInterface';
import { KGAudioBus } from './KGAudioBus';
+import { beatRangeToSeconds, beatToSeconds, findGlobalTrackByType, getEffectiveBpmAtBeat, getSortedTempoRegions } from '../../util/globalTrackUtil';
+import { GlobalTrackType } from '../global-track';
import { ConfigManager } from '../config/ConfigManager';
import { Mp3Encoder } from '@breezystack/lamejs';
@@ -92,7 +94,7 @@ export class KGOfflineRenderer {
const tailSeconds = options?.tailSeconds ?? 2;
// Calculate render duration in seconds
- const bpm = project.getBpm();
+ const bpm = getEffectiveBpmAtBeat(project, 0);
const secondsPerBeat = 60 / bpm;
const timeSignature = project.getTimeSignature();
const beatsPerBar = timeSignature.numerator;
@@ -279,7 +281,7 @@ export class KGOfflineRenderer {
// 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`);
@@ -289,8 +291,20 @@ export class KGOfflineRenderer {
const masterGain = new Tone.Gain(1).toDestination();
// 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];
+ 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 ----
const samplerPromises: Promise[] = [];
@@ -343,7 +357,7 @@ export class KGOfflineRenderer {
renderEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
- bpm: project.getBpm(),
+ bpm: getEffectiveBpmAtBeat(project, renderStartBeat),
defaultValue: MIDI_PITCH_BEND_CENTER,
}
);
@@ -353,7 +367,7 @@ export class KGOfflineRenderer {
renderEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
- bpm: project.getBpm(),
+ bpm: getEffectiveBpmAtBeat(project, renderStartBeat),
defaultValue: 127,
interpolationMode: 'linear',
quantizeValue: clampMidiControllerValue,
@@ -366,14 +380,13 @@ export class KGOfflineRenderer {
// Skip notes outside render range
if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue;
- const offsetBeat = note.startBeat - renderStartBeat;
- const noteStartTime = offsetBeat * secondsPerBeat;
+ const noteStartTime = beatToSeconds(project, note.startBeat) - beatToSeconds(project, renderStartBeat);
const sustainedEndBeat = resolveSustainExtendedEndBeat(
trackInfo.controllerEventsByType[64],
note.endBeat,
0
);
- const noteDuration = Math.max(0, sustainedEndBeat - note.startBeat) * secondsPerBeat;
+ const noteDuration = beatRangeToSeconds(project, note.startBeat, sustainedEndBeat);
const velocity = note.velocity / 127;
const initialNormalizedPitchBend = midiPitchBendToNormalized(
resolveMidiAutomationValueAtBeat(trackInfo.pitchBends, note.startBeat, MIDI_PITCH_BEND_CENTER)
@@ -451,13 +464,12 @@ export class KGOfflineRenderer {
const clipStartOffsetSeconds = regionInfo.clipStartOffsetSeconds;
const audioDurationSeconds = regionInfo.audioDurationSeconds;
- const regionLengthSeconds = regionInfo.lengthBeats * secondsPerBeat;
+ const regionLengthSeconds = beatRangeToSeconds(project, regionStartBeat, regionEndBeat);
const effectiveDurationSeconds = Math.min(regionLengthSeconds, audioDurationSeconds - clipStartOffsetSeconds);
if (effectiveDurationSeconds <= 0) continue;
- const offsetBeat = regionStartBeat - renderStartBeat;
- const regionStartTime = Math.max(0, offsetBeat * secondsPerBeat);
+ const regionStartTime = Math.max(0, beatToSeconds(project, regionStartBeat) - beatToSeconds(project, renderStartBeat));
// Create buffer source NOW while the offline context is still active.
// Schedule callbacks fire during rendering after Tone.js restores the
diff --git a/src/core/commands/global-region/CreateTempoRegionCommand.ts b/src/core/commands/global-region/CreateTempoRegionCommand.ts
new file mode 100644
index 0000000..5db3813
--- /dev/null
+++ b/src/core/commands/global-region/CreateTempoRegionCommand.ts
@@ -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;
+ }
+}
diff --git a/src/core/commands/global-region/DeleteTempoRegionCommand.ts b/src/core/commands/global-region/DeleteTempoRegionCommand.ts
new file mode 100644
index 0000000..c01e86f
--- /dev/null
+++ b/src/core/commands/global-region/DeleteTempoRegionCommand.ts
@@ -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`;
+ }
+}
diff --git a/src/core/commands/global-region/GlobalTempoCommands.test.ts b/src/core/commands/global-region/GlobalTempoCommands.test.ts
new file mode 100644
index 0000000..d4e0250
--- /dev/null
+++ b/src/core/commands/global-region/GlobalTempoCommands.test.ts
@@ -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;
+ };
+
+ 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);
+ });
+});
diff --git a/src/core/commands/global-region/ResizeTempoRegionCommand.ts b/src/core/commands/global-region/ResizeTempoRegionCommand.ts
new file mode 100644
index 0000000..3f3b559
--- /dev/null
+++ b/src/core/commands/global-region/ResizeTempoRegionCommand.ts
@@ -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}"`;
+ }
+}
diff --git a/src/core/commands/global-region/UpdateTempoRegionCommand.ts b/src/core/commands/global-region/UpdateTempoRegionCommand.ts
new file mode 100644
index 0000000..5c934a4
--- /dev/null
+++ b/src/core/commands/global-region/UpdateTempoRegionCommand.ts
@@ -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"`;
+ }
+}
diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts
index 8943933..1c2fcc3 100644
--- a/src/core/commands/index.ts
+++ b/src/core/commands/index.ts
@@ -37,13 +37,17 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
+export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignatureRegionCommand';
+export { ResizeTempoRegionCommand } from './global-region/ResizeTempoRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
+export { DeleteTempoRegionCommand, DeleteMultipleTempoRegionsCommand } from './global-region/DeleteTempoRegionCommand';
+export { UpdateTempoRegionCommand } from './global-region/UpdateTempoRegionCommand';
// Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand';
diff --git a/src/core/commands/project/ChangeProjectPropertyCommand.ts b/src/core/commands/project/ChangeProjectPropertyCommand.ts
index 28b1f6b..3d0c345 100644
--- a/src/core/commands/project/ChangeProjectPropertyCommand.ts
+++ b/src/core/commands/project/ChangeProjectPropertyCommand.ts
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGProject, type KeySignature } from '../../KGProject';
import type { TimeSignature } from '../../../types/projectTypes';
+import { normalizeTempoRegionsForProject } from '../../../util/globalTrackUtil';
/**
* Interface defining properties that can be updated on a project
@@ -59,6 +60,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
// Update maxBars
if (this.newProperties.maxBars !== undefined && this.newProperties.maxBars !== this.originalProperties.maxBars) {
this.targetProject.setMaxBars(this.newProperties.maxBars);
+ normalizeTempoRegionsForProject(this.targetProject);
this.changedProperties.add('maxBars');
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${this.newProperties.maxBars}`);
}
@@ -85,6 +87,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
// Compare time signatures
if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) {
this.targetProject.setTimeSignature(newTS);
+ normalizeTempoRegionsForProject(this.targetProject);
this.changedProperties.add('timeSignature');
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)
if (this.changedProperties.has('maxBars') && this.originalProperties.maxBars !== undefined) {
this.targetProject.setMaxBars(this.originalProperties.maxBars);
+ normalizeTempoRegionsForProject(this.targetProject);
restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`);
}
@@ -146,6 +150,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
// Restore time signature (only if it was changed)
if (this.changedProperties.has('timeSignature') && this.originalProperties.timeSignature !== undefined) {
this.targetProject.setTimeSignature(this.originalProperties.timeSignature);
+ normalizeTempoRegionsForProject(this.targetProject);
const ts = this.originalProperties.timeSignature;
restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`);
}
@@ -226,4 +231,4 @@ export class ChangeProjectPropertyCommand extends KGCommand {
public getChangedProperties(): Set {
return new Set(this.changedProperties);
}
-}
\ No newline at end of file
+}
diff --git a/src/core/global-track/KGGlobalTrack.ts b/src/core/global-track/KGGlobalTrack.ts
index 5a6834a..c6d549b 100644
--- a/src/core/global-track/KGGlobalTrack.ts
+++ b/src/core/global-track/KGGlobalTrack.ts
@@ -2,6 +2,7 @@ import { Expose, Type } from 'class-transformer';
import { KGGlobalRegion } from '../region/KGGlobalRegion';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
+import { KGTempoRegion } from '../region/KGTempoRegion';
export enum GlobalTrackType {
Marker = 'marker',
@@ -33,6 +34,7 @@ export class KGGlobalTrack {
subTypes: [
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
+ { value: KGTempoRegion, name: 'KGTempoRegion' },
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
],
},
diff --git a/src/core/region/KGTempoRegion.ts b/src/core/region/KGTempoRegion.ts
new file mode 100644
index 0000000..112a68f
--- /dev/null
+++ b/src/core/region/KGTempoRegion.ts
@@ -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';
+ }
+}
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index c2d2eb4..4d7e89d 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -27,6 +27,7 @@ import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationD
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
+import { beatToSeconds } from '../util/globalTrackUtil';
/**
* 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';
function getSidePanelVisibilityState(activePanel: SidePanelType | null) {
@@ -106,6 +117,7 @@ interface ProjectState {
activeTrackAutomationTrackId: string | null;
activeTrackAutomationType: TrackAutomationType | null;
trackAutomationRedrawVersion: number;
+ audioWaveformRedrawVersion: number;
// ChatBox state
showChatBox: boolean;
@@ -203,6 +215,7 @@ interface ProjectState {
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
bumpAutomationRedrawVersion: () => void;
bumpTrackAutomationRedrawVersion: () => void;
+ bumpAudioWaveformRedrawVersion: () => void;
// Project state cleanup
cleanupProjectState: () => void;
@@ -318,9 +331,10 @@ export const useProjectStore = create((set, get) => {
// Set up playhead update callback to keep store in sync during playback
KGCore.instance().setPlayheadUpdateCallback((position: number) => {
const { bpm, timeSignature } = get();
+ const project = KGCore.instance().getCurrentProject();
set(state => ({
playheadPosition: position,
- currentTime: beatsToTimeString(position, bpm, timeSignature),
+ currentTime: formatCurrentTime(project, position),
recordingAudioPreviewCurrentBeat: state.recordingMode === 'audio'
? Math.max(state.recordingCommitStartBeatAbsolute, position)
: state.recordingAudioPreviewCurrentBeat,
@@ -407,7 +421,7 @@ export const useProjectStore = create((set, get) => {
projectName: currentProject.getName(),
savedProjectName: currentProject.getName(),
tracks: currentProject.getTracks() as KGTrack[],
- globalTracks: currentProject.getGlobalTracks() as KGGlobalTrack[],
+ globalTracks: getProjectGlobalTracks(currentProject),
currentStatus: KGCore.instance().getStatus() || 'Unknown',
maxBars: currentProject.getMaxBars(),
barWidthMultiplier: currentProject.getBarWidthMultiplier(),
@@ -422,7 +436,7 @@ export const useProjectStore = create((set, get) => {
isPlaying: KGCore.instance().getIsPlaying(),
isPreparingPlayback: false,
autoScrollEnabled: true,
- currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
+ currentTime: formatCurrentTime(currentProject, KGCore.instance().getPlayheadPosition()),
// Initial selection state
selectedNoteIds: [],
@@ -443,6 +457,7 @@ export const useProjectStore = create((set, get) => {
activeTrackAutomationTrackId: null,
activeTrackAutomationType: null,
trackAutomationRedrawVersion: 0,
+ audioWaveformRedrawVersion: 0,
// Initial ChatBox state
showChatBox: initialChatBoxState,
@@ -522,7 +537,7 @@ export const useProjectStore = create((set, get) => {
const project = KGCore.instance().getCurrentProject();
set({
tracks: [...project.getTracks()] as KGTrack[],
- globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
+ globalTracks: [...getProjectGlobalTracks(project)],
});
// Auto-select the newly created track and open instrument selection panel
@@ -547,7 +562,7 @@ export const useProjectStore = create((set, get) => {
const project = KGCore.instance().getCurrentProject();
set({
tracks: [...project.getTracks()] as KGTrack[],
- globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
+ globalTracks: [...getProjectGlobalTracks(project)],
});
const newTrackId = command.getTrackId().toString();
@@ -670,7 +685,7 @@ export const useProjectStore = create((set, get) => {
const remainingTracks = [...project.getTracks()] as KGTrack[];
set({
tracks: remainingTracks,
- globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
+ globalTracks: [...getProjectGlobalTracks(project)],
});
// Auto-select another track if any remain
@@ -758,7 +773,7 @@ export const useProjectStore = create((set, get) => {
const project = KGCore.instance().getCurrentProject();
set({
tracks: [...project.getTracks()] as KGTrack[],
- globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
+ globalTracks: [...getProjectGlobalTracks(project)],
});
console.log(`Updated track ${trackId} properties`);
@@ -790,7 +805,7 @@ export const useProjectStore = create((set, get) => {
const project = KGCore.instance().getCurrentProject();
set({
tracks: [...project.getTracks()] as KGTrack[],
- globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
+ globalTracks: [...getProjectGlobalTracks(project)],
});
console.log(`Reordered track from index ${sourceIndex} to ${destinationIndex}`);
@@ -920,7 +935,7 @@ export const useProjectStore = create((set, get) => {
projectName: projectToLoad.getName(),
savedProjectName: savedName ?? projectToLoad.getName(),
tracks: [...tracks],
- globalTracks: [...projectToLoad.getGlobalTracks()] as KGGlobalTrack[],
+ globalTracks: [...getProjectGlobalTracks(projectToLoad)],
maxBars,
barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
timeSignature,
@@ -947,7 +962,7 @@ export const useProjectStore = create((set, get) => {
recordingAudioPreviewCurrentBeat: 0,
recordingAudioPreviewFileName: null,
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
@@ -978,7 +993,7 @@ export const useProjectStore = create((set, get) => {
KGCore.instance().setPlayheadPosition(position);
set({
playheadPosition: position,
- currentTime: beatsToTimeString(position, bpm, timeSignature)
+ currentTime: formatCurrentTime(KGCore.instance().getCurrentProject(), position)
});
},
@@ -1457,6 +1472,7 @@ export const useProjectStore = create((set, get) => {
// Update the store state
set({ bpm });
+ get().bumpAudioWaveformRedrawVersion();
console.log(`Set BPM to ${bpm}`);
} catch (error) {
@@ -1634,6 +1650,9 @@ export const useProjectStore = create((set, get) => {
bumpTrackAutomationRedrawVersion: () => {
set(state => ({ trackAutomationRedrawVersion: state.trackAutomationRedrawVersion + 1 }));
},
+ bumpAudioWaveformRedrawVersion: () => {
+ set(state => ({ audioWaveformRedrawVersion: state.audioWaveformRedrawVersion + 1 }));
+ },
// Project state cleanup - used when starting new/loading projects
cleanupProjectState: () => {
@@ -1650,6 +1669,7 @@ export const useProjectStore = create((set, get) => {
activeTrackAutomationTrackId: null,
activeTrackAutomationType: null,
trackAutomationRedrawVersion: 0,
+ audioWaveformRedrawVersion: 0,
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: 0,
});
@@ -1807,6 +1827,7 @@ export const useProjectStore = create((set, get) => {
// Use centralized refresh method
get().refreshProjectState();
get().bumpTrackAutomationRedrawVersion();
+ get().bumpAudioWaveformRedrawVersion();
console.log('Undo completed');
}
},
@@ -1817,6 +1838,7 @@ export const useProjectStore = create((set, get) => {
// Use centralized refresh method
get().refreshProjectState();
get().bumpTrackAutomationRedrawVersion();
+ get().bumpAudioWaveformRedrawVersion();
console.log('Redo completed');
}
},
@@ -1841,13 +1863,14 @@ export const useProjectStore = create((set, get) => {
set({
projectName: project.getName(),
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
- globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
+ globalTracks: [...getProjectGlobalTracks(project)],
maxBars: project.getMaxBars(),
barWidthMultiplier: project.getBarWidthMultiplier(),
timeSignature: project.getTimeSignature(),
bpm: project.getBpm(),
keySignature: project.getKeySignature(),
- selectedMode: project.getSelectedMode()
+ selectedMode: project.getSelectedMode(),
+ currentTime: formatCurrentTime(project, core.getPlayheadPosition()),
});
// Sync CSS variables that affect layout
diff --git a/src/util/globalTrackUtil.test.ts b/src/util/globalTrackUtil.test.ts
new file mode 100644
index 0000000..5d19c46
--- /dev/null
+++ b/src/util/globalTrackUtil.test.ts
@@ -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);
+ });
+});
diff --git a/src/util/globalTrackUtil.ts b/src/util/globalTrackUtil.ts
index 69f3b9f..cacbc02 100644
--- a/src/util/globalTrackUtil.ts
+++ b/src/util/globalTrackUtil.ts
@@ -7,6 +7,8 @@ import {
} from '../core/global-track';
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
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';
@@ -34,7 +36,7 @@ export function getSongEndBeat(project: KGProject): number {
}
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(
@@ -90,6 +92,29 @@ export function getSongEndBar(project: KGProject): number {
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[] {
return track.getRegions()
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
@@ -130,10 +155,192 @@ export function findKeySignatureRegionAtBeat(project: KGProject, beat: number):
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 {
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 {
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);
+}