Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions src/PlaybackEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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();
}

Expand Down Expand Up @@ -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;
Expand Down
44 changes: 36 additions & 8 deletions src/PlaybackScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
private scheduleInterval: number = 200; // Milliseconds
private schedulePeriod: number = 500;
private tickDenominator: number = 1024;
Expand Down Expand Up @@ -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) {
Expand All @@ -68,6 +80,8 @@ export default class PlaybackScheduler {

pause() {
this.playing = false;
// Clear bookkeeping so resume can re-schedule cleanly.
this.scheduledTicks.clear();
}

resume() {
Expand All @@ -80,21 +94,35 @@ 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();
}

for (let entry of currentVoiceEntries) {
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);
}
}
}
}
Expand Down