diff --git a/src/components/MainContent.test.tsx b/src/components/MainContent.test.tsx index c7d2a02..21d50d5 100644 --- a/src/components/MainContent.test.tsx +++ b/src/components/MainContent.test.tsx @@ -332,25 +332,19 @@ describe('MainContent', () => { 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(); fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' })); - const addButtons = [ - screen.getByRole('button', { name: 'Add Tempo global track item' }), - screen.getByRole('button', { name: 'Add Chord global track item' }), - ]; + fireEvent.click(screen.getByRole('button', { name: 'Add Tempo global track item' })); + expect(executeCommandMock).toHaveBeenCalledTimes(1); - expect(addButtons).toHaveLength(2); - - addButtons.forEach(button => { - fireEvent.click(button); - }); + fireEvent.click(screen.getByRole('button', { name: 'Add Chord global track item' })); expect(storeState.addTrack).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', () => { diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 78ea65c..7fe7a57 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -9,12 +9,14 @@ import { KGTrack } from '../core/track/KGTrack'; import { KGRegion } from '../core/region/KGRegion'; import { KGGlobalRegion } from '../core/region/KGGlobalRegion'; import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion'; +import { KGTempoRegion } from '../core/region/KGTempoRegion'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGMarkerRegion } from '../core/region/KGMarkerRegion'; import TrackInfoPanel from './track/TrackInfoPanel'; import TrackGridPanel from './track/TrackGridPanel'; import GlobalKeySignatureLane from './global-track/GlobalKeySignatureLane'; +import GlobalTempoLane from './global-track/GlobalTempoLane'; import GlobalMarkerLane from './global-track/GlobalMarkerLane'; import PianoRoll from './piano-roll/PianoRoll'; import { TrackCreateDialog } from './common'; @@ -27,19 +29,25 @@ import { ChangeLoopSettingsCommand, CreateGlobalMarkerRegionCommand, CreateKeySignatureRegionCommand, + CreateTempoRegionCommand, DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand, + DeleteMultipleTempoRegionsCommand, + DeleteTempoRegionCommand, DeleteMultipleGlobalRegionsCommand, DeleteTrackAutomationPointsCommand, MoveGlobalRegionCommand, ResizeKeySignatureRegionCommand, + ResizeTempoRegionCommand, ResizeGlobalRegionCommand, UpdateKeySignatureRegionCommand, UpdateGlobalRegionTextCommand, + UpdateTempoRegionCommand, } 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 { FaSquareArrowUpRight } from 'react-icons/fa6'; +import { TIME_CONSTANTS } from '../constants/coreConstants'; interface MainContentProps { onTrackClick?: () => void; @@ -98,6 +106,7 @@ const MainContent: React.FC = ({ activeTrackAutomationType, selectedTrackAutomationPointIds, bumpTrackAutomationRedrawVersion, + bumpAudioWaveformRedrawVersion, refreshProjectState, } = useProjectStore(); @@ -119,6 +128,8 @@ const MainContent: React.FC = ({ const [editingGlobalRegionId, setEditingGlobalRegionId] = useState(null); const [editingGlobalRegionText, setEditingGlobalRegionText] = useState(''); const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState(null); + const [editingTempoRegionId, setEditingTempoRegionId] = useState(null); + const [editingTempoText, setEditingTempoText] = useState(''); // Use the region operations hook const { deleteSelectedRegions } = useRegionOperations({ @@ -173,6 +184,8 @@ const MainContent: React.FC = ({ ); const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null; 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 => { for (const track of tracks) { @@ -205,14 +218,22 @@ const MainContent: React.FC = ({ try { 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); - if (signatureRegionIds.length > 0 && markerRegionIds.length === 0) { + if (signatureRegionIds.length > 0 && markerRegionIds.length === 0 && tempoRegionIds.length === 0) { KGCore.instance().executeCommand( signatureRegionIds.length === 1 ? new DeleteKeySignatureRegionCommand(signatureRegionIds[0]) : 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) { KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(markerRegionIds)); } else { @@ -226,13 +247,17 @@ const MainContent: React.FC = ({ if (editingKeySignatureRegionId && selectedGlobalRegionIds.includes(editingKeySignatureRegionId)) { setEditingKeySignatureRegionId(null); } + if (editingTempoRegionId && selectedGlobalRegionIds.includes(editingTempoRegionId)) { + setEditingTempoRegionId(null); + setEditingTempoText(''); + } refreshProjectState(); return true; } catch (error) { console.error('Error deleting global marker regions:', error); return false; } - }, [editingGlobalRegionId, editingKeySignatureRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]); + }, [bumpAudioWaveformRedrawVersion, editingGlobalRegionId, editingKeySignatureRegionId, editingTempoRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]); // Register the delete function with the global manager useEffect(() => { @@ -430,7 +455,10 @@ const MainContent: React.FC = ({ // Calculate bar number and length from beats const beatsPerBar = timeSignature.numerator; 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 updatedRegions.push({ @@ -447,7 +475,7 @@ const MainContent: React.FC = ({ // Update the regions state setRegions(updatedRegions); - }, [tracks, timeSignature]); + }, [globalTracks, tracks, timeSignature]); useEffect(() => { if (!showPianoRoll || !activeRegionId) { @@ -914,6 +942,16 @@ const MainContent: React.FC = ({ setEditingKeySignatureRegionId(regionId); }, [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 region = findProjectRegionById(regionId); if (!(region instanceof KGMarkerRegion)) { @@ -1044,6 +1082,84 @@ const MainContent: React.FC = ({ } }, [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 * Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions @@ -1397,6 +1513,11 @@ const MainContent: React.FC = ({ return; } + if (track.id === 'tempo') { + createTempoAtPlayheadBar(); + return; + } + if (track.id === 'signature') { createKeySignatureAtPlayheadBar(); } @@ -1433,6 +1554,26 @@ const MainContent: React.FC = ({ onMoveRegion={moveGlobalMarkerRegion} onResizeRegion={resizeGlobalMarkerRegion} /> + ) : track.id === 'tempo' ? ( + { + setEditingTempoRegionId(null); + setEditingTempoText(''); + }} + onBeginEdit={beginEditingTempoRegion} + onSelectRegion={selectGlobalRegion} + onCreateAtBar={createTempoAtBar} + onResizeRegion={resizeTempoRegion} + /> ) : track.id === 'signature' ? ( { const { @@ -60,7 +61,7 @@ const Toolbar: React.FC = () => { selectedRegionIds, selectedTrackId, // Playhead and refresh playheadPosition, refreshProjectState, - requestMainContentScroll, requestPianoRollScroll + requestMainContentScroll, requestPianoRollScroll, bumpAudioWaveformRedrawVersion } = useProjectStore(); // State for main content tools @@ -74,10 +75,18 @@ const Toolbar: React.FC = () => { const signatureRegions = (signatureTrack?.getRegions() ?? []) .filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion) .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 activeKeySignatureRegion = signatureRegions.find( region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar() ) ?? null; + const activeTempoRegion = tempoRegions.find( + region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar() + ) ?? null; + const displayedBpm = activeTempoRegion?.getBpm() ?? bpm; const displayedKeySignature = activeKeySignatureRegion?.getKeySignature() ?? keySignature; // State for export dropdown @@ -650,7 +659,7 @@ const Toolbar: React.FC = () => { 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 if (newBpmStr === null) { @@ -673,11 +682,17 @@ const Toolbar: React.FC = () => { } // Update BPM - setBpm(newBpm); + if (activeTempoRegion) { + KGCore.instance().executeCommand(new UpdateTempoRegionCommand(activeTempoRegion.getId(), newBpm)); + bumpAudioWaveformRedrawVersion(); + refreshProjectState(); + } else { + setBpm(newBpm); + } setStatus(`BPM changed to ${newBpm}`); 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 = () => { {currentTime}
- {bpm} + {displayedBpm}
{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); +}