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 1/2] 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); + }); } From 722ba685ebe0525415d210d8c4b5626ec9c5f60e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:41:35 -0700 Subject: [PATCH 2/2] fix: persist separator results until project switch --- src/components/KGOnePanel.test.tsx | 81 ++++++++++- src/components/KGOnePanel.tsx | 219 +++++++++++++++-------------- 2 files changed, 192 insertions(+), 108 deletions(-) diff --git a/src/components/KGOnePanel.test.tsx b/src/components/KGOnePanel.test.tsx index 74a86d7..b895434 100644 --- a/src/components/KGOnePanel.test.tsx +++ b/src/components/KGOnePanel.test.tsx @@ -12,6 +12,8 @@ const { mockLocalSeparatorDownload } = vi.hoisted(() => ({ let kgoneEnabled = false; let selectedRegionIds: string[] = []; +let projectName = 'Test Project'; +let savedProjectName = 'Test Project'; let localModelCached: Record = {}; let localSeparationResult: Array<{ name: string; blob: Blob }> = []; @@ -21,7 +23,8 @@ const mockExecuteCommand = vi.fn(); vi.mock('../stores/projectStore', () => ({ useProjectStore: () => ({ selectedRegionIds, - projectName: 'Test Project', + projectName, + savedProjectName, bpm: 120, keySignature: 'C major', timeSignature: { numerator: 4, denominator: 4 }, @@ -127,6 +130,8 @@ describe('KGOnePanel local separator mode', () => { beforeEach(() => { kgoneEnabled = false; selectedRegionIds = []; + projectName = 'Test Project'; + savedProjectName = 'Test Project'; localModelCached = {}; localSeparationResult = [ { name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) }, @@ -185,6 +190,7 @@ describe('KGOnePanel local separator mode', () => { render(); expect(await screen.findByText(/Select an audio region on the timeline/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Separate Stems' })).toBeDisabled(); }); it('renders local separation outputs after processing completes', async () => { @@ -202,6 +208,79 @@ describe('KGOnePanel local separator mode', () => { }); }); + it('keeps stem results visible and disables separation after the selection is cleared', async () => { + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; + selectedRegionIds = ['audio-region-1']; + + const { rerender } = render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' })); + + await waitFor(() => { + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + expect(screen.getByText('Vocals')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument(); + }); + + selectedRegionIds = []; + rerender(); + + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + expect(screen.getByText('Vocals')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument(); + expect(screen.queryByText(/Select an audio region on the timeline/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Separate Stems' })).toBeDisabled(); + }); + + it('clears stem results when the saved project changes', async () => { + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; + selectedRegionIds = ['audio-region-1']; + + const { rerender } = render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' })); + + await waitFor(() => { + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + }); + expect(screen.getAllByText('Separation complete.').length).toBeGreaterThan(0); + + savedProjectName = 'Loaded Project'; + projectName = 'Loaded Project'; + selectedRegionIds = []; + rerender(); + + await waitFor(() => { + expect(screen.queryByText('Instrumental')).not.toBeInTheDocument(); + expect(screen.queryByText('Vocals')).not.toBeInTheDocument(); + }); + expect(screen.queryAllByText('Separation complete.')).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'Import All Stems to Timeline' })).not.toBeInTheDocument(); + expect(screen.getByText(/Select an audio region on the timeline/)).toBeInTheDocument(); + }); + + it('preserves stem results when only the project name changes', async () => { + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; + selectedRegionIds = ['audio-region-1']; + + const { rerender } = render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' })); + + await waitFor(() => { + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + }); + + projectName = 'Renamed Project'; + selectedRegionIds = []; + rerender(); + + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + expect(screen.getByText('Vocals')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument(); + expect(screen.queryByText(/Select an audio region on the timeline/)).not.toBeInTheDocument(); + }); + it('uses Demucs defaults and renders four local stem players', async () => { localModelCached[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s] = true; localSeparationResult = [ diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx index 64235e6..e4a177e 100644 --- a/src/components/KGOnePanel.tsx +++ b/src/components/KGOnePanel.tsx @@ -856,7 +856,7 @@ function countRepaintTracks(sourceTrackName: string): number { const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { const { t } = useI18n(); - const { selectedRegionIds, projectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore(); + const { selectedRegionIds, projectName, savedProjectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore(); const localOnlyMode = mode === 'local-separator'; const availableSeparatorModels = localOnlyMode ? LOCAL_SEPARATOR_MODEL_OPTIONS : SERVER_SEPARATOR_MODELS; const [model, setModel] = useState(availableSeparatorModels[0].value); @@ -910,12 +910,14 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { setStemAudioUrls([]); setGenStatus('idle'); setGenHint(''); + setLocalProgressPercent(0); + setLocalProgressText(''); setErrorMsg(''); setIsImporting(false); setImportError(''); originalRegionRef.current = null; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectName]); + }, [savedProjectName]); useEffect(() => { setModel(availableSeparatorModels[0].value); @@ -967,6 +969,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { } return null; }, [selectedRegionIds]); + const hasStemResults = stemAudioUrls.length > 0; const getConfiguredLocalSeparatorModelUrl = useCallback(() => { const configured = ConfigManager.instance().get(currentLocalModelConfig.download.configKey); @@ -1450,119 +1453,121 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { )} - {selectedAudioRegion ? ( - <> + <> + {selectedAudioRegion && (
{t('kgone.shared.selectedRegion')}
{selectedAudioRegion.region.getName()}
{t('kgone.shared.track')}
{selectedAudioRegion.trackName}
+ )} -
- - -
- - {localOnlyMode && ( - -
- - setLocalChunkDurationSeconds(e.target.value)} - placeholder={t('kgone.separator.field.chunkDurationPlaceholder')} - /> -
-
- - setLocalOverlap(e.target.value)} - /> -
-
- )} - - {/* Stem audio players — shown once separation is complete */} - {stemAudioUrls.length > 0 && ( -
- {stemAudioUrls.map(stem => ( -
-
{stem.name}
- -
- ))} -
- )} - - {/* Drag-to-track hint — shown after successful separation */} - {genStatus === 'done' && ( -
- )} - - {/* Bulk import button — shown after successful separation */} - {genStatus === 'done' && stemAudioUrls.length > 0 && ( - <> - - {importError &&
{importError}
} - - )} - - {/* Separation error message */} - {genStatus === 'error' && errorMsg && ( -
{errorMsg}
- )} - - - - {/* Status hint below button */} - {(localOnlyMode ? localProgressText : genHint) && ( -
{localOnlyMode ? localProgressText : genHint}
- )} - - ) : ( -
- {localOnlyMode && !isLocalModelCached - ? t('kgone.separator.hint.noRegion.download', { model: getModelLabel(currentLocalModelConfig.id, t) }) - : t('kgone.separator.hint.noRegion.select')} +
+ +
- )} + + {localOnlyMode && ( + +
+ + setLocalChunkDurationSeconds(e.target.value)} + placeholder={t('kgone.separator.field.chunkDurationPlaceholder')} + /> +
+
+ + setLocalOverlap(e.target.value)} + /> +
+
+ )} + + {/* Stem audio players — shown once separation is complete */} + {hasStemResults && ( +
+ {stemAudioUrls.map(stem => ( +
+
{stem.name}
+ +
+ ))} +
+ )} + + {/* Drag-to-track hint — shown after successful separation */} + {hasStemResults && ( +
+ )} + + {/* Bulk import button — shown after successful separation */} + {hasStemResults && ( + <> + + {importError &&
{importError}
} + + )} + + {/* Separation error message */} + {genStatus === 'error' && errorMsg && ( +
{errorMsg}
+ )} + + + + {/* Status hint below button */} + {(localOnlyMode ? localProgressText : genHint) && ( +
{localOnlyMode ? localProgressText : genHint}
+ )} + + {!selectedAudioRegion && !hasStemResults && ( +
+ {localOnlyMode && !isLocalModelCached + ? t('kgone.separator.hint.noRegion.download', { model: getModelLabel(currentLocalModelConfig.id, t) }) + : t('kgone.separator.hint.noRegion.select')} +
+ )} + {!localOnlyMode && (