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