tone 15.5.17 → 15.5.19

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) => {
@@ -173,6 +173,19 @@ export abstract class Source<
173
173
  duration?: Time
174
174
  ): void;
175
175
 
176
+ /**
177
+ * Compute the buffer offset to use when the source is started mid-playback.
178
+ * @param explicitOffset The buffer-time offset the caller passed to `start()`
179
+ * @param transportElapsed Seconds of Transport time that have elapsed since
180
+ * this source was scheduled to start.
181
+ */
182
+ protected _getSyncedStartOffset(
183
+ explicitOffset: Seconds,
184
+ transportElapsed: Seconds
185
+ ): Seconds {
186
+ return explicitOffset + transportElapsed;
187
+ }
188
+
176
189
  /**
177
190
  * Ensure that the scheduled time is not before the current time.
178
191
  * Should only be used when scheduled unsynced.
@@ -338,7 +351,10 @@ export abstract class Source<
338
351
  }
339
352
  this._start(
340
353
  time,
341
- this.toSeconds(stateEvent.offset) + startOffset,
354
+ this._getSyncedStartOffset(
355
+ this.toSeconds(stateEvent.offset),
356
+ startOffset
357
+ ),
342
358
  duration
343
359
  );
344
360
  }
@@ -620,7 +620,124 @@ describe("Player", () => {
620
620
  // start halfway through
621
621
  transport.start(0, 0.15);
622
622
  }, 0.05);
623
- expect(buff.getValueAtTime(0)).to.be.closeTo(0.5, 0.05);
623
+ // With playbackRate=0.5, transport position 0.15 maps to buffer position
624
+ // 0.15 * 0.5 = 0.075. The ramp value there is 0.075 / 0.3 = 0.25.
625
+ expect(buff.getValueAtTime(0)).to.be.closeTo(0.25, 0.03);
626
+ });
627
+
628
+ it("seeks to the correct buffer position when Transport starts at a non-zero offset with playbackRate != 1", async () => {
629
+ const buff = await Offline(({ transport }) => {
630
+ // Ramp 0 -> 1 over 0.4 s
631
+ const ramp = new Float32Array(
632
+ Math.floor(getContext().sampleRate * 0.4)
633
+ );
634
+ for (let i = 0; i < ramp.length; i++) {
635
+ ramp[i] = i / ramp.length;
636
+ }
637
+ const playerBuff = ToneAudioBuffer.fromArray(ramp);
638
+ const player = new Player(playerBuff).toDestination();
639
+ // Buffer plays at 2x speed: 1 transport-second advances 2 buffer-seconds
640
+ player.playbackRate = 2;
641
+ player.sync().start(0);
642
+ // Seek transport to 0.1 s. The player should start at buffer position
643
+ // 0.1 * 2 = 0.2, giving a ramp value of 0.2 / 0.4 = 0.5.
644
+ transport.start(0, 0.1);
645
+ }, 0.05);
646
+ expect(buff.getValueAtTime(0)).to.be.closeTo(0.5, 0.03);
647
+ });
648
+
649
+ it("explicit buffer-time offset is not scaled by playbackRate when synced", async () => {
650
+ const buff = await Offline(({ transport }) => {
651
+ const ramp = new Float32Array(
652
+ Math.floor(getContext().sampleRate * 0.3)
653
+ );
654
+ for (let i = 0; i < ramp.length; i++) {
655
+ ramp[i] = i / ramp.length;
656
+ }
657
+ const playerBuff = ToneAudioBuffer.fromArray(ramp);
658
+ const player = new Player(playerBuff).toDestination();
659
+ player.loop = true;
660
+ player.playbackRate = 2;
661
+ player.sync().start(0, 0.1);
662
+ transport.start(0);
663
+ }, 0.05);
664
+ expect(buff.getValueAtTime(0)).to.be.closeTo(1 / 3, 0.03);
665
+ });
666
+
667
+ it("combines explicit buffer-time offset with transport-elapsed time scaled by playbackRate", async () => {
668
+ const buff = await Offline(({ transport }) => {
669
+ const ramp = new Float32Array(
670
+ Math.floor(getContext().sampleRate * 0.3)
671
+ );
672
+ for (let i = 0; i < ramp.length; i++) {
673
+ ramp[i] = i / ramp.length;
674
+ }
675
+ const playerBuff = ToneAudioBuffer.fromArray(ramp);
676
+ const player = new Player(playerBuff).toDestination();
677
+ player.loop = true;
678
+ player.playbackRate = 2;
679
+ player.sync().start(0, 0.1);
680
+ transport.start(0, 0.05);
681
+ }, 0.05);
682
+ expect(buff.getValueAtTime(0)).to.be.closeTo(2 / 3, 0.03);
683
+ });
684
+
685
+ it("looping player wraps correctly when transport offset exceeds buffer duration (playbackRate=1)", async () => {
686
+ // buffer duration = 1s, transport seeks to 2.5s
687
+ // buffer offset = 2.5 * 1 = 2.5 → wrapped = 2.5 % 1 = 0.5
688
+ const buff = await Offline(({ transport }) => {
689
+ const ramp = new Float32Array(getContext().sampleRate); // 1 s
690
+ for (let i = 0; i < ramp.length; i++) {
691
+ ramp[i] = i / ramp.length;
692
+ }
693
+ const playerBuff = ToneAudioBuffer.fromArray(ramp);
694
+ const player = new Player(playerBuff).toDestination();
695
+ player.loop = true;
696
+ player.sync().start(0);
697
+ transport.start(0, 2.5);
698
+ }, 0.05);
699
+ expect(buff.getValueAtTime(0)).to.be.closeTo(0.5, 0.03);
700
+ });
701
+
702
+ it("looping player wraps correctly when transport offset exceeds buffer duration (playbackRate=2)", async () => {
703
+ // buffer duration = 1s, transport seeks to 1.25s, playbackRate=2
704
+ // buffer offset = 1.25 * 2 = 2.5 → wrapped = 2.5 % 1 = 0.5
705
+ const buff = await Offline(({ transport }) => {
706
+ const ramp = new Float32Array(getContext().sampleRate); // 1 s
707
+ for (let i = 0; i < ramp.length; i++) {
708
+ ramp[i] = i / ramp.length;
709
+ }
710
+ const playerBuff = ToneAudioBuffer.fromArray(ramp);
711
+ const player = new Player(playerBuff).toDestination();
712
+ player.loop = true;
713
+ player.playbackRate = 2;
714
+ player.sync().start(0);
715
+ transport.start(0, 1.25);
716
+ }, 0.05);
717
+ expect(buff.getValueAtTime(0)).to.be.closeTo(0.5, 0.03);
718
+ });
719
+
720
+ it("looping player with loopStart/loopEnd wraps correctly when transport offset exceeds loop duration (playbackRate=2)", async () => {
721
+ // buffer duration = 1s, loopStart=0.2, loopEnd=1.0, loopDuration=0.8
722
+ // transport seeks to 1.15s, playbackRate=2
723
+ // buffer offset = 1.15 * 2 = 2.3
724
+ // wrapped = ((2.3 - 0.2) % 0.8) + 0.2 = (2.1 % 0.8) + 0.2 = 0.5 + 0.2 = 0.7
725
+ // ramp value at 0.7 in a 1s ramp = 0.7
726
+ const buff = await Offline(({ transport }) => {
727
+ const ramp = new Float32Array(getContext().sampleRate); // 1 s
728
+ for (let i = 0; i < ramp.length; i++) {
729
+ ramp[i] = i / ramp.length;
730
+ }
731
+ const playerBuff = ToneAudioBuffer.fromArray(ramp);
732
+ const player = new Player(playerBuff).toDestination();
733
+ player.loop = true;
734
+ player.loopStart = 0.2;
735
+ player.loopEnd = 1.0;
736
+ player.playbackRate = 2;
737
+ player.sync().start(0);
738
+ transport.start(0, 1.15);
739
+ }, 0.05);
740
+ expect(buff.getValueAtTime(0)).to.be.closeTo(0.7, 0.03);
624
741
  });
625
742
 
626
743
  it("starts with an offset when synced and started after Transport is running", async () => {
@@ -262,6 +262,17 @@ export class Player extends Source<PlayerOptions> {
262
262
  return this;
263
263
  }
264
264
 
265
+ /**
266
+ * Factor in the playback rate to compute the buffer offset when the player
267
+ * is synced to the Transport.
268
+ */
269
+ protected _getSyncedStartOffset(
270
+ explicitOffset: number,
271
+ transportElapsed: number
272
+ ): number {
273
+ return explicitOffset + transportElapsed * this._playbackRate;
274
+ }
275
+
265
276
  /**
266
277
  * Internal start method
267
278
  */
package/Tone/version.ts CHANGED
@@ -1 +1 @@
1
- export const version: string = "15.5.17";
1
+ export const version: string = "15.5.19";