feat: sub-tick scheduling

values are no longer rounded to the nearest tick, they can happen between tick values.
This commit is contained in:
Yotam Mann 2021-01-12 22:54:45 -05:00
parent dc9de66401
commit 33e14d06eb
11 changed files with 63 additions and 32 deletions

View file

@ -636,7 +636,7 @@ describe("Transport", () => {
invocations++; invocations++;
}, 0.1, 0); }, 0.1, 0);
transport.start(); transport.start();
}, 0.5).then(() => { }, 0.51).then(() => {
expect(invocations).to.equal(6); expect(invocations).to.equal(6);
}); });
}); });
@ -688,8 +688,8 @@ describe("Transport", () => {
repeatCount++; repeatCount++;
}, 0.1, 0, 0.5); }, 0.1, 0, 0.5);
transport.start(); transport.start();
}, 0.6).then(() => { }, 0.61).then(() => {
expect(repeatCount).to.equal(6); expect(repeatCount).to.equal(5);
}); });
}); });

View file

@ -32,7 +32,7 @@ interface TransportOptions extends ToneWithContextOptions {
ppq: number; ppq: number;
} }
type TransportEventNames = "start" | "stop" | "pause" | "loop" | "loopEnd" | "loopStart"; type TransportEventNames = "start" | "stop" | "pause" | "loop" | "loopEnd" | "loopStart" | "ticks";
interface SyncedSignalEvent { interface SyncedSignalEvent {
signal: Signal; signal: Signal;
@ -576,6 +576,7 @@ export class Transport extends ToneWithContext<TransportOptions> implements Emit
// restart it with the new time // restart it with the new time
this.emit("start", time, this._clock.getSecondsAtTime(time)); this.emit("start", time, this._clock.getSecondsAtTime(time));
} else { } else {
this.emit("ticks", now);
this._clock.setTicksAtTime(t, now); this._clock.setTicksAtTime(t, now);
} }
} }
@ -587,7 +588,7 @@ export class Transport extends ToneWithContext<TransportOptions> implements Emit
* @return The tick value at the given time. * @return The tick value at the given time.
*/ */
getTicksAtTime(time?: Time): Ticks { getTicksAtTime(time?: Time): Ticks {
return Math.round(this._clock.getTicksAtTime(time)); return this._clock.getTicksAtTime(time);
} }
/** /**

View file

@ -41,6 +41,12 @@ export class TransportEvent {
*/ */
private _once: boolean; private _once: boolean;
/**
* The remaining value between the passed in time, and Math.floor(time).
* This value is later added back when scheduling to get sub-tick precision.
*/
protected _remainderTime = 0;
/** /**
* @param transport The transport object which the event belongs to * @param transport The transport object which the event belongs to
*/ */
@ -51,7 +57,8 @@ export class TransportEvent {
this.transport = transport; this.transport = transport;
this.callback = options.callback; this.callback = options.callback;
this._once = options.once; this._once = options.once;
this.time = options.time; this.time = Math.floor(options.time);
this._remainderTime = options.time - this.time;
} }
static getDefaults(): TransportEventOptions { static getDefaults(): TransportEventOptions {
@ -67,13 +74,21 @@ export class TransportEvent {
*/ */
private static _eventId = 0; private static _eventId = 0;
/**
* Get the time and remainder time.
*/
protected get floatTime(): number {
return this.time + this._remainderTime;
}
/** /**
* Invoke the event callback. * Invoke the event callback.
* @param time The AudioContext time in seconds of the event * @param time The AudioContext time in seconds of the event
*/ */
invoke(time: Seconds): void { invoke(time: Seconds): void {
if (this.callback) { if (this.callback) {
this.callback(time); const tickDuration = this.transport.bpm.getDurationOfTicks(1, time);
this.callback(time + this._remainderTime * tickDuration);
if (this._once) { if (this._once) {
this.transport.clear(this.id); this.transport.clear(this.id);
} }

View file

@ -2,6 +2,7 @@ import { BaseContext } from "../context/BaseContext";
import { TicksClass } from "../type/Ticks"; import { TicksClass } from "../type/Ticks";
import { Seconds, Ticks, Time } from "../type/Units"; import { Seconds, Ticks, Time } from "../type/Units";
import { TransportEvent, TransportEventOptions } from "./TransportEvent"; import { TransportEvent, TransportEventOptions } from "./TransportEvent";
import { GT, LT } from "../util/Math";
type Transport = import("../clock/Transport").Transport; type Transport = import("../clock/Transport").Transport;
@ -60,11 +61,12 @@ export class TransportRepeatEvent extends TransportEvent {
const options = Object.assign(TransportRepeatEvent.getDefaults(), opts); const options = Object.assign(TransportRepeatEvent.getDefaults(), opts);
this.duration = new TicksClass(transport.context, options.duration).valueOf(); this.duration = options.duration;
this._interval = new TicksClass(transport.context, options.interval).valueOf(); this._interval = options.interval;
this._nextTick = options.time; this._nextTick = options.time;
this.transport.on("start", this._boundRestart); this.transport.on("start", this._boundRestart);
this.transport.on("loopStart", this._boundRestart); this.transport.on("loopStart", this._boundRestart);
this.transport.on("ticks", this._boundRestart);
this.context = this.transport.context; this.context = this.transport.context;
this._restart(); this._restart();
} }
@ -89,13 +91,25 @@ export class TransportRepeatEvent extends TransportEvent {
super.invoke(time); super.invoke(time);
} }
/**
* Create an event on the transport on the nextTick
*/
private _createEvent(): number {
if (LT(this._nextTick, this.floatTime + this.duration)) {
return this.transport.scheduleOnce(this.invoke.bind(this),
new TicksClass(this.context, this._nextTick).toSeconds());
}
return -1;
}
/** /**
* Push more events onto the timeline to keep up with the position of the timeline * Push more events onto the timeline to keep up with the position of the timeline
*/ */
private _createEvents(time: Seconds): void { private _createEvents(time: Seconds): void {
// schedule the next event // schedule the next event
const ticks = this.transport.getTicksAtTime(time); // const ticks = this.transport.getTicksAtTime(time);
if (ticks >= this.time && ticks >= this._nextTick && this._nextTick + this._interval < this.time + this.duration) { // if the next tick is within the bounds set by "duration"
if (LT(this._nextTick + this._interval, this.floatTime + this.duration)) {
this._nextTick += this._interval; this._nextTick += this._interval;
this._currentId = this._nextId; this._currentId = this._nextId;
this._nextId = this.transport.scheduleOnce(this.invoke.bind(this), this._nextId = this.transport.scheduleOnce(this.invoke.bind(this),
@ -104,21 +118,21 @@ export class TransportRepeatEvent extends TransportEvent {
} }
/** /**
* Push more events onto the timeline to keep up with the position of the timeline * Re-compute the events when the transport time has changed from a start/ticks/loopStart event
*/ */
private _restart(time?: Time): void { private _restart(time?: Time): void {
this.transport.clear(this._currentId); this.transport.clear(this._currentId);
this.transport.clear(this._nextId); this.transport.clear(this._nextId);
this._nextTick = this.time; // start at the first event
this._nextTick = this.floatTime;
const ticks = this.transport.getTicksAtTime(time); const ticks = this.transport.getTicksAtTime(time);
if (ticks > this.time) { if (GT(ticks, this.time)) {
this._nextTick = this.time + Math.ceil((ticks - this.time) / this._interval) * this._interval; // the event is not being scheduled from the beginning and should be offset
this._nextTick = this.floatTime + Math.ceil((ticks - this.floatTime) / this._interval) * this._interval;
} }
this._currentId = this.transport.scheduleOnce(this.invoke.bind(this), this._currentId = this._createEvent();
new TicksClass(this.context, this._nextTick).toSeconds());
this._nextTick += this._interval; this._nextTick += this._interval;
this._nextId = this.transport.scheduleOnce(this.invoke.bind(this), this._nextId = this._createEvent();
new TicksClass(this.context, this._nextTick).toSeconds());
} }
/** /**
@ -130,6 +144,7 @@ export class TransportRepeatEvent extends TransportEvent {
this.transport.clear(this._nextId); this.transport.clear(this._nextId);
this.transport.off("start", this._boundRestart); this.transport.off("start", this._boundRestart);
this.transport.off("loopStart", this._boundRestart); this.transport.off("loopStart", this._boundRestart);
this.transport.off("ticks", this._boundRestart);
return this; return this;
} }
} }

View file

@ -113,7 +113,7 @@ export class TimeClass<Type extends Seconds | Ticks = Seconds, Unit extends stri
toTicks(): Ticks { toTicks(): Ticks {
const quarterTime = this._beatsToUnits(1); const quarterTime = this._beatsToUnits(1);
const quarters = this.valueOf() / quarterTime; const quarters = this.valueOf() / quarterTime;
return Math.round(quarters * this._getPPQ()); return quarters * this._getPPQ();
} }
/** /**

View file

@ -235,7 +235,7 @@ describe("Loop", () => {
} }
}).start(0); }).start(0);
transport.start(); transport.start();
}, 0.8).then(() => { }, 0.81).then(() => {
expect(callCount).to.equal(9); expect(callCount).to.equal(9);
}); });
}); });
@ -326,7 +326,7 @@ describe("Loop", () => {
loop.playbackRate = 1.5; loop.playbackRate = 1.5;
expect(loop.playbackRate).to.equal(1.5); expect(loop.playbackRate).to.equal(1.5);
transport.start(); transport.start();
}, 0.8).then(() => { }, 0.81).then(() => {
expect(callCount).to.equal(13); expect(callCount).to.equal(13);
}); });
}); });

View file

@ -355,11 +355,11 @@ describe("ToneEvent", () => {
it("can be started and stopped multiple times", () => { it("can be started and stopped multiple times", () => {
return Offline(({ transport }) => { return Offline(({ transport }) => {
const eventTimes = [0.3, 0.4, 0.9, 1.0, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9]; const eventTimes = [0.3, 0.39, 0.9, 0.99, 1.3, 1.39, 1.48, 1.57, 1.66, 1.75, 1.84];
let eventTimeIndex = 0; let eventTimeIndex = 0;
new ToneEvent({ new ToneEvent({
loop: true, loop: true,
loopEnd: 0.1, loopEnd: 0.09,
callback(time): void { callback(time): void {
expect(eventTimes.length).to.be.gt(eventTimeIndex); expect(eventTimes.length).to.be.gt(eventTimeIndex);
expect(eventTimes[eventTimeIndex]).to.be.closeTo(time, 0.05); expect(eventTimes[eventTimeIndex]).to.be.closeTo(time, 0.05);

View file

@ -251,11 +251,11 @@ export class ToneEvent<ValueType = any> extends ToneWithContext<ToneEventOptions
if (this._state.getValueAtTime(ticks) === "started") { if (this._state.getValueAtTime(ticks) === "started") {
this._state.setStateAtTime("stopped", ticks, { id: -1 }); this._state.setStateAtTime("stopped", ticks, { id: -1 });
const previousEvent = this._state.getBefore(ticks); const previousEvent = this._state.getBefore(ticks);
let reschedulTime = ticks; let rescheduleTime = ticks;
if (previousEvent !== null) { if (previousEvent !== null) {
reschedulTime = previousEvent.time; rescheduleTime = previousEvent.time;
} }
this._rescheduleEvents(reschedulTime); this._rescheduleEvents(rescheduleTime);
} }
return this; return this;
} }
@ -300,7 +300,7 @@ export class ToneEvent<ValueType = any> extends ToneWithContext<ToneEventOptions
* Get the duration of the loop. * Get the duration of the loop.
*/ */
protected _getLoopDuration(): Ticks { protected _getLoopDuration(): Ticks {
return Math.round((this._loopEnd - this._loopStart) / this._playbackRate); return (this._loopEnd - this._loopStart) / this._playbackRate;
} }
/** /**
@ -322,7 +322,7 @@ export class ToneEvent<ValueType = any> extends ToneWithContext<ToneEventOptions
} }
/** /**
* The playback rate of the note. Defaults to 1. * The playback rate of the event. Defaults to 1.
* @example * @example
* const note = new Tone.ToneEvent(); * const note = new Tone.ToneEvent();
* note.loop = true; * note.loop = true;

View file

@ -368,7 +368,7 @@ describe("Source", () => {
}, 0.7).then(output => { }, 0.7).then(output => {
expect(output.getValueAtTime(0.01)).to.be.closeTo(0.2, 0.01); expect(output.getValueAtTime(0.01)).to.be.closeTo(0.2, 0.01);
expect(output.getValueAtTime(0.1)).to.be.closeTo(0.3, 0.01); expect(output.getValueAtTime(0.1)).to.be.closeTo(0.3, 0.01);
expect(output.getValueAtTime(0.2)).to.be.closeTo(0.4, 0.01); expect(output.getValueAtTime(0.199)).to.be.closeTo(0.4, 0.01);
expect(output.getValueAtTime(0.31)).to.be.equal(0); expect(output.getValueAtTime(0.31)).to.be.equal(0);
}); });
}); });

2
package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "tone", "name": "tone",
"version": "14.7.0", "version": "14.8.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "tone", "name": "tone",
"version": "14.7.0", "version": "14.8.0",
"description": "A Web Audio framework for making interactive music in the browser.", "description": "A Web Audio framework for making interactive music in the browser.",
"main": "build/Tone.js", "main": "build/Tone.js",
"module": "build/esm/index.js", "module": "build/esm/index.js",