From a8c68d97ed1f13e1ce7d9d2631596b38f3950a4b Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:04:42 -0700 Subject: [PATCH] fix: tempo-driven audio region resizing and playhead clamping --- .../global-region/GlobalTempoCommands.test.ts | 24 +++ .../global-region/UpdateTempoRegionCommand.ts | 31 +++- .../WriteTempoTrackCommand.test.ts | 24 +++ .../global-region/WriteTempoTrackCommand.ts | 34 +++- .../project/ChangeProjectPropertyCommand.ts | 43 ++++- src/stores/projectStore.test.ts | 150 +++++++++++++++++- src/stores/projectStore.ts | 9 +- src/util/globalTrackUtil.test.ts | 72 ++++++++- src/util/globalTrackUtil.ts | 79 ++++++++- 9 files changed, 450 insertions(+), 16 deletions(-) diff --git a/src/core/commands/global-region/GlobalTempoCommands.test.ts b/src/core/commands/global-region/GlobalTempoCommands.test.ts index d4e0250..b4d3d93 100644 --- a/src/core/commands/global-region/GlobalTempoCommands.test.ts +++ b/src/core/commands/global-region/GlobalTempoCommands.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { KGCore } from '../../KGCore'; import { KGProject } from '../../KGProject'; import { GlobalTrackType } from '../../global-track'; +import { KGAudioRegion } from '../../region/KGAudioRegion'; import { KGTempoRegion } from '../../region/KGTempoRegion'; +import { KGAudioTrack } from '../../track/KGAudioTrack'; import { CreateTempoRegionCommand } from './CreateTempoRegionCommand'; import { DeleteTempoRegionCommand } from './DeleteTempoRegionCommand'; import { ResizeTempoRegionCommand } from './ResizeTempoRegionCommand'; @@ -118,4 +120,26 @@ describe('global tempo region commands', () => { command.undo(); expect((tempoTrack.getRegions()[0] as KGTempoRegion).getBpm()).toBe(120); }); + + it('expands max bars when a tempo edit makes audio require more space', () => { + const project = KGCore.instance().getCurrentProject(); + const tempoTrack = getTempoTrack(); + tempoTrack.setRegions([ + new KGTempoRegion('region', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4), + ]); + + const audioTrack = new KGAudioTrack('Audio', 1); + audioTrack.setRegions([ + new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 8, 0), + ]); + project.setTracks([audioTrack]); + + const command = new UpdateTempoRegionCommand('region', 60); + command.execute(); + expect(project.getMaxBars()).toBe(9); + expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(8); + command.undo(); + expect(project.getMaxBars()).toBe(8); + expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(4); + }); }); diff --git a/src/core/commands/global-region/UpdateTempoRegionCommand.ts b/src/core/commands/global-region/UpdateTempoRegionCommand.ts index 5c934a4..198dbf6 100644 --- a/src/core/commands/global-region/UpdateTempoRegionCommand.ts +++ b/src/core/commands/global-region/UpdateTempoRegionCommand.ts @@ -2,13 +2,23 @@ import { KGCommand } from '../KGCommand'; import { KGCore } from '../../KGCore'; import { GlobalTrackType } from '../../global-track'; import { KGTempoRegion } from '../../region/KGTempoRegion'; -import { findGlobalTrackByType } from '../../../util/globalTrackUtil'; +import { + findGlobalTrackByType, + getRequiredMaxBarsForAudioRegions, + normalizeTempoRegionsForProject, + restoreAudioRegionLengths, + syncAudioRegionLengthsToPlaybackDuration, + type AudioRegionLengthSnapshot, +} from '../../../util/globalTrackUtil'; export class UpdateTempoRegionCommand extends KGCommand { private readonly regionId: string; private readonly nextBpm: number; private previousBpm: number | null = null; + private previousMaxBars: number | null = null; + private nextMaxBars: number | null = null; private targetRegion: KGTempoRegion | null = null; + private audioRegionLengthSnapshots: AudioRegionLengthSnapshot[] = []; constructor(regionId: string, nextBpm: number) { super(); @@ -30,15 +40,32 @@ export class UpdateTempoRegionCommand extends KGCommand { this.targetRegion = region; this.previousBpm = region.getBpm(); + this.previousMaxBars = project.getMaxBars(); region.setBpm(this.nextBpm); + this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(project); + + const requiredMaxBars = getRequiredMaxBarsForAudioRegions(project); + this.nextMaxBars = Math.max(project.getMaxBars(), requiredMaxBars); + if (this.nextMaxBars > project.getMaxBars()) { + project.setMaxBars(this.nextMaxBars); + normalizeTempoRegionsForProject(project); + } } undo(): void { - if (!this.targetRegion || this.previousBpm === null) { + if (!this.targetRegion || this.previousBpm === null || this.previousMaxBars === null) { throw new Error('Cannot undo tempo update without previous state'); } + const project = KGCore.instance().getCurrentProject(); this.targetRegion.setBpm(this.previousBpm); + if (project.getMaxBars() !== this.previousMaxBars) { + project.setMaxBars(this.previousMaxBars); + normalizeTempoRegionsForProject(project); + } + if (this.audioRegionLengthSnapshots.length > 0) { + restoreAudioRegionLengths(this.audioRegionLengthSnapshots); + } } getDescription(): string { diff --git a/src/core/commands/global-region/WriteTempoTrackCommand.test.ts b/src/core/commands/global-region/WriteTempoTrackCommand.test.ts index 6492279..182607e 100644 --- a/src/core/commands/global-region/WriteTempoTrackCommand.test.ts +++ b/src/core/commands/global-region/WriteTempoTrackCommand.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { KGCore } from '../../KGCore'; import { KGProject } from '../../KGProject'; import { GlobalTrackType } from '../../global-track'; +import { KGAudioRegion } from '../../region/KGAudioRegion'; import { KGTempoRegion } from '../../region/KGTempoRegion'; +import { KGAudioTrack } from '../../track/KGAudioTrack'; import { WriteTempoTrackCommand } from './WriteTempoTrackCommand'; describe('WriteTempoTrackCommand', () => { @@ -90,4 +92,26 @@ describe('WriteTempoTrackCommand', () => { { bpm: 128, startBar: 2, lengthBars: 6 }, ]); }); + + it('expands max bars when the rebuilt tempo map makes audio overflow', () => { + const project = KGCore.instance().getCurrentProject(); + const audioTrack = new KGAudioTrack('Audio', 1); + audioTrack.setRegions([ + new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 8, 0), + ]); + project.setTracks([audioTrack]); + + const command = new WriteTempoTrackCommand(60, []); + command.execute(); + + expect(project.getBpm()).toBe(60); + expect(project.getMaxBars()).toBe(9); + expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(8); + + command.undo(); + + expect(project.getBpm()).toBe(120); + expect(project.getMaxBars()).toBe(8); + expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(4); + }); }); diff --git a/src/core/commands/global-region/WriteTempoTrackCommand.ts b/src/core/commands/global-region/WriteTempoTrackCommand.ts index 972b026..65c1598 100644 --- a/src/core/commands/global-region/WriteTempoTrackCommand.ts +++ b/src/core/commands/global-region/WriteTempoTrackCommand.ts @@ -5,8 +5,13 @@ import { KGTempoRegion } from '../../region/KGTempoRegion'; import { cloneTempoRegions, findGlobalTrackByType, + getRequiredMaxBarsForAudioRegions, getSongEndBar, getSortedTempoRegions, + normalizeTempoRegionsForProject, + restoreAudioRegionLengths, + syncAudioRegionLengthsToPlaybackDuration, + type AudioRegionLengthSnapshot, } from '../../../util/globalTrackUtil'; import { generateUniqueId } from '../../../util/miscUtil'; @@ -24,7 +29,10 @@ export class WriteTempoTrackCommand extends KGCommand { private readonly replacements: WriteTempoEntry[]; private previousRegions: KGTempoRegion[] | null = null; private previousProjectBpm: number | null = null; + private previousMaxBars: number | null = null; private nextRegions: KGTempoRegion[] | null = null; + private nextMaxBars: number | null = null; + private audioRegionLengthSnapshots: AudioRegionLengthSnapshot[] = []; constructor(baseBpm: number, replacements: WriteTempoEntry[]) { super(); @@ -46,17 +54,29 @@ export class WriteTempoTrackCommand extends KGCommand { if (this.nextRegions) { project.setBpm(this.baseBpm); track.setRegions(cloneRegions(this.nextRegions, beatsPerBar)); + syncAudioRegionLengthsToPlaybackDuration(project); + if (this.nextMaxBars !== null) { + project.setMaxBars(this.nextMaxBars); + normalizeTempoRegionsForProject(project); + } return; } const currentRegions = getSortedTempoRegions(track, beatsPerBar); this.previousProjectBpm = project.getBpm(); + this.previousMaxBars = project.getMaxBars(); this.previousRegions = cloneRegions(currentRegions, beatsPerBar); project.setBpm(this.baseBpm); if (this.replacements.length === 0) { this.nextRegions = []; track.setRegions([]); + this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(project); + this.nextMaxBars = getRequiredMaxBarsForAudioRegions(project); + if (this.nextMaxBars > project.getMaxBars()) { + project.setMaxBars(this.nextMaxBars); + normalizeTempoRegionsForProject(project); + } return; } @@ -64,6 +84,7 @@ export class WriteTempoTrackCommand extends KGCommand { if (songEndBar <= 0) { this.nextRegions = []; track.setRegions([]); + this.nextMaxBars = getRequiredMaxBarsForAudioRegions(project); return; } @@ -119,10 +140,16 @@ export class WriteTempoTrackCommand extends KGCommand { this.nextRegions = nextRegions; track.setRegions(cloneRegions(this.nextRegions, beatsPerBar)); + this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(project); + this.nextMaxBars = getRequiredMaxBarsForAudioRegions(project); + if (this.nextMaxBars > project.getMaxBars()) { + project.setMaxBars(this.nextMaxBars); + normalizeTempoRegionsForProject(project); + } } undo(): void { - if (!this.previousRegions || this.previousProjectBpm === null) { + if (!this.previousRegions || this.previousProjectBpm === null || this.previousMaxBars === null) { throw new Error('Cannot undo tempo write without original state'); } @@ -134,7 +161,12 @@ export class WriteTempoTrackCommand extends KGCommand { } project.setBpm(this.previousProjectBpm); + project.setMaxBars(this.previousMaxBars); track.setRegions(cloneRegions(this.previousRegions, beatsPerBar)); + normalizeTempoRegionsForProject(project); + if (this.audioRegionLengthSnapshots.length > 0) { + restoreAudioRegionLengths(this.audioRegionLengthSnapshots); + } } getDescription(): string { diff --git a/src/core/commands/project/ChangeProjectPropertyCommand.ts b/src/core/commands/project/ChangeProjectPropertyCommand.ts index 3d0c345..74e3490 100644 --- a/src/core/commands/project/ChangeProjectPropertyCommand.ts +++ b/src/core/commands/project/ChangeProjectPropertyCommand.ts @@ -2,7 +2,13 @@ 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'; +import { + getRequiredMaxBarsForAudioRegions, + normalizeTempoRegionsForProject, + restoreAudioRegionLengths, + syncAudioRegionLengthsToPlaybackDuration, + type AudioRegionLengthSnapshot, +} from '../../../util/globalTrackUtil'; /** * Interface defining properties that can be updated on a project @@ -26,12 +32,25 @@ export class ChangeProjectPropertyCommand extends KGCommand { private originalProperties: ProjectUpdateProperties = {}; private targetProject: KGProject | null = null; private changedProperties: Set = new Set(); + private audioRegionLengthSnapshots: AudioRegionLengthSnapshot[] = []; constructor(properties: ProjectUpdateProperties) { super(); this.newProperties = properties; } + private clampPlayheadToSongEndIfNeeded(): void { + if (!this.targetProject) { + return; + } + + const core = KGCore.instance(); + const songEndBeat = this.targetProject.getMaxBars() * this.targetProject.getTimeSignature().numerator; + if (core.getPlayheadPosition() > songEndBeat) { + core.setPlayheadPosition(songEndBeat); + } + } + execute(): void { const core = KGCore.instance(); this.targetProject = core.getCurrentProject(); @@ -63,6 +82,7 @@ export class ChangeProjectPropertyCommand extends KGCommand { normalizeTempoRegionsForProject(this.targetProject); this.changedProperties.add('maxBars'); updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${this.newProperties.maxBars}`); + this.clampPlayheadToSongEndIfNeeded(); } // Update currentBars @@ -79,6 +99,19 @@ export class ChangeProjectPropertyCommand extends KGCommand { updatedProperties.push(`bpm: ${this.originalProperties.bpm} → ${this.newProperties.bpm}`); } + if (this.changedProperties.has('bpm')) { + this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(this.targetProject); + const requiredMaxBars = getRequiredMaxBarsForAudioRegions(this.targetProject); + if (requiredMaxBars > this.targetProject.getMaxBars()) { + this.targetProject.setMaxBars(requiredMaxBars); + normalizeTempoRegionsForProject(this.targetProject); + this.changedProperties.add('maxBars'); + if (this.originalProperties.maxBars !== requiredMaxBars) { + updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${requiredMaxBars}`); + } + } + } + // Update time signature if (this.newProperties.timeSignature !== undefined) { const originalTS = this.originalProperties.timeSignature!; @@ -88,8 +121,10 @@ export class ChangeProjectPropertyCommand extends KGCommand { if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) { this.targetProject.setTimeSignature(newTS); normalizeTempoRegionsForProject(this.targetProject); + this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(this.targetProject); this.changedProperties.add('timeSignature'); updatedProperties.push(`timeSignature: ${originalTS.numerator}/${originalTS.denominator} → ${newTS.numerator}/${newTS.denominator}`); + this.clampPlayheadToSongEndIfNeeded(); } } @@ -133,6 +168,7 @@ export class ChangeProjectPropertyCommand extends KGCommand { this.targetProject.setMaxBars(this.originalProperties.maxBars); normalizeTempoRegionsForProject(this.targetProject); restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`); + this.clampPlayheadToSongEndIfNeeded(); } // Restore currentBars (only if it was changed) @@ -153,6 +189,7 @@ export class ChangeProjectPropertyCommand extends KGCommand { normalizeTempoRegionsForProject(this.targetProject); const ts = this.originalProperties.timeSignature; restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`); + this.clampPlayheadToSongEndIfNeeded(); } // Restore key signature (only if it was changed) @@ -167,6 +204,10 @@ export class ChangeProjectPropertyCommand extends KGCommand { restoredProperties.push(`selectedMode: "${this.originalProperties.selectedMode}"`); } + if (this.audioRegionLengthSnapshots.length > 0) { + restoreAudioRegionLengths(this.audioRegionLengthSnapshots); + } + console.log(`Restored project: ${restoredProperties.join(', ')}`); } diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts index c3b9396..7afff29 100644 --- a/src/stores/projectStore.test.ts +++ b/src/stores/projectStore.test.ts @@ -5,6 +5,7 @@ import { KGMidiTrack } from '../core/track/KGMidiTrack'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { createDefaultGlobalTracks } from '../core/global-track'; +import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil'; const pianoRollStateMocks = vi.hoisted(() => ({ setSheetMusicViewEnabled: vi.fn(), @@ -33,6 +34,7 @@ const toneMocks = vi.hoisted(() => { let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; let mockIsMetronomeEnabled = false; let mockShowGlobalTracks = false; +let mockPlayheadPosition = 0; const mockProject = { getTimeSignature: () => ({ numerator: 4, denominator: 4 }), getMaxBars: () => 32, @@ -101,8 +103,10 @@ const mockCore = { clearSelectedItems: vi.fn(), getStatus: () => 'Ready', setStatus: vi.fn(), - getPlayheadPosition: () => 0, - setPlayheadPosition: vi.fn(), + getPlayheadPosition: () => mockPlayheadPosition, + setPlayheadPosition: vi.fn((position: number) => { + mockPlayheadPosition = position; + }), getIsPlaying: () => false, startPlaying: vi.fn().mockResolvedValue(undefined), stopPlaying: vi.fn().mockResolvedValue(undefined), @@ -161,10 +165,13 @@ describe('projectStore piano roll state', () => { pianoRollStateMocks.setPianoRollZoom.mockReset(); mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; currentProject = mockProject; + mockPlayheadPosition = 0; mockCore.startPlaying.mockReset(); mockCore.startPlaying.mockResolvedValue(undefined); mockCore.stopPlaying.mockReset(); mockCore.stopPlaying.mockResolvedValue(undefined); + mockCore.executeCommand.mockReset(); + mockCore.setPlayheadPosition.mockClear(); mockCore.undo.mockReset(); mockCore.undo.mockReturnValue(true); mockCore.redo.mockReset(); @@ -591,4 +598,143 @@ describe('projectStore piano roll state', () => { expect(state.settingsReturnSidePanel).toBeNull(); expect(state.lastActiveSidePanel).toBe('eventList'); }); + + it('refreshes expanded max bars after BPM reduction', async () => { + const { KGAudioTrack: TestAudioTrack } = await import('../core/track/KGAudioTrack'); + const { KGAudioRegion: TestAudioRegion } = await import('../core/region/KGAudioRegion'); + + const audioTrack = new TestAudioTrack('Audio 1', 1); + audioTrack.setTrackIndex(0); + const audioRegion = new TestAudioRegion('audio-region-1', '1', 0, 'clip.wav', 124, 4, 'audio-file-1.wav', 'clip.wav', 8, 0); + audioTrack.setRegions([audioRegion]); + + let projectName = 'Test Project'; + let bpm = 120; + let maxBars = 32; + let currentBars = 0; + let timeSignature = { numerator: 4, denominator: 4 }; + let keySignature = 'C major'; + let selectedMode = 'major'; + const project = { + getName: () => projectName, + setName: (value: string) => { projectName = value; }, + getMaxBars: () => maxBars, + setMaxBars: (value: number) => { maxBars = value; }, + getCurrentBars: () => currentBars, + setCurrentBars: (value: number) => { currentBars = value; }, + getTimeSignature: () => timeSignature, + setTimeSignature: (value: { numerator: number; denominator: number }) => { timeSignature = value; }, + getBarWidthMultiplier: () => 1, + getTracks: () => [audioTrack], + getGlobalTracks: () => createDefaultGlobalTracks(), + getBpm: () => bpm, + setBpm: (value: number) => { bpm = value; }, + getKeySignature: () => keySignature, + setKeySignature: (value: string) => { keySignature = value; }, + getSelectedMode: () => selectedMode, + setSelectedMode: (value: string) => { selectedMode = value; }, + getIsLooping: () => false, + getIsMetronomeEnabled: () => false, + setIsMetronomeEnabled: vi.fn(), + getShowGlobalTracks: () => false, + setShowGlobalTracks: vi.fn(), + getLoopingRange: () => [0, 0] as [number, number], + getPianoRollZoom: () => 1, + }; + currentProject = project as unknown as typeof mockProject; + mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute()); + + const { useProjectStore } = await import('./projectStore'); + + act(() => { + useProjectStore.getState().setBpm(60); + }); + + const state = useProjectStore.getState(); + expect(bpm).toBe(60); + expect(maxBars).toBe(33); + expect(state.maxBars).toBe(33); + expect(audioRegion.getLength()).toBeCloseTo(8); + expect(getAudioRegionDisplayLengthBeats(project as any, audioRegion)).toBeCloseTo(8); + }); + + it('does not auto-shrink max bars after BPM increase', async () => { + const { KGAudioTrack: TestAudioTrack } = await import('../core/track/KGAudioTrack'); + const { KGAudioRegion: TestAudioRegion } = await import('../core/region/KGAudioRegion'); + + const audioTrack = new TestAudioTrack('Audio 1', 1); + audioTrack.setTrackIndex(0); + audioTrack.setRegions([ + new TestAudioRegion('audio-region-1', '1', 0, 'clip.wav', 124, 8, 'audio-file-1.wav', 'clip.wav', 4, 0), + ]); + + let projectName = 'Test Project'; + let bpm = 120; + let maxBars = 40; + let currentBars = 0; + let timeSignature = { numerator: 4, denominator: 4 }; + let keySignature = 'C major'; + let selectedMode = 'major'; + const project = { + getName: () => projectName, + setName: (value: string) => { projectName = value; }, + getMaxBars: () => maxBars, + setMaxBars: (value: number) => { maxBars = value; }, + getCurrentBars: () => currentBars, + setCurrentBars: (value: number) => { currentBars = value; }, + getTimeSignature: () => timeSignature, + setTimeSignature: (value: { numerator: number; denominator: number }) => { timeSignature = value; }, + getBarWidthMultiplier: () => 1, + getTracks: () => [audioTrack], + getGlobalTracks: () => createDefaultGlobalTracks(), + getBpm: () => bpm, + setBpm: (value: number) => { bpm = value; }, + getKeySignature: () => keySignature, + setKeySignature: (value: string) => { keySignature = value; }, + getSelectedMode: () => selectedMode, + setSelectedMode: (value: string) => { selectedMode = value; }, + getIsLooping: () => false, + getIsMetronomeEnabled: () => false, + setIsMetronomeEnabled: vi.fn(), + getShowGlobalTracks: () => false, + setShowGlobalTracks: vi.fn(), + getLoopingRange: () => [0, 0] as [number, number], + getPianoRollZoom: () => 1, + }; + currentProject = project as unknown as typeof mockProject; + mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute()); + + const { useProjectStore } = await import('./projectStore'); + + act(() => { + useProjectStore.getState().setBpm(180); + }); + + const state = useProjectStore.getState(); + expect(bpm).toBe(180); + expect(maxBars).toBe(40); + expect(state.maxBars).toBe(40); + expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(12); + }); + + it('moves the playhead to the song end when max bars shrink past it', async () => { + const { KGProject } = await import('../core/KGProject'); + mockPlayheadPosition = 124; + + currentProject = new KGProject('Test Project', 32, 0, 120) as unknown as typeof mockProject; + mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute()); + + const { useProjectStore } = await import('./projectStore'); + + act(() => { + useProjectStore.getState().setMaxBars(16); + }); + + const state = useProjectStore.getState(); + expect(currentProject.getMaxBars()).toBe(16); + expect(mockPlayheadPosition).toBe(64); + expect(mockCore.setPlayheadPosition).toHaveBeenCalledWith(64); + expect(state.maxBars).toBe(16); + expect(state.playheadPosition).toBe(64); + }); }); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index f9757db..11e3a72 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -1553,8 +1553,7 @@ export const useProjectStore = create((set, get) => { const command = new ChangeProjectPropertyCommand({ bpm }); KGCore.instance().executeCommand(command); - // Update the store state - set({ bpm }); + get().refreshProjectState(); get().bumpAudioWaveformRedrawVersion(); console.log(`Set BPM to ${bpm}`); @@ -1570,10 +1569,7 @@ export const useProjectStore = create((set, get) => { const command = new ChangeProjectPropertyCommand({ maxBars }); KGCore.instance().executeCommand(command); - // Update the store state - set({ maxBars }); - // Sync CSS var so layout adjusts immediately - updateMaxBarsCSS(maxBars); + get().refreshProjectState(); console.log(`Set max bars to ${maxBars}`); } catch (error) { @@ -1973,6 +1969,7 @@ export const useProjectStore = create((set, get) => { selectedMode: project.getSelectedMode(), isMetronomeEnabled: project.getIsMetronomeEnabled(), showGlobalTracks: project.getShowGlobalTracks(), + playheadPosition: core.getPlayheadPosition(), currentTime: formatCurrentTime(project, core.getPlayheadPosition()), }); diff --git a/src/util/globalTrackUtil.test.ts b/src/util/globalTrackUtil.test.ts index 5d19c46..88d6cb7 100644 --- a/src/util/globalTrackUtil.test.ts +++ b/src/util/globalTrackUtil.test.ts @@ -3,7 +3,8 @@ 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'; +import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import { beatRangeToSeconds, beatToSeconds, getAudioRegionDisplayLengthBeats, getEffectiveBpmAtBeat, getRequiredMaxBarsForAudioRegions, normalizeTempoRegionsForProject, secondsToBeat, syncAudioRegionLengthsToPlaybackDuration } from './globalTrackUtil'; describe('globalTrackUtil tempo helpers', () => { it('falls back to project bpm when no tempo regions exist', () => { @@ -72,4 +73,73 @@ describe('globalTrackUtil tempo helpers', () => { const region = new KGAudioRegion('audio', 'track-1', 0, 'Audio', 0, 48, 'file', 'file.wav', 24, 0); expect(getAudioRegionDisplayLengthBeats(project, region)).toBeCloseTo(32); }); + + it('computes required bars for a slower project BPM change', () => { + const project = new KGProject('Tempo', 8, 0, 60); + const track = new KGAudioTrack('Audio', 1); + track.setRegions([ + new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 8, 0), + ]); + project.setTracks([track]); + + expect(getRequiredMaxBarsForAudioRegions(project)).toBe(9); + }); + + it('computes required bars from playable audio after clip offset', () => { + const project = new KGProject('Tempo', 8, 0, 60); + const track = new KGAudioTrack('Audio', 1); + track.setRegions([ + new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 10, 6), + ]); + project.setTracks([track]); + + expect(getRequiredMaxBarsForAudioRegions(project)).toBe(8); + }); + + it('uses the farthest audio region end when multiple regions are present', () => { + const project = new KGProject('Tempo', 8, 0, 60); + const track = new KGAudioTrack('Audio', 1); + track.setRegions([ + new KGAudioRegion('audio-a', '1', 0, 'Audio A', 24, 4, 'file-a', 'a.wav', 4, 0), + new KGAudioRegion('audio-b', '1', 0, 'Audio B', 28, 4, 'file-b', 'b.wav', 12, 0), + ]); + project.setTracks([track]); + + expect(getRequiredMaxBarsForAudioRegions(project)).toBe(10); + }); + + it('extends beyond the current song end using the trailing tempo region BPM', () => { + 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(), 60, 4, 4, 4), + ]); + + const track = new KGAudioTrack('Audio', 1); + track.setRegions([ + new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 8, 0), + ]); + project.setTracks([track]); + + expect(getRequiredMaxBarsForAudioRegions(project)).toBe(9); + }); + + it('syncs stored audio region length to the tempo-aware playback duration', () => { + const project = new KGProject('Tempo', 16, 0, 120); + const track = new KGAudioTrack('Audio', 1); + const region = new KGAudioRegion('audio', '1', 0, 'Audio', 0, 24, 'file', 'file.wav', 12, 0); + track.setRegions([region]); + project.setTracks([track]); + + project.setBpm(240); + syncAudioRegionLengthsToPlaybackDuration(project); + + expect(region.getLength()).toBeCloseTo(48); + expect(getAudioRegionDisplayLengthBeats(project, region)).toBeCloseTo(48); + }); }); diff --git a/src/util/globalTrackUtil.ts b/src/util/globalTrackUtil.ts index 41d82a7..4242d86 100644 --- a/src/util/globalTrackUtil.ts +++ b/src/util/globalTrackUtil.ts @@ -13,6 +13,11 @@ import { KGTempoRegion } from '../core/region/KGTempoRegion'; export const DEFAULT_MARKER_REGION_NAME = 'Marker'; +export interface AudioRegionLengthSnapshot { + region: KGAudioRegion; + length: number; +} + export function ensureDefaultGlobalTracks(project: KGProject): KGGlobalTrack[] { const existingTracks = project.getGlobalTracks?.() ?? []; const nextTracks = createDefaultGlobalTracks(); @@ -360,8 +365,76 @@ export function getAudioRegionDisplayLengthBeats(project: KGProject, region: KGA } 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); + const endBeat = getAudioRegionPlaybackEndBeat(project, region); + return Math.max(0, endBeat - startBeat); +} + +export function getAudioRegionPlaybackEndBeat(project: KGProject, region: KGAudioRegion): number { + const startBeat = region.getStartFromBeat(); + const availableAudioSeconds = Math.max(0, region.getAudioDurationSeconds() - region.getClipStartOffsetSeconds()); + if (availableAudioSeconds <= 0) { + return startBeat; + } + + const targetSeconds = beatToSeconds(project, startBeat) + availableAudioSeconds; + const songEndBeat = getSongEndBeat(project); + const songEndSeconds = beatToSeconds(project, songEndBeat); + + if (targetSeconds <= songEndSeconds) { + return secondsToBeat(project, targetSeconds); + } + + const tailBpm = getEffectiveBpmAtBeat(project, Math.max(0, songEndBeat - 1e-6)); + return songEndBeat + ((targetSeconds - songEndSeconds) / (60 / tailBpm)); +} + +export function getRequiredMaxBarsForAudioRegions(project: KGProject): number { + const beatsPerBar = project.getTimeSignature().numerator; + let requiredBars = project.getMaxBars(); + + for (const track of project.getTracks()) { + for (const region of track.getRegions()) { + if (!(region instanceof KGAudioRegion)) { + continue; + } + + const regionEndBeat = getAudioRegionPlaybackEndBeat(project, region); + const regionRequiredBars = Math.ceil(regionEndBeat / beatsPerBar); + requiredBars = Math.max(requiredBars, regionRequiredBars); + } + } + + return requiredBars; +} + +export function syncAudioRegionLengthsToPlaybackDuration(project: KGProject): AudioRegionLengthSnapshot[] { + const changedRegions: AudioRegionLengthSnapshot[] = []; + + for (const track of project.getTracks()) { + for (const region of track.getRegions()) { + if (!(region instanceof KGAudioRegion)) { + continue; + } + + const nextLength = Math.max(0, getAudioRegionPlaybackEndBeat(project, region) - region.getStartFromBeat()); + if (region.getLength() === nextLength) { + continue; + } + + changedRegions.push({ + region, + length: region.getLength(), + }); + region.setLength(nextLength); + } + } + + return changedRegions; +} + +export function restoreAudioRegionLengths(snapshots: AudioRegionLengthSnapshot[]): void { + snapshots.forEach(({ region, length }) => { + region.setLength(length); + }); }