fix: tempo-driven audio region resizing and playhead clamping

This commit is contained in:
Xiaohan-Tian
2026-07-04 21:04:42 -07:00
parent 09dd717262
commit a8c68d97ed
9 changed files with 450 additions and 16 deletions
@@ -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);
});
});
@@ -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 {
@@ -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);
});
});
@@ -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 {