feat: implemented global tempo (bpm) track

This commit is contained in:
Xiaohan-Tian
2026-05-23 23:18:47 -07:00
parent 8eb3f5d84d
commit 7d275802c9
23 changed files with 1700 additions and 131 deletions
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
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';
describe('globalTrackUtil tempo helpers', () => {
it('falls back to project bpm when no tempo regions exist', () => {
const project = new KGProject('Tempo', 8, 0, 120);
expect(getEffectiveBpmAtBeat(project, 6)).toBe(120);
expect(beatToSeconds(project, 4)).toBeCloseTo(2);
});
it('resolves effective bpm and time across tempo regions', () => {
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, 2, 4),
new KGTempoRegion('b', tempoTrack.getId(), tempoTrack.getTrackIndex(), 60, 2, 6, 4),
]);
expect(getEffectiveBpmAtBeat(project, 2)).toBe(120);
expect(getEffectiveBpmAtBeat(project, 10)).toBe(60);
expect(beatToSeconds(project, 8)).toBeCloseTo(4);
expect(beatToSeconds(project, 12)).toBeCloseTo(8);
expect(beatRangeToSeconds(project, 8, 12)).toBeCloseTo(4);
expect(secondsToBeat(project, 8)).toBeCloseTo(12);
});
it('normalizes trailing tempo coverage when song length grows and shrinks', () => {
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(), 140, 4, 4, 4),
]);
project.setMaxBars(10);
normalizeTempoRegionsForProject(project);
let regions = tempoTrack.getRegions() as KGTempoRegion[];
expect(regions[1].getEndBar()).toBe(10);
project.setMaxBars(6);
normalizeTempoRegionsForProject(project);
regions = tempoTrack.getRegions() as KGTempoRegion[];
expect(regions).toHaveLength(2);
expect(regions[1].getStartBar()).toBe(4);
expect(regions[1].getEndBar()).toBe(6);
});
it('projects audio duration through the tempo map for display length', () => {
const project = new KGProject('Tempo', 16, 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, 12, 4),
]);
const region = new KGAudioRegion('audio', 'track-1', 0, 'Audio', 0, 48, 'file', 'file.wav', 24, 0);
expect(getAudioRegionDisplayLengthBeats(project, region)).toBeCloseTo(32);
});
});
+208 -1
View File
@@ -7,6 +7,8 @@ import {
} from '../core/global-track';
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { KGTempoRegion } from '../core/region/KGTempoRegion';
export const DEFAULT_MARKER_REGION_NAME = 'Marker';
@@ -34,7 +36,7 @@ export function getSongEndBeat(project: KGProject): number {
}
export function findGlobalTrackByType(project: KGProject, type: GlobalTrackType): KGGlobalTrack | null {
return project.getGlobalTracks().find(track => track.getType() === type) ?? null;
return project.getGlobalTracks?.().find(track => track.getType() === type) ?? null;
}
export function findGlobalTrackContainingRegion(
@@ -90,6 +92,29 @@ export function getSongEndBar(project: KGProject): number {
return project.getMaxBars();
}
export function getSortedTempoRegions(track: KGGlobalTrack, beatsPerBar: number): KGTempoRegion[] {
return track.getRegions()
.filter((region): region is KGTempoRegion => region instanceof KGTempoRegion)
.map((region) => {
region.syncBarsFromBeats(beatsPerBar);
region.syncBeatsFromBars(beatsPerBar);
return region;
})
.sort((left, right) => left.getStartBar() - right.getStartBar());
}
export function cloneTempoRegions(regions: KGTempoRegion[], beatsPerBar: number): KGTempoRegion[] {
return regions.map(region => new KGTempoRegion(
region.getId(),
region.getTrackId(),
region.getTrackIndex(),
region.getBpm(),
region.getStartBar(),
region.getLengthBars(),
beatsPerBar
));
}
export function getSortedKeySignatureRegions(track: KGGlobalTrack, beatsPerBar: number): KGKeySignatureRegion[] {
return track.getRegions()
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
@@ -130,10 +155,192 @@ export function findKeySignatureRegionAtBeat(project: KGProject, beat: number):
return findKeySignatureRegionAtBar(project, bar);
}
export function findTempoRegionAtBar(project: KGProject, bar: number): KGTempoRegion | null {
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
return null;
}
const beatsPerBar = project.getTimeSignature().numerator;
return getSortedTempoRegions(track, beatsPerBar)
.find(region => bar >= region.getStartBar() && bar < region.getEndBar()) ?? null;
}
export function findTempoRegionAtBeat(project: KGProject, beat: number): KGTempoRegion | null {
const beatsPerBar = project.getTimeSignature().numerator;
const bar = Math.floor(beat / beatsPerBar);
return findTempoRegionAtBar(project, bar);
}
export function getEffectiveKeySignatureAtBeat(project: KGProject, beat: number): KeySignature {
return findKeySignatureRegionAtBeat(project, beat)?.getKeySignature() ?? project.getKeySignature();
}
export function getEffectiveBpmAtBar(project: KGProject, bar: number): number {
return findTempoRegionAtBar(project, bar)?.getBpm() ?? project.getBpm();
}
export function getEffectiveBpmAtBeat(project: KGProject, beat: number): number {
return findTempoRegionAtBeat(project, beat)?.getBpm() ?? project.getBpm();
}
export function getClampedKeySignatureRegionEndBar(region: KGKeySignatureRegion, maxBars: number): number {
return Math.max(region.getStartBar(), Math.min(region.getEndBar(), maxBars));
}
export function getClampedTempoRegionEndBar(region: KGTempoRegion, maxBars: number): number {
return Math.max(region.getStartBar(), Math.min(region.getEndBar(), maxBars));
}
export function normalizeTempoRegionsForProject(project: KGProject): void {
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
return;
}
const beatsPerBar = project.getTimeSignature().numerator;
const songEndBar = getSongEndBar(project);
const regions = getSortedTempoRegions(track, beatsPerBar);
if (regions.length === 0) {
return;
}
const normalized: KGTempoRegion[] = [];
let currentBar = 0;
for (const region of regions) {
if (currentBar >= songEndBar) {
break;
}
const startBar = Math.max(currentBar, region.getStartBar());
const endBar = Math.max(startBar + 1, Math.min(region.getEndBar(), songEndBar));
if (endBar <= startBar) {
continue;
}
normalized.push(new KGTempoRegion(
region.getId(),
track.getId(),
track.getTrackIndex(),
region.getBpm(),
startBar,
endBar - startBar,
beatsPerBar
));
currentBar = endBar;
}
if (normalized.length === 0) {
track.setRegions([]);
return;
}
const firstRegion = normalized[0];
if (firstRegion.getStartBar() > 0) {
firstRegion.setBarRange(0, firstRegion.getEndBar(), beatsPerBar);
}
const lastRegion = normalized[normalized.length - 1];
if (lastRegion.getEndBar() < songEndBar) {
lastRegion.setLengthBars(songEndBar - lastRegion.getStartBar(), beatsPerBar);
} else if (lastRegion.getEndBar() > songEndBar) {
lastRegion.setLengthBars(songEndBar - lastRegion.getStartBar(), beatsPerBar);
}
track.setRegions(normalized);
}
export function beatToSeconds(project: KGProject, beat: number): number {
const clampedBeat = Math.max(0, beat);
const timeSignature = project.getTimeSignature?.();
const beatsPerBar = timeSignature?.numerator ?? 4;
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!tempoTrack) {
return clampedBeat * 60 / project.getBpm();
}
const tempoRegions = getSortedTempoRegions(tempoTrack, beatsPerBar);
if (tempoRegions.length === 0) {
return clampedBeat * 60 / project.getBpm();
}
let seconds = 0;
let traversedBeat = 0;
for (const region of tempoRegions) {
const regionStartBeat = region.getStartBar() * beatsPerBar;
const regionEndBeat = region.getEndBar() * beatsPerBar;
const effectiveStartBeat = Math.max(traversedBeat, regionStartBeat);
if (clampedBeat <= effectiveStartBeat) {
return seconds;
}
const coveredEndBeat = Math.min(clampedBeat, regionEndBeat);
if (coveredEndBeat > effectiveStartBeat) {
seconds += (coveredEndBeat - effectiveStartBeat) * (60 / region.getBpm());
}
if (clampedBeat <= regionEndBeat) {
return seconds;
}
traversedBeat = regionEndBeat;
}
return seconds + Math.max(0, clampedBeat - traversedBeat) * (60 / project.getBpm());
}
export function beatRangeToSeconds(project: KGProject, startBeat: number, endBeat: number): number {
if (endBeat <= startBeat) {
return 0;
}
return beatToSeconds(project, endBeat) - beatToSeconds(project, startBeat);
}
export function secondsToBeat(project: KGProject, seconds: number): number {
const clampedSeconds = Math.max(0, seconds);
const timeSignature = project.getTimeSignature?.();
const beatsPerBar = timeSignature?.numerator ?? 4;
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!tempoTrack) {
return clampedSeconds / (60 / project.getBpm());
}
const tempoRegions = getSortedTempoRegions(tempoTrack, beatsPerBar);
if (tempoRegions.length === 0) {
return clampedSeconds / (60 / project.getBpm());
}
let remainingSeconds = clampedSeconds;
let traversedBeat = 0;
for (const region of tempoRegions) {
const regionStartBeat = region.getStartBar() * beatsPerBar;
const regionEndBeat = region.getEndBar() * beatsPerBar;
const effectiveStartBeat = Math.max(traversedBeat, regionStartBeat);
const regionDurationSeconds = (regionEndBeat - effectiveStartBeat) * (60 / region.getBpm());
if (remainingSeconds <= regionDurationSeconds) {
return effectiveStartBeat + (remainingSeconds / (60 / region.getBpm()));
}
remainingSeconds -= regionDurationSeconds;
traversedBeat = regionEndBeat;
}
return traversedBeat + remainingSeconds / (60 / project.getBpm());
}
export function getAudioRegionDisplayLengthBeats(project: KGProject, region: KGAudioRegion): number {
if (!project.getTimeSignature?.() || !project.getBpm?.()) {
return region.getLength();
}
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);
}