diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 3ac5d67..829e9d8 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -270,16 +270,36 @@ export class KGCore { // High-level playback control methods public async startPlaying(): Promise { + // 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 await this.preparePlay(); - + // Start playing (non-blocking) this.play(); // Don't await this - + // Set up the regular playback update timer this.playbackStartTime = performance.now(); this.playbackStartPosition = this.playheadPosition; - + this.startPlaybackUpdates(); } @@ -336,28 +356,52 @@ export class KGCore { const bpm = this.currentProject.getBpm(); const beatsPerMs = bpm / (60 * 1000); - const newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs); - - // Stop playback at the end of project (maxBars) - const maxBars = this.currentProject.getMaxBars(); + let newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs); + + // Handle looping or end-of-project const beatsPerBar = this.currentProject.getTimeSignature().numerator; - const maxBeats = maxBars * beatsPerBar; - if (newPosition >= maxBeats) { - // Clamp to max and stop - this.setPlayheadPosition(maxBeats); - // Stop playback (non-blocking) - this.stopPlaying(); - return; + + if (this.currentProject.getIsLooping()) { + // Loop mode: wrap playhead when it reaches loop end + const [startBar, endBarOriginal] = this.currentProject.getLoopingRange(); + const endBar = (startBar === 0 && endBarOriginal === 0) ? this.currentProject.getMaxBars() : endBarOriginal; + + 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 this.setPlayheadPosition(newPosition); - + // TODO: Future enhancements // - Sync with Tone.Transport position for more accurate timing // - Handle tempo changes mid-playback // - Account for latency compensation - // - Support for loop regions } // selected items diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index 3b5bd4d..b775e98 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -264,9 +264,41 @@ export class KGAudioInterface { Tone.Transport.bpm.value = project.getBpm(); const timeSignature = project.getTimeSignature(); Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator]; - + 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) this.setTransportPosition(startPosition); @@ -290,8 +322,14 @@ export class KGAudioInterface { // Calculate absolute note timing in beats (note position + region start position) const regionStartBeat = region.getStartFromBeat(); const noteStartBeat = note.getStartBeat() + regionStartBeat; + const noteEndBeat = note.getEndBeat() + regionStartBeat; 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 if (noteStartBeat < startPosition) { return; // Skip notes that would have already finished before playback starts