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
+9 -32
View File
@@ -11,6 +11,7 @@ import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
import { KGRegion } from './region/KGRegion';
import { generateUniqueId } from '../util/miscUtil';
import { KGCommand, KGCommandHistory } from './commands';
import { getEffectiveBpmAtBeat } from '../util/globalTrackUtil';
interface PlaybackStartOptions {
preserveLoopPreroll?: boolean;
@@ -150,7 +151,7 @@ export class KGCore {
try {
const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized()) {
audioInterface.setBpm(project.getBpm());
audioInterface.setBpm(getEffectiveBpmAtBeat(project, 0));
}
} catch (error) {
console.error('Error syncing project with audio interface:', error);
@@ -253,9 +254,6 @@ export class KGCore {
audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
});
// Sync BPM and transport settings
audioInterface.setBpm(this.currentProject.getBpm());
audioInterface.setTransportPosition(this.playheadPosition);
console.log("Playback prepared successfully");
@@ -388,22 +386,8 @@ export class KGCore {
this.stopPlaybackUpdates();
return;
}
// Calculate current playhead position based on elapsed time
const elapsedMs = performance.now() - this.playbackStartTime;
// Get playback delay from config
const configManager = ConfigManager.instance();
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
const playbackDelayMs = playbackDelaySeconds * 1000;
// Subtract the delay from elapsed time for visual sync
// During the initial delay period, playhead stays at start position
const adjustedElapsedMs = Math.max(0, elapsedMs - playbackDelayMs);
const bpm = this.currentProject.getBpm();
const beatsPerMs = bpm / (60 * 1000);
let newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs);
const audioInterface = KGAudioInterface.instance();
let newPosition = audioInterface.getTransportPosition();
// Handle looping or end-of-project
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
@@ -415,10 +399,10 @@ export class KGCore {
const loopStartBeats = startBar * beatsPerBar;
const loopEndBeats = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
const loopLengthBeats = loopEndBeats - loopStartBeats;
const previousPosition = this.playheadPosition;
// Wrap playhead position within loop range
if (newPosition >= loopEndBeats) {
// Tone.Transport position wraps back to loop start. Preserve the loop-end callback behavior.
if (this.loopBoundaryReachedCallback && previousPosition < loopEndBeats && newPosition < previousPosition) {
if (this.loopBoundaryReachedCallback) {
const callback = this.loopBoundaryReachedCallback;
this.loopBoundaryReachedCallback = null;
@@ -426,16 +410,9 @@ export class KGCore {
callback(loopEndBeats);
return;
}
// Calculate how far we've overshot and wrap back
const overshot = newPosition - loopEndBeats;
newPosition = loopStartBeats + (overshot % loopLengthBeats);
// Reset timing reference to prevent drift accumulation
const newElapsedBeats = newPosition - loopStartBeats;
this.playbackStartTime = performance.now() - (newElapsedBeats / beatsPerMs) - playbackDelayMs;
this.playbackStartPosition = loopStartBeats;
}
newPosition = Math.max(loopStartBeats, Math.min(newPosition, loopEndBeats));
} else {
// Non-looping mode: stop at project end
const maxBars = this.currentProject.getMaxBars();
+53 -44
View File
@@ -35,6 +35,8 @@ import type { KGAudioRegion } from '../region/KGAudioRegion';
import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager';
import { KGMetronome } from './KGMetronome';
import { GlobalTrackType } from '../global-track';
import { beatRangeToSeconds, beatToSeconds, findGlobalTrackByType, getEffectiveBpmAtBeat, getSortedTempoRegions, secondsToBeat } from '../../util/globalTrackUtil';
interface PreparePlaybackOptions {
allowStartBeforeLoopStart?: boolean;
@@ -445,14 +447,14 @@ export class KGAudioInterface {
try {
// Set project BPM and time signature FIRST (this affects timing calculations)
Tone.Transport.bpm.value = project.getBpm();
Tone.Transport.bpm.value = getEffectiveBpmAtBeat(project, Math.max(startPosition, 0));
const timeSignature = project.getTimeSignature();
const secondsPerBeat = 60 / project.getBpm();
const secondsPerBeat = 60 / Math.max(1, getEffectiveBpmAtBeat(project, Math.max(startPosition, 0)));
const resumeSafetyOffsetBeats =
KGAudioInterface.AUDIO_RESUME_SAFETY_OFFSET_SECONDS / secondsPerBeat;
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
console.log(`Setting Tone.js BPM to ${Tone.Transport.bpm.value}, actual value: ${Tone.Transport.bpm.value}`);
// Configure loop settings
const isLooping = project.getIsLooping();
@@ -486,8 +488,10 @@ export class KGAudioInterface {
console.log("Loop mode disabled");
}
this.scheduleTempoChanges(project, Math.max(startPosition, scheduleStartBeat), scheduleEndBeat);
if (startPosition < 0) {
this.delayedTransportStartSeconds = Math.abs(startPosition) * secondsPerBeat;
this.delayedTransportStartSeconds = Math.abs(startPosition) * (60 / project.getBpm());
this.virtualPrerollStartBeat = startPosition;
this.virtualPrerollStartAudioTime = null;
} else {
@@ -513,7 +517,7 @@ export class KGAudioInterface {
const automationWindowStartBeat = isLooping ? Math.max(startPosition, scheduleStartBeat) : startPosition;
this.applyTrackAutomationAtBeat(track, automationWindowStartBeat);
this.scheduleTrackAutomation(track, automationWindowStartBeat, scheduleEndBeat, interpolationIntervalMs, project.getBpm());
this.scheduleTrackAutomation(track, automationWindowStartBeat, scheduleEndBeat, interpolationIntervalMs, getEffectiveBpmAtBeat(project, automationWindowStartBeat));
console.log(`Track ${trackId} has audio bus: ${audioBus ? 'true' : 'false'}; type: ${track.getType()}`);
@@ -622,7 +626,7 @@ export class KGAudioInterface {
scheduleEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
bpm: project.getBpm(),
bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
defaultValue: MIDI_PITCH_BEND_CENTER,
}
);
@@ -648,7 +652,7 @@ export class KGAudioInterface {
scheduleEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
bpm: project.getBpm(),
bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
defaultValue: 127,
interpolationMode: 'linear',
quantizeValue: clampMidiControllerValue,
@@ -676,7 +680,7 @@ export class KGAudioInterface {
scheduleEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
bpm: project.getBpm(),
bpm: getEffectiveBpmAtBeat(project, pitchBendWindowStartBeat),
defaultValue: 0,
interpolationMode: 'step',
quantizeValue: clampMidiControllerValue,
@@ -699,20 +703,19 @@ export class KGAudioInterface {
});
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
const noteDurationBeats = absoluteEndBeat - absoluteStartBeat;
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
const noteDuration = this.beatsToToneTime(noteDurationBeats);
const noteDurationSeconds = beatRangeToSeconds(project, absoluteStartBeat, absoluteEndBeat);
const velocity = note.getVelocity() / 127;
const noteName = pitchToNoteNameString(note.getPitch());
console.log(
`Scheduling note ${noteName} at beat ${Number(absoluteStartBeat.toFixed ? absoluteStartBeat.toFixed(3) : absoluteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
`Scheduling note ${noteName} at beat ${Number(absoluteStartBeat.toFixed ? absoluteStartBeat.toFixed(3) : absoluteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDurationSeconds).toFixed(3))}, delay: ${playbackDelay}s`
);
const eventId = Tone.Transport.schedule((time) => {
const hasSoloedTracks = this.hasSoloedTracks();
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
audioBus.triggerPitchBendAwareAttack(note.getPitch(), time + playbackDelay, velocity, Tone.Time(noteDuration).toSeconds());
audioBus.triggerPitchBendAwareAttack(note.getPitch(), time + playbackDelay, velocity, noteDurationSeconds);
}
}, noteStartTime);
@@ -740,17 +743,15 @@ export class KGAudioInterface {
// Skip regions that start before playback start position
if (regionStartBeat < startPosition) {
// Region starts before playhead — calculate offset into the audio file
const offsetBeats = startPosition - regionStartBeat;
const offsetSeconds = offsetBeats * secondsPerBeat;
const remainingBeats = regionEndBeat - startPosition;
const remainingSeconds = remainingBeats * secondsPerBeat;
const offsetSeconds = beatRangeToSeconds(project, regionStartBeat, startPosition);
const remainingSeconds = beatRangeToSeconds(project, startPosition, regionEndBeat);
const audioFileId = audioRegion.getAudioFileId();
// Cap duration at loop boundary to prevent overlap on loop re-trigger
let effectiveRemainingSeconds = remainingSeconds;
if (isLooping) {
const maxDurationBeats = scheduleEndBeat - startPosition;
const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
const maxDurationSeconds = beatRangeToSeconds(project, startPosition, startPosition + maxDurationBeats);
effectiveRemainingSeconds = Math.min(remainingSeconds, maxDurationSeconds);
}
@@ -769,7 +770,7 @@ export class KGAudioInterface {
startPosition + resumeSafetyOffsetBeats,
regionEndBeat
);
const extraOffsetSeconds = (safeResumeBeat - startPosition) * secondsPerBeat;
const extraOffsetSeconds = beatRangeToSeconds(project, startPosition, safeResumeBeat);
const adjustedOffsetSeconds = clipStartOffsetSeconds + offsetSeconds + extraOffsetSeconds;
const adjustedRemainingSeconds = Math.max(
0,
@@ -800,7 +801,7 @@ export class KGAudioInterface {
const audioFileId = audioRegion.getAudioFileId();
// Effective duration: region length in seconds, capped at available audio after clip offset
const regionLengthSeconds = region.getLength() * secondsPerBeat;
const regionLengthSeconds = beatRangeToSeconds(project, regionStartBeat, regionEndBeat);
let effectiveDurationSeconds = Math.min(
regionLengthSeconds,
audioDurationSeconds - clipStartOffsetSeconds
@@ -814,7 +815,7 @@ export class KGAudioInterface {
// Cap duration at loop boundary to prevent overlap on loop re-trigger
if (isLooping) {
const maxDurationBeats = scheduleEndBeat - regionStartBeat;
const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
const maxDurationSeconds = beatRangeToSeconds(project, regionStartBeat, regionStartBeat + maxDurationBeats);
effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds);
}
@@ -1176,8 +1177,12 @@ export class KGAudioInterface {
return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
}
const position = Tone.Transport.position;
return this.toneTimeToBeats(position);
const transportSeconds = Number(Tone.Transport.seconds);
if (Number.isFinite(transportSeconds)) {
return secondsToBeat(KGCore.instance().getCurrentProject(), transportSeconds);
}
return this.toneTimeToBeats(Tone.Transport.position);
} catch (error) {
console.error('Error getting transport position:', error);
return 0;
@@ -1472,6 +1477,30 @@ export class KGAudioInterface {
});
}
private scheduleTempoChanges(project: KGProject, windowStartBeat: number, windowEndBeat: number): void {
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!tempoTrack) {
return;
}
const tempoRegions = getSortedTempoRegions(tempoTrack, project.getTimeSignature().numerator);
if (tempoRegions.length === 0) {
return;
}
tempoRegions.forEach((region) => {
const regionStartBeat = region.getStartBar() * project.getTimeSignature().numerator;
if (regionStartBeat <= windowStartBeat || regionStartBeat >= windowEndBeat) {
return;
}
const eventId = Tone.Transport.schedule(() => {
Tone.Transport.bpm.value = region.getBpm();
}, this.beatsToToneTime(regionStartBeat));
this.scheduledEvents.add(eventId);
});
}
private clearTrackAutomationOverrides(): void {
this.trackAudioBuses.forEach(audioBus => {
audioBus.setAutomationVolume(null);
@@ -1527,35 +1556,15 @@ export class KGAudioInterface {
* This approach handles triplets and all subdivisions correctly
*/
private beatsToToneTime(beats: number): Tone.Unit.Time {
const project = KGCore.instance().getCurrentProject();
const bpm = project.getBpm();
// Calculate seconds per beat - BPM is always quarter note beats per minute
// Time signature denominator doesn't affect BPM, only subdivision
const secondsPerBeat = 60 / bpm;
// Convert beats directly to seconds
const totalSeconds = beats * secondsPerBeat;
return totalSeconds as Tone.Unit.Time;
return beatToSeconds(KGCore.instance().getCurrentProject(), beats) as Tone.Unit.Time;
}
/**
* Convert Tone.js time format to beats
*/
private toneTimeToBeats(toneTime: Tone.Unit.Time): number {
const project = KGCore.instance().getCurrentProject();
const bpm = project.getBpm();
// Calculate seconds per beat - BPM is always quarter note beats per minute
// Time signature denominator doesn't affect BPM, only subdivision
const secondsPerBeat = 60 / bpm;
// Tone.Time() can handle both numbers and strings
const seconds = Tone.Time(toneTime).toSeconds();
const beats = seconds / secondsPerBeat;
return beats;
return secondsToBeat(KGCore.instance().getCurrentProject(), seconds);
}
/**
+23 -11
View File
@@ -25,6 +25,8 @@ import { KGToneBuffersPool } from './KGToneBuffersPool';
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
import { KGAudioInterface } from './KGAudioInterface';
import { KGAudioBus } from './KGAudioBus';
import { beatRangeToSeconds, beatToSeconds, findGlobalTrackByType, getEffectiveBpmAtBeat, getSortedTempoRegions } from '../../util/globalTrackUtil';
import { GlobalTrackType } from '../global-track';
import { ConfigManager } from '../config/ConfigManager';
import { Mp3Encoder } from '@breezystack/lamejs';
@@ -92,7 +94,7 @@ export class KGOfflineRenderer {
const tailSeconds = options?.tailSeconds ?? 2;
// Calculate render duration in seconds
const bpm = project.getBpm();
const bpm = getEffectiveBpmAtBeat(project, 0);
const secondsPerBeat = 60 / bpm;
const timeSignature = project.getTimeSignature();
const beatsPerBar = timeSignature.numerator;
@@ -279,7 +281,7 @@ export class KGOfflineRenderer {
// else: no content found, keep the full project range as fallback
}
const durationSeconds = (renderEndBeat - renderStartBeat) * secondsPerBeat + tailSeconds;
const durationSeconds = beatRangeToSeconds(project, renderStartBeat, renderEndBeat) + tailSeconds;
console.log(`Offline render: ${durationSeconds}s (beats ${renderStartBeat}-${renderEndBeat}), ${sampleRate}Hz, ${channels}ch`);
@@ -289,8 +291,20 @@ export class KGOfflineRenderer {
const masterGain = new Tone.Gain(1).toDestination();
// Set BPM and time signature on offline transport
context.transport.bpm.value = bpm;
context.transport.bpm.value = getEffectiveBpmAtBeat(project, renderStartBeat);
context.transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
const tempoRegions = tempoTrack ? getSortedTempoRegions(tempoTrack, timeSignature.numerator) : [];
tempoRegions.forEach((region) => {
const regionStartBeat = region.getStartBar() * timeSignature.numerator;
if (regionStartBeat <= renderStartBeat || regionStartBeat >= renderEndBeat) {
return;
}
context.transport.schedule((time) => {
context.transport.bpm.setValueAtTime(region.getBpm(), time);
}, beatToSeconds(project, regionStartBeat) - beatToSeconds(project, renderStartBeat));
});
// ---- Create MIDI track samplers ----
const samplerPromises: Promise<void>[] = [];
@@ -343,7 +357,7 @@ export class KGOfflineRenderer {
renderEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
bpm: project.getBpm(),
bpm: getEffectiveBpmAtBeat(project, renderStartBeat),
defaultValue: MIDI_PITCH_BEND_CENTER,
}
);
@@ -353,7 +367,7 @@ export class KGOfflineRenderer {
renderEndBeat,
{
maxIntervalMs: interpolationIntervalMs,
bpm: project.getBpm(),
bpm: getEffectiveBpmAtBeat(project, renderStartBeat),
defaultValue: 127,
interpolationMode: 'linear',
quantizeValue: clampMidiControllerValue,
@@ -366,14 +380,13 @@ export class KGOfflineRenderer {
// Skip notes outside render range
if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue;
const offsetBeat = note.startBeat - renderStartBeat;
const noteStartTime = offsetBeat * secondsPerBeat;
const noteStartTime = beatToSeconds(project, note.startBeat) - beatToSeconds(project, renderStartBeat);
const sustainedEndBeat = resolveSustainExtendedEndBeat(
trackInfo.controllerEventsByType[64],
note.endBeat,
0
);
const noteDuration = Math.max(0, sustainedEndBeat - note.startBeat) * secondsPerBeat;
const noteDuration = beatRangeToSeconds(project, note.startBeat, sustainedEndBeat);
const velocity = note.velocity / 127;
const initialNormalizedPitchBend = midiPitchBendToNormalized(
resolveMidiAutomationValueAtBeat(trackInfo.pitchBends, note.startBeat, MIDI_PITCH_BEND_CENTER)
@@ -451,13 +464,12 @@ export class KGOfflineRenderer {
const clipStartOffsetSeconds = regionInfo.clipStartOffsetSeconds;
const audioDurationSeconds = regionInfo.audioDurationSeconds;
const regionLengthSeconds = regionInfo.lengthBeats * secondsPerBeat;
const regionLengthSeconds = beatRangeToSeconds(project, regionStartBeat, regionEndBeat);
const effectiveDurationSeconds = Math.min(regionLengthSeconds, audioDurationSeconds - clipStartOffsetSeconds);
if (effectiveDurationSeconds <= 0) continue;
const offsetBeat = regionStartBeat - renderStartBeat;
const regionStartTime = Math.max(0, offsetBeat * secondsPerBeat);
const regionStartTime = Math.max(0, beatToSeconds(project, regionStartBeat) - beatToSeconds(project, renderStartBeat));
// Create buffer source NOW while the offline context is still active.
// Schedule callbacks fire during rendering after Tone.js restores the
@@ -0,0 +1,97 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import {
cloneTempoRegions,
findGlobalTrackByType,
findTempoRegionAtBar,
getEffectiveBpmAtBar,
getSongEndBar,
getSortedTempoRegions,
} from '../../../util/globalTrackUtil';
export class CreateTempoRegionCommand extends KGCommand {
private readonly startBar: number;
private readonly regionId: string;
private createdRegion: KGTempoRegion | null = null;
private previousRegions: KGTempoRegion[] = [];
constructor(startBar: number, regionId?: string) {
super();
this.startBar = startBar;
this.regionId = regionId ?? generateUniqueId('KGTempoRegion');
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found');
}
const existingRegions = getSortedTempoRegions(track, beatsPerBar);
this.previousRegions = cloneTempoRegions(existingRegions, beatsPerBar);
const songEndBar = getSongEndBar(project);
const clampedStartBar = Math.max(0, Math.min(this.startBar, Math.max(0, songEndBar - 1)));
if (existingRegions.length === 0) {
this.createdRegion = new KGTempoRegion(
this.regionId,
track.getId(),
track.getTrackIndex(),
getEffectiveBpmAtBar(project, clampedStartBar),
0,
Math.max(1, songEndBar),
beatsPerBar
);
track.setRegions([this.createdRegion]);
return;
}
const containingRegion = findTempoRegionAtBar(project, clampedStartBar);
if (!containingRegion) {
throw new Error(`No tempo region covers bar ${clampedStartBar}`);
}
const regionStartBar = containingRegion.getStartBar();
const regionEndBar = containingRegion.getEndBar();
if (clampedStartBar <= regionStartBar || clampedStartBar >= regionEndBar) {
throw new Error(`Bar ${clampedStartBar} is not a valid split point`);
}
containingRegion.setLengthBars(clampedStartBar - regionStartBar, beatsPerBar);
this.createdRegion = new KGTempoRegion(
this.regionId,
track.getId(),
track.getTrackIndex(),
containingRegion.getBpm(),
clampedStartBar,
regionEndBar - clampedStartBar,
beatsPerBar
);
track.setRegions([...existingRegions, this.createdRegion].sort((left, right) => left.getStartBar() - right.getStartBar()));
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneTempoRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Create tempo change at bar ${this.startBar + 1}`;
}
public getCreatedRegion(): KGTempoRegion | null {
return this.createdRegion;
}
}
@@ -0,0 +1,143 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import {
cloneTempoRegions,
findGlobalTrackByType,
getSortedTempoRegions,
} from '../../../util/globalTrackUtil';
export class DeleteTempoRegionCommand extends KGCommand {
private readonly regionId: string;
private previousRegions: KGTempoRegion[] = [];
private deletedBpm = '';
constructor(regionId: string) {
super();
this.regionId = regionId;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found');
}
const regions = getSortedTempoRegions(track, beatsPerBar);
this.previousRegions = cloneTempoRegions(regions, beatsPerBar);
const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
if (targetIndex === -1) {
throw new Error(`Tempo region with ID ${this.regionId} not found`);
}
const targetRegion = regions[targetIndex];
this.deletedBpm = `${targetRegion.getBpm()} BPM`;
if (regions.length === 1) {
track.setRegions([]);
return;
}
const nextRegions = [...regions];
const deletedLengthBars = targetRegion.getLengthBars();
if (targetIndex === 0) {
const nextRegion = nextRegions[1];
nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
nextRegions.splice(0, 1);
track.setRegions(nextRegions);
return;
}
const previousRegion = nextRegions[targetIndex - 1];
previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
nextRegions.splice(targetIndex, 1);
track.setRegions(nextRegions);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneTempoRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Delete tempo "${this.deletedBpm || this.regionId}"`;
}
}
export class DeleteMultipleTempoRegionsCommand extends KGCommand {
private readonly regionIds: string[];
private previousRegions: KGTempoRegion[] = [];
constructor(regionIds: string[]) {
super();
this.regionIds = regionIds;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found');
}
const regions = getSortedTempoRegions(track, beatsPerBar);
this.previousRegions = cloneTempoRegions(regions, beatsPerBar);
let workingRegions = cloneTempoRegions(regions, beatsPerBar);
for (const regionId of this.regionIds) {
const targetIndex = workingRegions.findIndex(region => region.getId() === regionId);
if (targetIndex === -1) {
continue;
}
const deletedRegion = workingRegions[targetIndex];
const deletedLengthBars = deletedRegion.getLengthBars();
if (workingRegions.length === 1) {
workingRegions = [];
continue;
}
if (targetIndex === 0) {
const nextRegion = workingRegions[1];
nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
workingRegions.splice(0, 1);
continue;
}
const previousRegion = workingRegions[targetIndex - 1];
previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
workingRegions.splice(targetIndex, 1);
}
track.setRegions(workingRegions);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneTempoRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return this.regionIds.length === 1 ? 'Delete tempo change' : `Delete ${this.regionIds.length} tempo changes`;
}
}
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import { CreateTempoRegionCommand } from './CreateTempoRegionCommand';
import { DeleteTempoRegionCommand } from './DeleteTempoRegionCommand';
import { ResizeTempoRegionCommand } from './ResizeTempoRegionCommand';
import { UpdateTempoRegionCommand } from './UpdateTempoRegionCommand';
describe('global tempo region commands', () => {
beforeEach(() => {
const project = new KGProject('Tempo', 8, 0, 120);
const mockCore = KGCore.instance() as unknown as {
getCurrentProject: ReturnType<typeof vi.fn>;
};
mockCore.getCurrentProject.mockReturnValue(project);
});
const getTempoTrack = () => {
const tempoTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
.find(track => track.getType() === GlobalTrackType.Tempo);
if (!tempoTrack) {
throw new Error('Tempo track missing in test setup');
}
return tempoTrack;
};
it('creates the first explicit region as full-song coverage', () => {
const command = new CreateTempoRegionCommand(3);
command.execute();
const tempoTrack = getTempoTrack();
const regions = tempoTrack.getRegions() as KGTempoRegion[];
expect(regions).toHaveLength(1);
expect(regions[0].getStartBar()).toBe(0);
expect(regions[0].getLengthBars()).toBe(8);
expect(regions[0].getBpm()).toBe(120);
});
it('creates additional regions by splitting the covered span and inheriting BPM', () => {
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('left', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 0, 8, 4),
]);
const command = new CreateTempoRegionCommand(5);
command.execute();
const regions = tempoTrack.getRegions() as KGTempoRegion[];
expect(regions).toHaveLength(2);
expect(regions[0].getLengthBars()).toBe(5);
expect(regions[1].getStartBar()).toBe(5);
expect(regions[1].getLengthBars()).toBe(3);
expect(regions[1].getBpm()).toBe(128);
});
it('resizes a shared boundary and keeps the track gapless', () => {
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('left', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 4, 4),
new KGTempoRegion('right', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 4, 4, 4),
]);
const command = new ResizeTempoRegionCommand('left', 'end', 6);
command.execute();
const regions = tempoTrack.getRegions() as KGTempoRegion[];
expect(regions[0].getLengthBars()).toBe(6);
expect(regions[1].getStartBar()).toBe(6);
expect(regions[1].getLengthBars()).toBe(2);
});
it('deletes a middle region by extending the previous region', () => {
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('first', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 2, 4),
new KGTempoRegion('middle', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 2, 3, 4),
new KGTempoRegion('last', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 5, 3, 4),
]);
const command = new DeleteTempoRegionCommand('middle');
command.execute();
const regions = tempoTrack.getRegions() as KGTempoRegion[];
expect(regions).toHaveLength(2);
expect(regions[0].getLengthBars()).toBe(5);
expect(regions[1].getStartBar()).toBe(5);
});
it('allows deleting the last remaining region', () => {
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('only', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4),
]);
const command = new DeleteTempoRegionCommand('only');
command.execute();
expect(tempoTrack.getRegions()).toHaveLength(0);
command.undo();
expect(tempoTrack.getRegions()).toHaveLength(1);
});
it('updates the region tempo with undo support', () => {
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('region', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4),
]);
const command = new UpdateTempoRegionCommand('region', 150);
command.execute();
expect((tempoTrack.getRegions()[0] as KGTempoRegion).getBpm()).toBe(150);
command.undo();
expect((tempoTrack.getRegions()[0] as KGTempoRegion).getBpm()).toBe(120);
});
});
@@ -0,0 +1,88 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import type { GlobalRegionResizeEdge } from './ResizeGlobalRegionCommand';
import {
cloneTempoRegions,
findGlobalTrackByType,
getSortedTempoRegions,
} from '../../../util/globalTrackUtil';
export class ResizeTempoRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly edge: GlobalRegionResizeEdge;
private readonly desiredBar: number;
private previousRegions: KGTempoRegion[] = [];
constructor(regionId: string, edge: GlobalRegionResizeEdge, desiredBar: number) {
super();
this.regionId = regionId;
this.edge = edge;
this.desiredBar = desiredBar;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found');
}
const regions = getSortedTempoRegions(track, beatsPerBar);
this.previousRegions = cloneTempoRegions(regions, beatsPerBar);
const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
if (targetIndex === -1) {
throw new Error(`Tempo region with ID ${this.regionId} not found`);
}
const targetRegion = regions[targetIndex];
if (this.edge === 'start') {
if (targetIndex === 0) {
return;
}
const previousRegion = regions[targetIndex - 1];
const targetEndBar = targetRegion.getEndBar();
const clampedBoundaryBar = Math.max(
previousRegion.getStartBar() + 1,
Math.min(this.desiredBar, targetEndBar - 1)
);
previousRegion.setLengthBars(clampedBoundaryBar - previousRegion.getStartBar(), beatsPerBar);
targetRegion.setBarRange(clampedBoundaryBar, targetEndBar - clampedBoundaryBar, beatsPerBar);
return;
}
if (targetIndex === regions.length - 1) {
return;
}
const nextRegion = regions[targetIndex + 1];
const nextRegionEndBar = nextRegion.getEndBar();
const clampedBoundaryBar = Math.max(
targetRegion.getStartBar() + 1,
Math.min(this.desiredBar, nextRegionEndBar - 1)
);
targetRegion.setLengthBars(clampedBoundaryBar - targetRegion.getStartBar(), beatsPerBar);
nextRegion.setBarRange(clampedBoundaryBar, nextRegionEndBar - clampedBoundaryBar, beatsPerBar);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneTempoRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Resize tempo boundary for "${this.regionId}"`;
}
}
@@ -0,0 +1,47 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
export class UpdateTempoRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly nextBpm: number;
private previousBpm: number | null = null;
private targetRegion: KGTempoRegion | null = null;
constructor(regionId: string, nextBpm: number) {
super();
this.regionId = regionId;
this.nextBpm = nextBpm;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found');
}
const region = track.getRegions().find(candidate => candidate.getId() === this.regionId);
if (!(region instanceof KGTempoRegion)) {
throw new Error(`Tempo region with ID ${this.regionId} not found`);
}
this.targetRegion = region;
this.previousBpm = region.getBpm();
region.setBpm(this.nextBpm);
}
undo(): void {
if (!this.targetRegion || this.previousBpm === null) {
throw new Error('Cannot undo tempo update without previous state');
}
this.targetRegion.setBpm(this.previousBpm);
}
getDescription(): string {
return `Change tempo to "${this.nextBpm} BPM"`;
}
}
+4
View File
@@ -37,13 +37,17 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignatureRegionCommand';
export { ResizeTempoRegionCommand } from './global-region/ResizeTempoRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
export { DeleteTempoRegionCommand, DeleteMultipleTempoRegionsCommand } from './global-region/DeleteTempoRegionCommand';
export { UpdateTempoRegionCommand } from './global-region/UpdateTempoRegionCommand';
// Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand';
@@ -2,6 +2,7 @@ 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';
/**
* Interface defining properties that can be updated on a project
@@ -59,6 +60,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
// Update maxBars
if (this.newProperties.maxBars !== undefined && this.newProperties.maxBars !== this.originalProperties.maxBars) {
this.targetProject.setMaxBars(this.newProperties.maxBars);
normalizeTempoRegionsForProject(this.targetProject);
this.changedProperties.add('maxBars');
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars}${this.newProperties.maxBars}`);
}
@@ -85,6 +87,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
// Compare time signatures
if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) {
this.targetProject.setTimeSignature(newTS);
normalizeTempoRegionsForProject(this.targetProject);
this.changedProperties.add('timeSignature');
updatedProperties.push(`timeSignature: ${originalTS.numerator}/${originalTS.denominator}${newTS.numerator}/${newTS.denominator}`);
}
@@ -128,6 +131,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
// Restore maxBars (only if it was changed)
if (this.changedProperties.has('maxBars') && this.originalProperties.maxBars !== undefined) {
this.targetProject.setMaxBars(this.originalProperties.maxBars);
normalizeTempoRegionsForProject(this.targetProject);
restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`);
}
@@ -146,6 +150,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
// Restore time signature (only if it was changed)
if (this.changedProperties.has('timeSignature') && this.originalProperties.timeSignature !== undefined) {
this.targetProject.setTimeSignature(this.originalProperties.timeSignature);
normalizeTempoRegionsForProject(this.targetProject);
const ts = this.originalProperties.timeSignature;
restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`);
}
@@ -226,4 +231,4 @@ export class ChangeProjectPropertyCommand extends KGCommand {
public getChangedProperties(): Set<keyof ProjectUpdateProperties> {
return new Set(this.changedProperties);
}
}
}
+2
View File
@@ -2,6 +2,7 @@ import { Expose, Type } from 'class-transformer';
import { KGGlobalRegion } from '../region/KGGlobalRegion';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
import { KGTempoRegion } from '../region/KGTempoRegion';
export enum GlobalTrackType {
Marker = 'marker',
@@ -33,6 +34,7 @@ export class KGGlobalTrack {
subTypes: [
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
{ value: KGTempoRegion, name: 'KGTempoRegion' },
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
],
},
+90
View File
@@ -0,0 +1,90 @@
import { Expose } from 'class-transformer';
import { KGGlobalRegion } from './KGGlobalRegion';
export class KGTempoRegion extends KGGlobalRegion {
@Expose()
protected override __type: string = 'KGTempoRegion';
@Expose()
private bpm: number = 120;
@Expose()
private startBar: number = 0;
@Expose()
private lengthBars: number = 1;
constructor(
id: string,
trackId: string,
trackIndex: number,
bpm: number,
startBar: number = 0,
lengthBars: number = 1,
beatsPerBar: number = 4
) {
super(id, trackId, trackIndex, `${bpm} BPM`, startBar * beatsPerBar, lengthBars * beatsPerBar);
this.__type = 'KGTempoRegion';
this.bpm = bpm;
this.startBar = startBar;
this.lengthBars = lengthBars;
this.syncBeatsFromBars(beatsPerBar);
super.setName(this.getDisplayName());
}
public getBpm(): number {
return this.bpm;
}
public setBpm(bpm: number): void {
this.bpm = bpm;
super.setName(this.getDisplayName());
}
public getStartBar(): number {
return this.startBar;
}
public getLengthBars(): number {
return this.lengthBars;
}
public getEndBar(): number {
return this.startBar + this.lengthBars;
}
public setStartBar(startBar: number, beatsPerBar: number): void {
this.startBar = startBar;
this.syncBeatsFromBars(beatsPerBar);
}
public setLengthBars(lengthBars: number, beatsPerBar: number): void {
this.lengthBars = lengthBars;
this.syncBeatsFromBars(beatsPerBar);
}
public setBarRange(startBar: number, lengthBars: number, beatsPerBar: number): void {
this.startBar = startBar;
this.lengthBars = lengthBars;
this.syncBeatsFromBars(beatsPerBar);
}
public syncBeatsFromBars(beatsPerBar: number): void {
super.setStartFromBeat(this.startBar * beatsPerBar);
super.setLength(this.lengthBars * beatsPerBar);
}
public syncBarsFromBeats(beatsPerBar: number): void {
this.startBar = Math.floor(this.getStartFromBeat() / beatsPerBar);
this.lengthBars = Math.max(1, Math.round(this.getLength() / beatsPerBar));
super.setName(this.getDisplayName());
}
public getDisplayName(): string {
return `${this.bpm} BPM`;
}
public override getCurrentType(): string {
return 'KGTempoRegion';
}
}