Don't reschedule source when offset is very small

This offset is due to floating point error

Fixes #999
Fixes #944
This commit is contained in:
Yotam Mann 2021-12-19 11:21:27 -05:00
parent aeaaa1e871
commit 444d6179c4
3 changed files with 182 additions and 76 deletions

View file

@ -2,11 +2,19 @@ import { Volume } from "../component/channel/Volume";
import "../core/context/Destination";
import "../core/clock/Transport";
import { Param } from "../core/context/Param";
import { OutputNode, ToneAudioNode, ToneAudioNodeOptions } from "../core/context/ToneAudioNode";
import {
OutputNode,
ToneAudioNode,
ToneAudioNodeOptions,
} from "../core/context/ToneAudioNode";
import { Decibels, Seconds, Time } from "../core/type/Units";
import { defaultArg } from "../core/util/Defaults";
import { noOp, readOnly } from "../core/util/Interface";
import { BasicPlaybackState, StateTimeline, StateTimelineEvent } from "../core/util/StateTimeline";
import {
BasicPlaybackState,
StateTimeline,
StateTimelineEvent,
} from "../core/util/StateTimeline";
import { isDefined, isUndef } from "../core/util/TypeCheck";
import { assert, assertContextRunning } from "../core/util/Debug";
import { GT } from "../core/util/Math";
@ -20,9 +28,9 @@ export interface SourceOptions extends ToneAudioNodeOptions {
}
/**
* Base class for sources.
* Base class for sources.
* start/stop of this.context.transport.
*
*
* ```
* // Multiple state change events can be chained together,
* // but must be set in the correct order and with ascending times
@ -36,8 +44,9 @@ export interface SourceOptions extends ToneAudioNodeOptions {
* state.start("+0.3").stop("+0.2");
* ```
*/
export abstract class Source<Options extends SourceOptions> extends ToneAudioNode<Options> {
export abstract class Source<
Options extends SourceOptions
> extends ToneAudioNode<Options> {
/**
* The output volume node
*/
@ -129,7 +138,9 @@ export abstract class Source<Options extends SourceOptions> extends ToneAudioNod
get state(): BasicPlaybackState {
if (this._synced) {
if (this.context.transport.state === "started") {
return this._state.getValueAtTime(this.context.transport.seconds) as BasicPlaybackState;
return this._state.getValueAtTime(
this.context.transport.seconds
) as BasicPlaybackState;
} else {
return "stopped";
}
@ -155,7 +166,11 @@ export abstract class Source<Options extends SourceOptions> extends ToneAudioNod
// overwrite these functions
protected abstract _start(time: Time, offset?: Time, duration?: Time): void;
protected abstract _stop(time: Time): void;
protected abstract _restart(time: Seconds, offset?: Time, duration?: Time): void;
protected abstract _restart(
time: Seconds,
offset?: Time,
duration?: Time
): void;
/**
* Ensure that the scheduled time is not before the current time.
@ -178,12 +193,24 @@ export abstract class Source<Options extends SourceOptions> extends ToneAudioNod
* source.start("+0.5"); // starts the source 0.5 seconds from now
*/
start(time?: Time, offset?: Time, duration?: Time): this {
let computedTime = isUndef(time) && this._synced ? this.context.transport.seconds : this.toSeconds(time);
let computedTime =
isUndef(time) && this._synced
? this.context.transport.seconds
: this.toSeconds(time);
computedTime = this._clampToCurrentTime(computedTime);
// if it's started, stop it and restart it
if (!this._synced && this._state.getValueAtTime(computedTime) === "started") {
if (
!this._synced &&
this._state.getValueAtTime(computedTime) === "started"
) {
// time should be strictly greater than the previous start time
assert(GT(computedTime, (this._state.get(computedTime) as StateTimelineEvent).time), "Start time must be strictly greater than previous start time");
assert(
GT(
computedTime,
(this._state.get(computedTime) as StateTimelineEvent).time
),
"Start time must be strictly greater than previous start time"
);
this._state.cancel(computedTime);
this._state.setStateAtTime("started", computedTime);
this.log("restart", computedTime);
@ -196,18 +223,26 @@ export abstract class Source<Options extends SourceOptions> extends ToneAudioNod
const event = this._state.get(computedTime);
if (event) {
event.offset = this.toSeconds(defaultArg(offset, 0));
event.duration = duration ? this.toSeconds(duration) : undefined;
event.duration = duration
? this.toSeconds(duration)
: undefined;
}
const sched = this.context.transport.schedule(t => {
const sched = this.context.transport.schedule((t) => {
this._start(t, offset, duration);
}, computedTime);
this._scheduled.push(sched);
// if the transport is already started
// and the time is greater than where the transport is
if (this.context.transport.state === "started" &&
this.context.transport.getSecondsAtTime(this.immediate()) > computedTime) {
this._syncedStart(this.now(), this.context.transport.seconds);
if (
this.context.transport.state === "started" &&
this.context.transport.getSecondsAtTime(this.immediate()) >
computedTime
) {
this._syncedStart(
this.now(),
this.context.transport.seconds
);
}
} else {
assertContextRunning(this.context);
@ -227,14 +262,23 @@ export abstract class Source<Options extends SourceOptions> extends ToneAudioNod
* source.stop("+0.5"); // stops the source 0.5 seconds from now
*/
stop(time?: Time): this {
let computedTime = isUndef(time) && this._synced ? this.context.transport.seconds : this.toSeconds(time);
let computedTime =
isUndef(time) && this._synced
? this.context.transport.seconds
: this.toSeconds(time);
computedTime = this._clampToCurrentTime(computedTime);
if (this._state.getValueAtTime(computedTime) === "started" || isDefined(this._state.getNextState("started", computedTime))) {
if (
this._state.getValueAtTime(computedTime) === "started" ||
isDefined(this._state.getNextState("started", computedTime))
) {
this.log("stop", computedTime);
if (!this._synced) {
this._stop(computedTime);
} else {
const sched = this.context.transport.schedule(this._stop.bind(this), computedTime);
const sched = this.context.transport.schedule(
this._stop.bind(this),
computedTime
);
this._scheduled.push(sched);
}
this._state.cancel(computedTime);
@ -274,23 +318,36 @@ export abstract class Source<Options extends SourceOptions> extends ToneAudioNod
if (!this._synced) {
this._synced = true;
this._syncedStart = (time, offset) => {
if (offset > 0) {
if (GT(offset, 0)) {
// get the playback state at that time
const stateEvent = this._state.get(offset);
// listen for start events which may occur in the middle of the sync'ed time
if (stateEvent && stateEvent.state === "started" && stateEvent.time !== offset) {
if (
stateEvent &&
stateEvent.state === "started" &&
stateEvent.time !== offset
) {
// get the offset
const startOffset = offset - this.toSeconds(stateEvent.time);
const startOffset =
offset - this.toSeconds(stateEvent.time);
let duration: number | undefined;
if (stateEvent.duration) {
duration = this.toSeconds(stateEvent.duration) - startOffset;
duration =
this.toSeconds(stateEvent.duration) -
startOffset;
}
this._start(time, this.toSeconds(stateEvent.offset) + startOffset, duration);
this._start(
time,
this.toSeconds(stateEvent.offset) + startOffset,
duration
);
}
}
};
this._syncedStop = time => {
const seconds = this.context.transport.getSecondsAtTime(Math.max(time - this.sampleTime, 0));
this._syncedStop = (time) => {
const seconds = this.context.transport.getSecondsAtTime(
Math.max(time - this.sampleTime, 0)
);
if (this._state.getValueAtTime(seconds) === "started") {
this._stop(time);
}
@ -317,7 +374,7 @@ export abstract class Source<Options extends SourceOptions> extends ToneAudioNod
}
this._synced = false;
// clear all of the scheduled ids
this._scheduled.forEach(id => this.context.transport.clear(id));
this._scheduled.forEach((id) => this.context.transport.clear(id));
this._scheduled = [];
this._state.cancel(0);
// stop it also

View file

@ -8,7 +8,6 @@ import { getContext } from "Tone/core/Global";
import { Player } from "./Player";
describe("Player", () => {
const buffer = new ToneAudioBuffer();
beforeEach(() => {
@ -20,15 +19,18 @@ describe("Player", () => {
SourceTests(Player, buffer);
it("matches a file", () => {
return CompareToFile(() => {
const player = new Player(buffer).toDestination();
player.start(0.1).stop(0.2);
player.playbackRate = 2;
}, "player.wav", 0.005);
return CompareToFile(
() => {
const player = new Player(buffer).toDestination();
player.start(0.1).stop(0.2);
player.playbackRate = 2;
},
"player.wav",
0.005
);
});
context("Constructor", () => {
it("can be constructed with a Tone.Buffer", () => {
const player = new Player(buffer);
expect(player.buffer.get()).to.equal(buffer.get());
@ -60,7 +62,6 @@ describe("Player", () => {
});
context("onstop", () => {
it("invokes the onstop method when the player is explicitly stopped", () => {
let wasInvoked = false;
return Offline(() => {
@ -105,7 +106,6 @@ describe("Player", () => {
});
context("Loading", () => {
it("loads a url which was passed in", (done) => {
const player = new Player("./audio/sine.wav", () => {
expect(player.loaded).to.be.true;
@ -131,12 +131,12 @@ describe("Player", () => {
it("invokes onerror if no url", (done) => {
const source = new Player({
url: "./nosuchfile.wav",
url: "./nosuchfile.wav",
onerror(e) {
expect(e).to.be.instanceOf(Error);
source.dispose();
done();
}
},
});
});
@ -152,11 +152,9 @@ describe("Player", () => {
url: "./audio/sine.wav",
});
});
});
context("Reverse", () => {
it("can get/set reverse", () => {
const player = new Player();
player.reverse = true;
@ -166,7 +164,9 @@ describe("Player", () => {
it("can be played in reverse", () => {
const shorterBuffer = buffer.slice(0, buffer.duration / 2);
const audioBuffer = (shorterBuffer.get() as AudioBuffer).getChannelData(0);
const audioBuffer = (
shorterBuffer.get() as AudioBuffer
).getChannelData(0);
const lastSample = audioBuffer[audioBuffer.length - 1];
expect(lastSample).to.not.equal(0);
return Offline(() => {
@ -180,11 +180,9 @@ describe("Player", () => {
expect(firstSample).to.equal(lastSample);
});
});
});
context("Looping", () => {
beforeEach(() => {
return buffer.load("./audio/short_sine.wav");
});
@ -243,7 +241,7 @@ describe("Player", () => {
player.toDestination();
player.start(0);
player.loop = true;
}, buffer.duration * 1.5).then(buff => {
}, buffer.duration * 1.5).then((buff) => {
expect(buff.getRmsAtTime(0)).to.be.above(0);
expect(buff.getRmsAtTime(buffer.duration * 0.5)).to.be.above(0);
expect(buff.getRmsAtTime(buffer.duration)).to.be.above(0);
@ -252,7 +250,8 @@ describe("Player", () => {
});
it("offset is the loopStart when set to loop", () => {
const testSample = buffer.toArray(0)[Math.floor(0.1 * getContext().sampleRate)];
const testSample =
buffer.toArray(0)[Math.floor(0.1 * getContext().sampleRate)];
return Offline(() => {
const player = new Player(buffer);
player.loopStart = 0.1;
@ -271,10 +270,10 @@ describe("Player", () => {
player.loop = true;
player.toDestination();
player.start(0, 0, playDur);
}, buffer.duration * 2).then(buff => {
}, buffer.duration * 2).then((buff) => {
for (let time = 0; time < buffer.duration * 2; time += 0.1) {
const val = buff.getRmsAtTime(time);
if (time < (playDur - 0.01)) {
if (time < playDur - 0.01) {
expect(val).to.be.greaterThan(0);
} else if (time > playDur) {
expect(val).to.equal(0);
@ -286,9 +285,11 @@ describe("Player", () => {
it("correctly compensates if the offset is greater than the loopEnd", () => {
return Offline(() => {
// make a ramp between 0-1
const ramp = new Float32Array(Math.floor(getContext().sampleRate * 0.3));
const ramp = new Float32Array(
Math.floor(getContext().sampleRate * 0.3)
);
for (let i = 0; i < ramp.length; i++) {
ramp[i] = (i / (ramp.length)) * 0.3;
ramp[i] = (i / ramp.length) * 0.3;
}
const buff = ToneAudioBuffer.fromArray(ramp);
const player = new Player(buff).toDestination();
@ -306,7 +307,6 @@ describe("Player", () => {
});
});
});
});
context("PlaybackRate", () => {
@ -344,7 +344,6 @@ describe("Player", () => {
});
context("Get/Set", () => {
it("can be set with an options object", () => {
const player = new Player();
expect(player.loop).to.be.false;
@ -399,13 +398,12 @@ describe("Player", () => {
expect(player.playbackRate).to.equal(0.5);
player.dispose();
});
});
context("Start Scheduling", () => {
it("can be start with an offset", () => {
const testSample = buffer.toArray(0)[Math.floor(0.1 * getContext().sampleRate)];
const testSample =
buffer.toArray(0)[Math.floor(0.1 * getContext().sampleRate)];
return Offline(() => {
const player = new Player(buffer.get());
player.toDestination();
@ -418,9 +416,11 @@ describe("Player", () => {
it("is stopped and restarted when start is called twice", () => {
return Offline(() => {
// make a ramp between 0-1
const ramp = new Float32Array(Math.floor(getContext().sampleRate * 0.3));
const ramp = new Float32Array(
Math.floor(getContext().sampleRate * 0.3)
);
for (let i = 0; i < ramp.length; i++) {
ramp[i] = (i / (ramp.length - 1));
ramp[i] = i / (ramp.length - 1);
}
const buff = new ToneAudioBuffer().fromArray(ramp);
const player = new Player(buff).toDestination();
@ -442,9 +442,11 @@ describe("Player", () => {
it("can seek to a position at the given time", () => {
return Offline(() => {
const ramp = new Float32Array(Math.floor(getContext().sampleRate * 0.3));
const ramp = new Float32Array(
Math.floor(getContext().sampleRate * 0.3)
);
for (let i = 0; i < ramp.length; i++) {
ramp[i] = (i / (ramp.length)) * 0.3;
ramp[i] = (i / ramp.length) * 0.3;
}
const buff = new ToneAudioBuffer().fromArray(ramp);
const player = new Player(buff).toDestination();
@ -466,7 +468,7 @@ describe("Player", () => {
const player = new Player(buffer);
player.toDestination();
player.start(0).stop(0.1);
return time => {
return (time) => {
whenBetween(time, 0.1, Infinity, () => {
expect(player.state).to.equal("stopped");
});
@ -475,9 +477,13 @@ describe("Player", () => {
});
};
}, 0.3).then((buff) => {
buff.forEachBetween((sample) => {
expect(sample).to.equal(0);
}, 0.11, 0.15);
buff.forEachBetween(
(sample) => {
expect(sample).to.equal(0);
},
0.11,
0.15
);
});
});
@ -495,7 +501,10 @@ describe("Player", () => {
return Offline(() => {
const player = new Player(buffer);
player.toDestination();
player.start(0, 0, 0.05).start(0.1, 0, 0.05).start(0.2, 0, 0.05);
player
.start(0, 0, 0.05)
.start(0.1, 0, 0.05)
.start(0.2, 0, 0.05);
player.stop(0.1);
}, 0.3).then((buff) => {
expect(buff.getTimeOfLastSound()).to.be.closeTo(0.1, 0.02);
@ -518,7 +527,7 @@ describe("Player", () => {
const player = new Player(buffer);
player.toDestination();
player.start(0, 0, 0.1);
return time => {
return (time) => {
whenBetween(time, 0.1, Infinity, () => {
expect(player.state).to.equal("stopped");
});
@ -549,17 +558,42 @@ describe("Player", () => {
it("plays synced to the Transport", () => {
return Offline(({ transport }) => {
const player = new Player(buffer).sync().start(0).toDestination();
const player = new Player(buffer)
.sync()
.start(0)
.toDestination();
transport.start(0);
}, 0.05).then((buff) => {
expect(buff.isSilent()).to.be.false;
});
});
it("does not play twice when the offset is very small", () => {
// addresses #999 and #944
return CompareToFile(
() => {
const player = new Player(buffer).toDestination();
player.sync().start(0);
getContext().transport.bpm.value = 125;
getContext().transport.setLoopPoints(0, "1:0:0");
getContext().transport.loop = true;
getContext().transport.start(0);
},
"playerSyncLoop.wav",
0.01
);
});
it("offsets correctly when started by the Transport", () => {
const testSample = buffer.toArray(0)[Math.floor(0.13125 * getContext().sampleRate)];
const testSample =
buffer.toArray(0)[
Math.floor(0.13125 * getContext().sampleRate)
];
return Offline(({ transport }) => {
const player = new Player(buffer).sync().start(0, 0.1).toDestination();
const player = new Player(buffer)
.sync()
.start(0, 0.1)
.toDestination();
transport.start(0, 0.03125);
}, 0.05).then((buff) => {
expect(buff.toArray()[0][0]).to.equal(testSample);
@ -569,9 +603,11 @@ describe("Player", () => {
it("starts at the correct position when Transport is offset and playbackRate is not 1", () => {
return Offline(({ transport }) => {
// make a ramp between 0-1
const ramp = new Float32Array(Math.floor(getContext().sampleRate * 0.3));
const ramp = new Float32Array(
Math.floor(getContext().sampleRate * 0.3)
);
for (let i = 0; i < ramp.length; i++) {
ramp[i] = (i / (ramp.length));
ramp[i] = i / ramp.length;
}
const buff = ToneAudioBuffer.fromArray(ramp);
const player = new Player(buff).toDestination();
@ -586,9 +622,11 @@ describe("Player", () => {
it("starts with an offset when synced and started after Transport is running", () => {
return Offline(({ transport }) => {
const ramp = new Float32Array(Math.floor(getContext().sampleRate * 0.3));
const ramp = new Float32Array(
Math.floor(getContext().sampleRate * 0.3)
);
for (let i = 0; i < ramp.length; i++) {
ramp[i] = (i / (ramp.length)) * 0.3;
ramp[i] = (i / ramp.length) * 0.3;
}
const buff = new ToneAudioBuffer().fromArray(ramp);
const player = new Player(buff).toDestination();
@ -606,9 +644,11 @@ describe("Player", () => {
it("can pass in an offset when synced and started after Transport is running", () => {
return Offline(({ transport }) => {
const ramp = new Float32Array(Math.floor(getContext().sampleRate * 0.3));
const ramp = new Float32Array(
Math.floor(getContext().sampleRate * 0.3)
);
for (let i = 0; i < ramp.length; i++) {
ramp[i] = (i / (ramp.length)) * 0.3;
ramp[i] = (i / ramp.length) * 0.3;
}
const buff = new ToneAudioBuffer().fromArray(ramp);
const player = new Player(buff).toDestination();
@ -630,12 +670,18 @@ describe("Player", () => {
it("fades in and out correctly", () => {
let duration = 0.5;
return Offline(() => {
const onesArray = new Float32Array(getContext().sampleRate * duration);
const onesArray = new Float32Array(
getContext().sampleRate * duration
);
onesArray.forEach((sample, index) => {
onesArray[index] = 1;
});
const onesBuffer = ToneAudioBuffer.fromArray(onesArray);
const player = new Player({ url: onesBuffer, fadeOut: 0.1, fadeIn: 0.1 }).toDestination();
const player = new Player({
url: onesBuffer,
fadeOut: 0.1,
fadeIn: 0.1,
}).toDestination();
player.start(0);
}, 0.6).then((buff) => {
expect(buff.getRmsAtTime(0)).to.be.closeTo(0, 0.1);
@ -643,7 +689,10 @@ describe("Player", () => {
expect(buff.getRmsAtTime(0.1)).to.be.closeTo(1, 0.1);
duration -= 0.1;
expect(buff.getRmsAtTime(duration)).to.be.closeTo(1, 0.1);
expect(buff.getRmsAtTime(duration + 0.05)).to.be.closeTo(0.5, 0.1);
expect(buff.getRmsAtTime(duration + 0.05)).to.be.closeTo(
0.5,
0.1
);
expect(buff.getRmsAtTime(duration + 0.1)).to.be.closeTo(0, 0.1);
});
});

Binary file not shown.