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
+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);
});
}