diff --git a/Tone/event/ToneEvent.test.ts b/Tone/event/ToneEvent.test.ts new file mode 100644 index 00000000..adf40318 --- /dev/null +++ b/Tone/event/ToneEvent.test.ts @@ -0,0 +1,485 @@ +import { expect } from "chai"; +import { BasicTests } from "test/helper/Basic"; +import { Offline, whenBetween } from "test/helper/Offline"; +import { Time } from "Tone/core/type/Time"; +import { noOp } from "Tone/core/util/Interface"; +import { ToneEvent } from "./ToneEvent"; + +describe("ToneEvent", () => { + + BasicTests(ToneEvent); + + context("Constructor", () => { + + it("takes a callback and a value", () => { + return Offline(() => { + const callback = noOp; + const note = new ToneEvent(callback, "C4"); + expect(note.callback).to.equal(callback); + expect(note.value).to.equal("C4"); + note.dispose(); + }); + }); + + it("can be constructed with no arguments", () => { + return Offline(() => { + const note = new ToneEvent(); + expect(note.value).to.be.null; + note.dispose(); + }); + }); + + it("can pass in arguments in options object", () => { + return Offline(() => { + const callback = noOp; + const value = { a : 1 }; + const note = new ToneEvent({ + callback, + loop : true, + loopEnd : "4n", + probability : 0.3, + value, + }); + expect(note.callback).to.equal(callback); + expect(note.value).to.deep.equal(value); + expect(note.loop).to.be.true; + expect(note.loopEnd).to.equal(Time("4n").valueOf()); + expect(note.probability).to.equal(0.3); + note.dispose(); + }); + }); + }); + + context("Get/Set", () => { + + it("can set values with object", () => { + return Offline(() => { + const callback = noOp; + const note = new ToneEvent(); + note.set({ + callback, + loop : 8, + value : "D4", + }); + expect(note.callback).to.equal(callback); + expect(note.value).to.equal("D4"); + expect(note.loop).to.equal(8); + note.dispose(); + }); + }); + + it("can set get a the values as an object", () => { + return Offline(() => { + const callback = noOp; + const note = new ToneEvent({ + callback, + loop : 4, + value : "D3", + }); + const values = note.get(); + expect(values.value).to.equal("D3"); + expect(values.loop).to.equal(4); + note.dispose(); + }); + }); + }); + + context("ToneEvent callback", () => { + + it("does not invoke get invoked until started", () => { + return Offline(({transport}) => { + const event = new ToneEvent(() => { + throw new Error("shouldn't call this callback"); + }, "C4"); + transport.start(); + }, 0.3); + }); + + it("is invoked after it's started", () => { + let invoked = false; + return Offline(({transport}) => { + const note = new ToneEvent(() => { + note.dispose(); + invoked = true; + }, "C4").start(0); + transport.start(); + }, 0.3).then(() => { + expect(invoked).to.be.true; + }); + }); + + it("passes in the scheduled time to the callback", () => { + let invoked = false; + return Offline(({transport}) => { + const now = 0.1; + const note = new ToneEvent((time) => { + expect(time).to.be.a("number"); + expect(time - now).to.be.closeTo(0.3, 0.01); + note.dispose(); + invoked = true; + }); + note.start(0.3); + transport.start(now); + }, 0.5).then(() => { + expect(invoked).to.be.true; + }); + }); + + it("passes in the value to the callback", () => { + let invoked = false; + return Offline(({transport}) => { + const note = new ToneEvent((time, thing) => { + expect(time).to.be.a("number"); + expect(thing).to.equal("thing"); + note.dispose(); + invoked = true; + }, "thing").start(); + transport.start(); + }, 0.3).then(() => { + expect(invoked).to.be.true; + }); + }); + + it("can mute the callback", () => { + return Offline(({transport}) => { + const note = new ToneEvent(() => { + throw new Error("shouldn't call this callback"); + }, "C4").start(); + note.mute = true; + expect(note.mute).to.be.true; + transport.start(); + }, 0.3); + }); + + it("can trigger with some probability", () => { + + return Offline(({transport}) => { + const note = new ToneEvent(() => { + throw new Error("shouldn't call this callback"); + }, "C4").start(); + note.probability = 0; + expect(note.probability).to.equal(0); + transport.start(); + }, 0.3); + }); + }); + + context("Scheduling", () => { + + it("can be started and stopped multiple times", () => { + return Offline(({transport}) => { + const note = new ToneEvent().start(0).stop(0.2).start(0.4); + transport.start(0); + return (time) => { + whenBetween(time, 0, 0.19, () => { + expect(note.state).to.equal("started"); + }); + whenBetween(time, 0.2, 0.39, () => { + expect(note.state).to.equal("stopped"); + }); + whenBetween(time, 0.4, Infinity, () => { + expect(note.state).to.equal("started"); + }); + }; + }, 0.5); + }); + + it("restarts when transport is restarted", () => { + + return Offline(({transport}) => { + const note = new ToneEvent().start(0).stop(0.4); + transport.start(0).stop(0.5).start(0.55); + return (time) => { + whenBetween(time, 0, 0.39, () => { + expect(note.state).to.equal("started"); + }); + whenBetween(time, 0.4, 0.5, () => { + expect(note.state).to.equal("stopped"); + }); + whenBetween(time, 0.55, 0.8, () => { + expect(note.state).to.equal("started"); + }); + }; + }, 1); + }); + + it("can be cancelled", () => { + return Offline(({transport}) => { + const note = new ToneEvent().start(0); + expect(note.state).to.equal("started"); + transport.start(); + + let firstStop = false; + let restarted = false; + const tested = false; + return (time) => { + // stop the transport + if (time > 0.2 && !firstStop) { + firstStop = true; + transport.stop(); + note.cancel(); + } + if (time > 0.3 && !restarted) { + restarted = true; + transport.start(); + } + if (time > 0.4 && !tested) { + restarted = true; + transport.start(); + expect(note.state).to.equal("stopped"); + } + }; + }, 0.5); + }); + + }); + + context("Looping", () => { + + it("can be set to loop", () => { + let callCount = 0; + return Offline(({transport}) => { + new ToneEvent({ + callback(): void { + callCount++; + }, + loop : true, + loopEnd : 0.25, + }).start(0); + transport.start(0); + }, 0.8).then(() => { + expect(callCount).to.equal(4); + }); + + }); + + it("can be set to loop at a specific interval", () => { + return Offline(({transport}) => { + let lastCall; + new ToneEvent({ + callback(time): void { + if (lastCall) { + expect(time - lastCall).to.be.closeTo(0.25, 0.01); + } + lastCall = time; + }, + loop : true, + loopEnd : 0.25, + }).start(0); + transport.start(); + }, 1); + }); + + it("can adjust the loop duration after starting", () => { + return Offline(({transport}) => { + let lastCall; + const note = new ToneEvent({ + loop : true, + loopEnd : 0.5, + callback(time): void { + if (lastCall) { + expect(time - lastCall).to.be.closeTo(0.25, 0.01); + } else { + note.loopEnd = 0.25; + } + lastCall = time; + }, + }).start(0); + transport.start(); + }, 0.8); + }); + + it("can loop a specific number of times", () => { + let callCount = 0; + return Offline(({transport}) => { + new ToneEvent({ + loop : 3, + loopEnd : 0.125, + callback(): void { + callCount++; + }, + }).start(0); + transport.start(); + }, 0.8).then(() => { + expect(callCount).to.equal(3); + }); + }); + + it("plays once when loop is 1", () => { + let callCount = 0; + return Offline(({transport}) => { + new ToneEvent({ + loop : 1, + loopEnd : 0.125, + callback(): void { + callCount++; + }, + }).start(0); + transport.start(); + }, 0.8).then(() => { + expect(callCount).to.equal(1); + }); + }); + + it("plays once when loop is 0", () => { + let callCount = 0; + return Offline(({transport}) => { + new ToneEvent({ + loop : 0, + loopEnd : 0.125, + callback(): void { + callCount++; + }, + }).start(0); + transport.start(); + }, 0.8).then(() => { + expect(callCount).to.equal(1); + }); + }); + + it("plays once when loop is false", () => { + let callCount = 0; + return Offline(({transport}) => { + new ToneEvent({ + loop : false, + loopEnd : 0.125, + callback(): void { + callCount++; + }, + }).start(0); + transport.start(); + }, 0.8).then(() => { + expect(callCount).to.equal(1); + }); + }); + + it("can be started and stopped multiple times", () => { + 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]; + let eventTimeIndex = 0; + new ToneEvent({ + loop : true, + loopEnd : 0.1, + callback(time): void { + expect(eventTimes.length).to.be.gt(eventTimeIndex); + expect(eventTimes[eventTimeIndex]).to.be.closeTo(time, 0.05); + eventTimeIndex++; + }, + }).start(0.1).stop(0.2).start(0.5).stop(1.1); + transport.start(0.2).stop(0.5).start(0.8); + }, 2); + }); + + it("loops the correct amount of times when the event is started in the transport's past", () => { + let callCount = 0; + return Offline(({transport}) => { + const note = new ToneEvent({ + loop : 3, + loopEnd : 0.2, + callback(): void { + callCount++; + }, + }); + transport.start(); + let wasCalled = false; + return (time) => { + if (time > 0.1 && !wasCalled) { + wasCalled = true; + note.start(0); + } + }; + }, 1).then(() => { + expect(callCount).to.equal(2); + }); + }); + + it("reports the progress of the loop", () => { + return Offline(({transport}) => { + const note = new ToneEvent({ + loop : true, + loopEnd : 1, + }); + expect(note.progress).to.equal(0); + note.start(0); + transport.start(); + return (time) => { + expect(note.progress).to.be.closeTo(time, 0.05); + }; + }, 0.8); + }); + + it("progress is 0 when not looping", () => { + Offline(({transport}) => { + const note = new ToneEvent({ + loop : false, + loopEnd : 0.25, + }).start(0); + transport.start(); + return () => { + expect(note.progress).to.equal(0); + }; + }, 0.2); + }); + }); + + context("playbackRate and humanize", () => { + + it("can adjust the playbackRate", () => { + return Offline(({transport}) => { + let lastCall; + new ToneEvent({ + loop : true, + loopEnd : 0.5, + playbackRate : 2, + callback(time): void { + if (lastCall) { + expect(time - lastCall).to.be.closeTo(0.25, 0.01); + } + lastCall = time; + }, + }).start(0); + transport.start(); + }, 0.7); + }); + + it("can adjust the playbackRate after starting", () => { + return Offline(({transport}) => { + let lastCall; + const note = new ToneEvent({ + loop : true, + loopEnd : 0.25, + playbackRate : 1, + callback(time): void { + if (lastCall) { + expect(time - lastCall).to.be.closeTo(0.5, 0.01); + } else { + note.playbackRate = 0.5; + } + lastCall = time; + }, + }).start(0); + transport.start(); + }, 1.2); + + }); + + it("can humanize the callback by some amount", () => { + return Offline(({transport}) => { + let lastCall; + const note = new ToneEvent({ + humanize : 0.05, + loop : true, + loopEnd : 0.25, + callback(time): void { + if (lastCall) { + expect(time - lastCall).to.be.within(0.2, 0.3); + } + lastCall += 0.25; + }, + }).start(0); + transport.start(); + }, 0.6); + }); + + }); +}); diff --git a/Tone/event/ToneEvent.ts b/Tone/event/ToneEvent.ts new file mode 100644 index 00000000..71de0eb9 --- /dev/null +++ b/Tone/event/ToneEvent.ts @@ -0,0 +1,384 @@ +import "../core/clock/Transport"; +import { ToneWithContext, ToneWithContextOptions } from "../core/context/ToneWithContext"; +import { TicksClass } from "../core/type/Ticks"; +import { defaultArg, optionsFromArguments } from "../core/util/Defaults"; +import { noOp } from "../core/util/Interface"; +import { PlaybackState, StateTimeline } from "../core/util/StateTimeline"; +import { isBoolean, isDefined, isNumber } from "../core/util/TypeCheck"; + +type ToneEventCallback = (time: Seconds, value: any) => void; + +interface ToneEventOptions extends ToneWithContextOptions { + callback: ToneEventCallback; + loop: boolean | number; + loopEnd: Time; + loopStart: Time; + playbackRate: Positive; + value: any; + probability: NormalRange; + mute: boolean; + humanize: boolean | Time; +} + +/** + * ToneEvent abstracts away this.context.transport.schedule and provides a schedulable + * callback for a single or repeatable events along the timeline. + * + * @extends {Tone} + * @param callback The callback to invoke at the time. + * @param value The value or values which should be passed to the callback function on invocation. + * @example + * var chord = new ToneEvent(function(time, chord){ + * //the chord as well as the exact time of the event + * //are passed in as arguments to the callback function + * }, ["D4", "E4", "F4"]); + * //start the chord at the beginning of the transport timeline + * chord.start(); + * //loop it every measure for 8 measures + * chord.loop = 8; + * chord.loopEnd = "1m"; + */ +export class ToneEvent extends ToneWithContext { + + name = "ToneEvent"; + + /** + * Loop value + */ + private _loop: boolean | number; + + /** + * The callback to invoke. + */ + callback: ToneEventCallback; + + /** + * The value which is passed to the + * callback function. + */ + value: any; + + /** + * When the note is scheduled to start. + */ + private _loopStart: Ticks; + + /** + * When the note is scheduled to start. + */ + private _loopEnd: Ticks; + + /** + * Tracks the scheduled events + */ + private _state: StateTimeline = new StateTimeline("stopped"); + + /** + * The playback speed of the note. A speed of 1 + * is no change. + */ + private _playbackRate: Positive; + + /** + * A delay time from when the event is scheduled to start + */ + private _startOffset: Ticks = 0; + + /** + * private holder of probability value + */ + private _probability: NormalRange; + + /** + * the amount of variation from the given time. + */ + private _humanize: boolean | Time; + + /** + * If mute is true, the callback won't be invoked. + */ + mute: boolean; + + constructor(options?: Partial); + constructor(callback?: ToneEventCallback, value?: any); + constructor() { + + super(optionsFromArguments(ToneEvent.getDefaults(), arguments, ["callback", "value"])); + const options = optionsFromArguments(ToneEvent.getDefaults(), arguments, ["callback", "value"]); + + this._loop = options.loop; + this.callback = options.callback; + this.value = options.value; + this._loopStart = this.toTicks(options.loopStart); + this._loopEnd = this.toTicks(options.loopEnd); + this._playbackRate = options.playbackRate; + this._probability = options.probability; + this._humanize = options.humanize; + this.mute = options.mute; + this.playbackRate = options.playbackRate; + } + + static getDefaults(): ToneEventOptions { + return Object.assign(ToneWithContext.getDefaults(), { + callback : noOp, + humanize : false, + loop : false, + loopEnd : "1m", + loopStart : 0, + mute : false, + playbackRate : 1, + probability : 1, + value : null, + }); + } + + /** + * Reschedule all of the events along the timeline + * with the updated values. + * @param after Only reschedules events after the given time. + * @private + */ + private _rescheduleEvents(after: Ticks = -1): void { + // if no argument is given, schedules all of the events + this._state.forEachFrom(after, event => { + let duration; + if (event.state === "started") { + if (isDefined(event.id)) { + this.context.transport.clear(event.id); + } + const startTick = event.time + Math.round(this.startOffset / this._playbackRate); + if (this._loop === true || isNumber(this._loop) && this._loop > 1) { + duration = Infinity; + if (isNumber(this._loop)) { + duration = (this._loop) * this._getLoopDuration(); + } + const nextEvent = this._state.getAfter(startTick); + if (nextEvent !== null) { + duration = Math.min(duration, nextEvent.time - startTick); + } + if (duration !== Infinity) { + // schedule a stop since it's finite duration + this._state.setStateAtTime("stopped", startTick + duration + 1); + duration = new TicksClass(this.context, duration); + } + const interval = new TicksClass(this.context, this._getLoopDuration()); + event.id = this.context.transport.scheduleRepeat( + this._tick.bind(this), interval, new TicksClass(this.context, startTick), duration); + } else { + event.id = this.context.transport.schedule(this._tick.bind(this), new TicksClass(this.context, startTick)); + } + } + }); + } + + /** + * Returns the playback state of the note, either "started" or "stopped". + */ + get state(): PlaybackState { + return this._state.getValueAtTime(this.context.transport.ticks); + } + + /** + * The start from the scheduled start time + */ + protected get startOffset(): Ticks { + return this._startOffset; + } + protected set startOffset(offset) { + this._startOffset = offset; + } + + /** + * The probability of the notes being triggered. + */ + get probability(): NormalRange { + return this._probability; + } + set probability(prob) { + this._probability = prob; + } + + /** + * If set to true, will apply small random variation + * to the callback time. If the value is given as a time, it will randomize + * by that amount. + * @example + * event.humanize = true; + */ + get humanize(): Time | boolean { + return this._humanize; + } + + set humanize(variation) { + this._humanize = variation; + } + + /** + * Start the note at the given time. + * @param time When the event should start. + */ + start(time?: TransportTime): this { + time = this.toTicks(time); + if (this._state.getValueAtTime(time) === "stopped") { + this._state.add({ + id : undefined, + state : "started", + time, + }); + this._rescheduleEvents(time); + } + return this; + } + + /** + * Stop the Event at the given time. + * @param time When the event should stop. + */ + stop(time?: TransportTime): this { + this.cancel(time); + time = this.toTicks(time); + if (this._state.getValueAtTime(time) === "started") { + this._state.setStateAtTime("stopped", time); + const previousEvent = this._state.getBefore(time); + let reschedulTime = time; + if (previousEvent !== null) { + reschedulTime = previousEvent.time; + } + this._rescheduleEvents(reschedulTime); + } + return this; + } + + /** + * Cancel all scheduled events greater than or equal to the given time + * @param time The time after which events will be cancel. + */ + cancel(time?: TransportTime): this { + time = defaultArg(time, -Infinity); + time = this.toTicks(time); + this._state.forEachFrom(time, event => { + this.context.transport.clear(event.id as number); + }); + this._state.cancel(time); + return this; + } + + /** + * The callback function invoker. Also + * checks if the Event is done playing + * @param time The time of the event in seconds + * @private + */ + private _tick(time: Seconds): void { + const ticks = this.context.transport.getTicksAtTime(time); + if (!this.mute && this._state.getValueAtTime(ticks) === "started") { + if (this.probability < 1 && Math.random() > this.probability) { + return; + } + if (this.humanize) { + let variation = 0.02; + if (!isBoolean(this.humanize)) { + variation = this.toSeconds(this.humanize); + } + time += (Math.random() * 2 - 1) * variation; + } + this.callback(time, this.value); + } + } + + /** + * Get the duration of the loop. + */ + private _getLoopDuration(): Ticks { + return Math.round((this._loopEnd - this._loopStart) / this._playbackRate); + } + + /** + * If the note should loop or not + * between ToneEvent.loopStart and + * ToneEvent.loopEnd. If set to true, + * the event will loop indefinitely, + * if set to a number greater than 1 + * it will play a specific number of + * times, if set to false, 0 or 1, the + * part will only play once. + */ + get loop(): boolean | number { + return this._loop; + } + set loop(loop) { + this._loop = loop; + this._rescheduleEvents(); + } + + /** + * The playback rate of the note. Defaults to 1. + * @example + * note.loop = true; + * //repeat the note twice as fast + * note.playbackRate = 2; + */ + get playbackRate(): Positive { + return this._playbackRate; + } + set playbackRate(rate) { + this._playbackRate = rate; + this._rescheduleEvents(); + } + + /** + * The loopEnd point is the time the event will loop + * if ToneEvent.loop is true. + */ + get loopEnd(): Time { + return new TicksClass(this.context, this._loopEnd).toSeconds(); + } + set loopEnd(loopEnd) { + this._loopEnd = this.toTicks(loopEnd); + if (this._loop) { + this._rescheduleEvents(); + } + } + + /** + * The time when the loop should start. + */ + get loopStart(): Time { + return new TicksClass(this.context, this._loopStart).toSeconds(); + } + set loopStart(loopStart) { + this._loopStart = this.toTicks(loopStart); + if (this._loop) { + this._rescheduleEvents(); + } + } + + /** + * The current progress of the loop interval. + * Returns 0 if the event is not started yet or + * it is not set to loop. + */ + get progress(): NormalRange { + if (this._loop) { + const ticks = this.context.transport.ticks; + const lastEvent = this._state.get(ticks); + if (lastEvent !== null && lastEvent.state === "started") { + const loopDuration = this._getLoopDuration(); + const progress = (ticks - lastEvent.time) % loopDuration; + return progress / loopDuration; + } else { + return 0; + } + } else { + return 0; + } + } + + dispose(): this { + super.dispose(); + this.cancel(); + this._state.dispose(); + this.value = null; + return this; + } +}