fix: tempo-driven audio region resizing and playhead clamping
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<keyof ProjectUpdateProperties> = 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(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user