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 {
@@ -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(', ')}`);
}
+148 -2
View File
@@ -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);
});
});
+3 -6
View File
@@ -1553,8 +1553,7 @@ export const useProjectStore = create<ProjectState>((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<ProjectState>((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<ProjectState>((set, get) => {
selectedMode: project.getSelectedMode(),
isMetronomeEnabled: project.getIsMetronomeEnabled(),
showGlobalTracks: project.getShowGlobalTracks(),
playheadPosition: core.getPlayheadPosition(),
currentTime: formatCurrentTime(project, core.getPlayheadPosition()),
});
+71 -1
View File
@@ -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);
});
});
+76 -3
View File
@@ -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);
});
}