diff --git a/src/PlaybackEngine.ts b/src/PlaybackEngine.ts index a4d24e5..ca1c1bd 100644 --- a/src/PlaybackEngine.ts +++ b/src/PlaybackEngine.ts @@ -139,8 +139,15 @@ export default class PlaybackEngine { } async play() { + if (!this.scheduler) return; await this.ac.resume(); + if (this.state === PlaybackState.PAUSED) { + this.setState(PlaybackState.PLAYING); + this.scheduler.resume(); + return; + } + if (this.state === PlaybackState.INIT || this.state === PlaybackState.STOPPED) { this.cursor.show(); } @@ -161,10 +168,12 @@ export default class PlaybackEngine { pause() { this.setState(PlaybackState.PAUSED); + if (!this.scheduler) return; + // Pause the scheduler first to prevent any further scheduling while we suspend the audio context. + // Avoid rewinding scheduler state here; resume should continue from the same stepQueueIndex/tick. + this.scheduler.pause(); this.ac.suspend(); this.stopPlayers(); - this.scheduler.setIterationStep(this.currentIterationStep); - this.scheduler.pause(); this.clearTimeouts(); } @@ -197,7 +206,26 @@ export default class PlaybackEngine { let steps = 0; while (!this.cursor.Iterator.EndReached) { if (this.cursor.Iterator.CurrentVoiceEntries) { - this.scheduler.loadNotes(this.cursor.Iterator.CurrentVoiceEntries); + // Prefer OSMD's absolute cursor timestamp for the current position to prevent time compression. + // Fallback to legacy behavior if timestamp isn't available in the current OSMD build. + let timeStampRealValue: number | null = null; + try { + const iterator: any = this.cursor.Iterator as any; + const timeStamp: any = iterator.CurrentTimeStamp ?? iterator.currentTimeStamp ?? null; + if (timeStamp && typeof timeStamp.RealValue === "number") { + timeStampRealValue = timeStamp.RealValue; + } else if (timeStamp && typeof timeStamp.realValue === "number") { + timeStampRealValue = timeStamp.realValue; + } + + if (typeof timeStampRealValue === "number" && !Number.isFinite(timeStampRealValue)) { + timeStampRealValue = null; + } + } catch { + timeStampRealValue = null; + } + + this.scheduler.loadNotes(this.cursor.Iterator.CurrentVoiceEntries, timeStampRealValue); } this.cursor.next(); ++steps; diff --git a/src/PlaybackScheduler.ts b/src/PlaybackScheduler.ts index fb53f3e..67f84f2 100644 --- a/src/PlaybackScheduler.ts +++ b/src/PlaybackScheduler.ts @@ -18,6 +18,11 @@ export default class PlaybackScheduler { private audioContextStartTime: number = 0; private schedulerIntervalHandle: number = null; + /** + * Track all created interval handles so we can reliably clear them on reset. + * This prevents duplicate schedulers from running concurrently (perceived tempo increase). + */ + private schedulerIntervalHandles = new Set(); private scheduleInterval: number = 200; // Milliseconds private schedulePeriod: number = 500; private tickDenominator: number = 1024; @@ -51,13 +56,20 @@ export default class PlaybackScheduler { } start() { - this.playing = true; this.stepQueue.sort(); - this.audioContextStartTime = this.audioContext.currentTime; - this.currentTickTimestamp = this.audioContextTime; - if (!this.schedulerIntervalHandle) { + if (this.schedulerIntervalHandle === null) { + this.playing = true; + // Fresh start: reset timebase relative to "now". + this.audioContextStartTime = this.audioContext.currentTime; + this.currentTickTimestamp = this.audioContextTime; this.schedulerIntervalHandle = window.setInterval(() => this.scheduleIterationStep(), this.scheduleInterval); + this.schedulerIntervalHandles.add(this.schedulerIntervalHandle); + return; } + // If we're already running an interval (e.g. resume after pause), do NOT reset the timebase. + // Just resume scheduling from the current tick/stepQueueIndex. + this.playing = true; + this.currentTickTimestamp = this.audioContextTime; } setIterationStep(step: number) { @@ -68,6 +80,8 @@ export default class PlaybackScheduler { pause() { this.playing = false; + // Clear bookkeeping so resume can re-schedule cleanly. + this.scheduledTicks.clear(); } resume() { @@ -80,13 +94,24 @@ export default class PlaybackScheduler { this.currentTick = 0; this.currentTickTimestamp = 0; this.stepQueueIndex = 0; - clearInterval(this.scheduleInterval); + this.audioContextStartTime = 0; + this.scheduledTicks.clear(); + // IMPORTANT: clear the *interval handle*, not the interval duration. + for (const handle of this.schedulerIntervalHandles) { + clearInterval(handle); + } + this.schedulerIntervalHandles.clear(); this.schedulerIntervalHandle = null; } - loadNotes(currentVoiceEntries: VoiceEntry[]) { + loadNotes(currentVoiceEntries: VoiceEntry[], iteratorTimeStampRealValue?: number | null) { let thisTick = this.lastTickOffset; - if (this.stepQueue.steps.length > 0) { + + // Use absolute cursor timestamp if available to prevent time compression + if (typeof iteratorTimeStampRealValue === "number" && Number.isFinite(iteratorTimeStampRealValue)) { + thisTick = this.lastTickOffset + Math.round(iteratorTimeStampRealValue * this.tickDenominator); + } else if (this.stepQueue.steps.length > 0) { + // Fallback to old heuristic if timestamp unavailable thisTick = this.stepQueue.getFirstEmptyTick(); } @@ -94,7 +119,10 @@ export default class PlaybackScheduler { if (!entry.IsGrace) { for (let note of entry.Notes) { this.stepQueue.addNote(thisTick, note); - this.stepQueue.createStep(thisTick + note.Length.RealValue * this.tickDenominator); + // Skip creating empty end steps when using absolute timestamps to avoid scheduling noise + if (!(typeof iteratorTimeStampRealValue === "number" && Number.isFinite(iteratorTimeStampRealValue))) { + this.stepQueue.createStep(thisTick + note.Length.RealValue * this.tickDenominator); + } } } }