feat: enhanced the note scheduling logic to support loop mode

pending cases: user disabled the loop mode or updated the loop range during playback.
This commit is contained in:
Xiaohan-Tian
2026-01-23 13:22:51 -08:00
parent b46442bd9d
commit b767d2176f
2 changed files with 102 additions and 20 deletions
+55 -11
View File
@@ -270,6 +270,26 @@ export class KGCore {
// High-level playback control methods // High-level playback control methods
public async startPlaying(): Promise<void> { public async startPlaying(): Promise<void> {
// Handle loop mode initialization
if (this.currentProject.getIsLooping()) {
const [startBar, endBar] = this.currentProject.getLoopingRange();
// Handle [0, 0] case - set to full project
if (startBar === 0 && endBar === 0) {
const maxBars = this.currentProject.getMaxBars();
const { ChangeLoopSettingsCommand } = await import('./commands');
this.executeCommand(new ChangeLoopSettingsCommand({
loopingRange: [0, maxBars]
}));
}
// Move playhead to loop start (use updated range if [0,0] was just set)
const updatedRange = this.currentProject.getLoopingRange();
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
const loopStartBeats = updatedRange[0] * beatsPerBar;
this.setPlayheadPosition(loopStartBeats);
}
// Prepare playback first // Prepare playback first
await this.preparePlay(); await this.preparePlay();
@@ -336,18 +356,43 @@ export class KGCore {
const bpm = this.currentProject.getBpm(); const bpm = this.currentProject.getBpm();
const beatsPerMs = bpm / (60 * 1000); const beatsPerMs = bpm / (60 * 1000);
const newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs); let newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs);
// Stop playback at the end of project (maxBars) // Handle looping or end-of-project
const maxBars = this.currentProject.getMaxBars();
const beatsPerBar = this.currentProject.getTimeSignature().numerator; const beatsPerBar = this.currentProject.getTimeSignature().numerator;
const maxBeats = maxBars * beatsPerBar;
if (newPosition >= maxBeats) { if (this.currentProject.getIsLooping()) {
// Clamp to max and stop // Loop mode: wrap playhead when it reaches loop end
this.setPlayheadPosition(maxBeats); const [startBar, endBarOriginal] = this.currentProject.getLoopingRange();
// Stop playback (non-blocking) const endBar = (startBar === 0 && endBarOriginal === 0) ? this.currentProject.getMaxBars() : endBarOriginal;
this.stopPlaying();
return; const loopStartBeats = startBar * beatsPerBar;
const loopEndBeats = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
const loopLengthBeats = loopEndBeats - loopStartBeats;
// Wrap playhead position within loop range
if (newPosition >= loopEndBeats) {
// 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;
}
} else {
// Non-looping mode: stop at project end
const maxBars = this.currentProject.getMaxBars();
const maxBeats = maxBars * beatsPerBar;
if (newPosition >= maxBeats) {
// Clamp to max and stop
this.setPlayheadPosition(maxBeats);
// Stop playback (non-blocking)
this.stopPlaying();
return;
}
} }
// Update playhead position // Update playhead position
@@ -357,7 +402,6 @@ export class KGCore {
// - Sync with Tone.Transport position for more accurate timing // - Sync with Tone.Transport position for more accurate timing
// - Handle tempo changes mid-playback // - Handle tempo changes mid-playback
// - Account for latency compensation // - Account for latency compensation
// - Support for loop regions
} }
// selected items // selected items
@@ -267,6 +267,38 @@ export class KGAudioInterface {
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`); console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
// Configure loop settings
const isLooping = project.getIsLooping();
let scheduleStartBeat = 0;
let scheduleEndBeat = Infinity;
if (isLooping) {
const [startBar, endBarOriginal] = project.getLoopingRange();
const beatsPerBar = timeSignature.numerator;
// Handle [0, 0] case - use full project
const endBar = (startBar === 0 && endBarOriginal === 0) ? project.getMaxBars() : endBarOriginal;
scheduleStartBeat = startBar * beatsPerBar;
scheduleEndBeat = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
// Configure Tone.Transport loop boundaries
const loopStartTime = this.beatsToToneTime(scheduleStartBeat);
const loopEndTime = this.beatsToToneTime(scheduleEndBeat);
Tone.Transport.setLoopPoints(loopStartTime, loopEndTime);
Tone.Transport.loop = true;
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
// Adjust start position to loop start if before loop range
if (startPosition < scheduleStartBeat) {
startPosition = scheduleStartBeat;
}
} else {
Tone.Transport.loop = false;
console.log("Loop mode disabled");
}
// Set transport position (convert beats to Tone.js format) // Set transport position (convert beats to Tone.js format)
this.setTransportPosition(startPosition); this.setTransportPosition(startPosition);
@@ -290,8 +322,14 @@ export class KGAudioInterface {
// Calculate absolute note timing in beats (note position + region start position) // Calculate absolute note timing in beats (note position + region start position)
const regionStartBeat = region.getStartFromBeat(); const regionStartBeat = region.getStartFromBeat();
const noteStartBeat = note.getStartBeat() + regionStartBeat; const noteStartBeat = note.getStartBeat() + regionStartBeat;
const noteEndBeat = note.getEndBeat() + regionStartBeat;
const noteDurationBeats = note.getEndBeat() - note.getStartBeat(); const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
// Skip notes outside loop range when looping
if (noteStartBeat >= scheduleEndBeat || noteEndBeat <= scheduleStartBeat) {
return; // Skip notes outside the loop range
}
// Only schedule notes that start at or after the playback start position // Only schedule notes that start at or after the playback start position
if (noteStartBeat < startPosition) { if (noteStartBeat < startPosition) {
return; // Skip notes that would have already finished before playback starts return; // Skip notes that would have already finished before playback starts