tone 15.5.18 → 15.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -544,6 +544,65 @@ describe("TickSource", () => {
544
544
  });
545
545
  source.dispose();
546
546
  });
547
+
548
+ it("does not fire duplicate ticks across consecutive windows at a tick boundary", () => {
549
+ // Reproduces the double-trigger bug from issues #1080/#1098/#1175.
550
+ // At 400 ticks/sec (equivalent to BPM=125, PPQ=192), tick 7680 falls
551
+ // at exactly t=19.2s.
552
+ const source = new TickSource(400);
553
+ source.start(0);
554
+
555
+ const firedTicks: number[] = [];
556
+ // Window 1 ends just above the tick-7680 boundary
557
+ source.forEachTickBetween(
558
+ 19.19709750566994,
559
+ 19.20000000000101,
560
+ (_, tick) => firedTicks.push(tick)
561
+ );
562
+ // Window 2 starts at exactly that same boundary
563
+ source.forEachTickBetween(
564
+ 19.20000000000101,
565
+ 19.202902494332076,
566
+ (_, tick) => firedTicks.push(tick)
567
+ );
568
+
569
+ const occurrences = firedTicks.filter((t) => t === 7680).length;
570
+ expect(occurrences).to.equal(
571
+ 1,
572
+ `tick 7680 should fire exactly once, but fired ${occurrences} times`
573
+ );
574
+ source.dispose();
575
+ });
576
+
577
+ it("fires every tick exactly once across many consecutive windows", () => {
578
+ // At 2 Hz, ticks fall at 0, 0.5, 1.0, 1.5, 2.0.
579
+ // Window boundaries are placed just above each tick time (tick + eps)
580
+ // so every tick lands right at the end of one window and the start of the next.
581
+ const source = new TickSource(2);
582
+ source.start(0);
583
+
584
+ const tickCount = new Map<number, number>();
585
+ const record = (_: number, tick: number) => {
586
+ tickCount.set(tick, (tickCount.get(tick) ?? 0) + 1);
587
+ };
588
+
589
+ // Non-overlapping consecutive windows whose boundaries sit just above
590
+ // the tick times (0, 0.5, 1.0, 1.5, 2.0).
591
+ const eps = 1e-10;
592
+ source.forEachTickBetween(0, 0.5 + eps, record); // tick 0 (at 0.0) and tick 1 (at 0.5)
593
+ source.forEachTickBetween(0.5 + eps, 1.0 + eps, record); // tick 2 (at 1.0)
594
+ source.forEachTickBetween(1.0 + eps, 1.5 + eps, record); // tick 3 (at 1.5)
595
+ source.forEachTickBetween(1.5 + eps, 2.0 + eps, record); // tick 4 (at 2.0)
596
+ source.forEachTickBetween(2.0 + eps, 2.5, record); // tick 5 (at 2.5, not included)
597
+
598
+ for (const [tick, count] of tickCount) {
599
+ expect(count).to.equal(
600
+ 1,
601
+ `tick ${tick} fired ${count} times instead of once`
602
+ );
603
+ }
604
+ source.dispose();
605
+ });
547
606
  });
548
607
 
549
608
  context("Seconds", () => {
@@ -415,18 +415,28 @@ export class TickSource<
415
415
 
416
416
  if (lastStateEvent && lastStateEvent.state === "started") {
417
417
  const maxStartTime = Math.max(lastStateEvent.time, startTime);
418
- // figure out the difference between the frequency ticks and the
418
+ // Figure out how far past the last whole-tick boundary maxStartTime
419
+ // sits, so we can compute the time of the next tick at or after it.
419
420
  const startTicks = this.frequency.getTicksAtTime(maxStartTime);
420
421
  const ticksAtStart = this.frequency.getTicksAtTime(
421
422
  lastStateEvent.time
422
423
  );
423
424
  const diff = startTicks - ticksAtStart;
424
- let offset = Math.ceil(diff) - diff;
425
- // guard against floating point issues
426
- offset = EQ(offset, 1) ? 0 : offset;
427
- let nextTickTime = this.frequency.getTimeOfTick(
428
- startTicks + offset
429
- );
425
+ const offset = Math.ceil(diff) - diff;
426
+ // Guard against floating-point issues: when startTicks is just barely
427
+ // above an integer tick boundary (offset 1), snap back to that integer
428
+ const firstTick = EQ(offset, 1)
429
+ ? Math.floor(startTicks)
430
+ : startTicks + offset;
431
+ let nextTickTime = this.frequency.getTimeOfTick(firstTick);
432
+ // Advance past any ticks that land before the start of this window
433
+ // to avoid any tick that was already processed.
434
+ if (nextTickTime < maxStartTime) {
435
+ nextTickTime += this.frequency.getDurationOfTicks(
436
+ 1,
437
+ nextTickTime
438
+ );
439
+ }
430
440
  while (nextTickTime < endTime) {
431
441
  try {
432
442
  callback(
@@ -75,6 +75,20 @@ describe("Transport", () => {
75
75
  expect(invocations).to.equal(5);
76
76
  });
77
77
 
78
+ it("does not double-fire events when looping many times", async () => {
79
+ let invocations = 0;
80
+ await Offline((context) => {
81
+ const transport = new TransportInstance({ context });
82
+ transport.schedule(() => {
83
+ invocations++;
84
+ }, 0);
85
+ transport.setLoopPoints(0, 1).start(0);
86
+ transport.loop = true;
87
+ }, 120.1);
88
+ // Should fire exactly 121 times: at t=0,1,2,...,120
89
+ expect(invocations).to.equal(121);
90
+ });
91
+
78
92
  it("jumps to the loopStart after the loopEnd point", async () => {
79
93
  let looped = false;
80
94
  await Offline((context) => {
@@ -221,6 +221,16 @@ describe("Context", () => {
221
221
  }, 0.05);
222
222
  });
223
223
 
224
+ it("can efficiently add and remove 10k timeouts", () => {
225
+ const ids: number[] = [];
226
+ for (let i = 0; i < 10_000; i++) {
227
+ ids.push(ctx.setTimeout(() => {}, i * 0.001));
228
+ }
229
+ for (const id of ids) {
230
+ ctx.clearTimeout(id);
231
+ }
232
+ }).timeout(100);
233
+
224
234
  it("is robust against altering the timeline within the callback fn", (done) => {
225
235
  let invokeCount = 0;
226
236
  function checkDone(id: number) {
@@ -64,6 +64,11 @@ export class Context extends BaseContext {
64
64
  */
65
65
  private _timeouts: Timeline<ContextTimeoutEvent> = new Timeline();
66
66
 
67
+ /**
68
+ * A Map from timeout ID to event for O(1) lookup in clearTimeout.
69
+ */
70
+ private _timeoutMap = new Map<number, ContextTimeoutEvent>();
71
+
67
72
  /**
68
73
  * The timeout id counter
69
74
  */
@@ -554,6 +559,7 @@ export class Context extends BaseContext {
554
559
  super.dispose();
555
560
  this._ticker.dispose();
556
561
  this._timeouts.dispose();
562
+ this._timeoutMap.clear();
557
563
  Object.keys(this._constants).map((val) =>
558
564
  this._constants[val].disconnect()
559
565
  );
@@ -565,6 +571,26 @@ export class Context extends BaseContext {
565
571
  // TIMEOUTS
566
572
  //---------------------------
567
573
 
574
+ /**
575
+ * Add a timeout event to the timeline and map.
576
+ */
577
+ private _addTimeoutEvent(event: ContextTimeoutEvent): void {
578
+ this._timeouts.add(event);
579
+ this._timeoutMap.set(event.id, event);
580
+ }
581
+
582
+ /**
583
+ * Remove a timeout event from the timeline and map.
584
+ */
585
+ private _removeTimeoutEvent(event: ContextTimeoutEvent): void {
586
+ this._timeouts.remove(event);
587
+ // Skips the map deletion in setInterval case where the same id
588
+ // is reused for the next event.
589
+ if (this._timeoutMap.get(event.id) === event) {
590
+ this._timeoutMap.delete(event.id);
591
+ }
592
+ }
593
+
568
594
  /**
569
595
  * The private loop which keeps track of the context scheduled timeouts
570
596
  * Is invoked from the clock source
@@ -575,7 +601,7 @@ export class Context extends BaseContext {
575
601
  try {
576
602
  event.callback();
577
603
  } finally {
578
- this._timeouts.remove(event);
604
+ this._removeTimeoutEvent(event);
579
605
  }
580
606
  });
581
607
  }
@@ -592,7 +618,7 @@ export class Context extends BaseContext {
592
618
  setTimeout(fn: (...args: any[]) => void, timeout: Seconds): number {
593
619
  this._timeoutIds++;
594
620
  const now = this.now();
595
- this._timeouts.add({
621
+ this._addTimeoutEvent({
596
622
  callback: fn,
597
623
  id: this._timeoutIds,
598
624
  time: now + timeout,
@@ -605,11 +631,10 @@ export class Context extends BaseContext {
605
631
  * @param id The ID returned from {@link setTimeout}.
606
632
  */
607
633
  clearTimeout(id: number): this {
608
- this._timeouts.forEach((event) => {
609
- if (event.id === id) {
610
- this._timeouts.remove(event);
611
- }
612
- });
634
+ const event = this._timeoutMap.get(id);
635
+ if (event) {
636
+ this._removeTimeoutEvent(event);
637
+ }
613
638
  return this;
614
639
  }
615
640
 
@@ -631,7 +656,7 @@ export class Context extends BaseContext {
631
656
  const id = ++this._timeoutIds;
632
657
  const intervalFn = () => {
633
658
  const now = this.now();
634
- this._timeouts.add({
659
+ this._addTimeoutEvent({
635
660
  callback: () => {
636
661
  // invoke the callback
637
662
  fn();
@@ -93,6 +93,10 @@ describe("Timeline", () => {
93
93
  sched.remove({ time: 4 });
94
94
  });
95
95
  expect(sched.length).to.equal(1);
96
+ sched.add({ time: 1 });
97
+ sched.add({ time: 3 });
98
+ sched.remove({ time: 2 });
99
+ expect(sched.length).to.equal(3);
96
100
  sched.dispose();
97
101
  });
98
102
 
@@ -115,7 +115,7 @@ export class Timeline<GenericEvent extends TimelineEvent> extends Tone {
115
115
  * @returns {Timeline} this
116
116
  */
117
117
  remove(event: GenericEvent): this {
118
- const index = this._timeline.indexOf(event);
118
+ const index = this._indexOf(event);
119
119
  if (index !== -1) {
120
120
  this._timeline.splice(index, 1);
121
121
  }
@@ -240,7 +240,7 @@ export class Timeline<GenericEvent extends TimelineEvent> extends Tone {
240
240
  * @return The event right before the given event
241
241
  */
242
242
  previousEvent(event: GenericEvent): GenericEvent | null {
243
- const index = this._timeline.indexOf(event);
243
+ const index = this._indexOf(event);
244
244
  if (index > 0) {
245
245
  return this._timeline[index - 1];
246
246
  } else {
@@ -248,6 +248,34 @@ export class Timeline<GenericEvent extends TimelineEvent> extends Tone {
248
248
  }
249
249
  }
250
250
 
251
+ /**
252
+ * Return the index of the given event in the timeline. -1 if it is not found.
253
+ */
254
+ private _indexOf(event: GenericEvent): number {
255
+ // Use _search since internally implemented with binary search.
256
+ const index = this._search(event.time);
257
+ if (index === -1) {
258
+ return -1;
259
+ }
260
+ // There may be multiple events with the same time, so find the exact event
261
+ // Search forward and backward for events with the same time
262
+ for (let i = index; i < this._timeline.length; i++) {
263
+ if (this._timeline[i] === event) {
264
+ return i;
265
+ } else if (this._timeline[i].time !== event.time) {
266
+ break;
267
+ }
268
+ }
269
+ for (let j = index - 1; j >= 0; j--) {
270
+ if (this._timeline[j] === event) {
271
+ return j;
272
+ } else if (this._timeline[j].time !== event.time) {
273
+ break;
274
+ }
275
+ }
276
+ return -1;
277
+ }
278
+
251
279
  /**
252
280
  * Does a binary search on the timeline array and returns the
253
281
  * nearest event index whose time is after or equal to the given time.
package/Tone/version.ts CHANGED
@@ -1 +1 @@
1
- export const version: string = "15.5.18";
1
+ export const version: string = "15.5.20";