feat: implemented global tempo (bpm) track
This commit is contained in:
+9
-32
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"`;
|
||||
}
|
||||
}
|
||||
@@ -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,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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user