import { Tone } from "../Tone"; import { Seconds } from "../type/Units"; import { optionsFromArguments } from "./Defaults"; import { assert } from "./Debug"; import { EQ, GT, GTE, LT } from "./Math"; type TimelineSearchParam = "ticks" | "time"; /** * The options object for Timeline */ interface TimelineOptions { memory: number; increasing: boolean; } /** * An event must have a time number */ export interface TimelineEvent { time: number; } /** * A Timeline class for scheduling and maintaining state * along a timeline. All events must have a "time" property. * Internally, events are stored in time order for fast * retrieval. */ export class Timeline extends Tone { readonly name: string = "Timeline"; /** * The memory of the timeline, i.e. * how many events in the past it will retain */ memory: number; /** * The array of scheduled timeline events */ protected _timeline: GenericEvent[] = []; /** * If the time value must always be greater than or equal to the last * element on the list. */ increasing: boolean; /** * @param memory The number of previous events that are retained. */ constructor(memory?: number); constructor(options?: Partial); constructor() { super(); const options = optionsFromArguments(Timeline.getDefaults(), arguments, ["memory"]); this.memory = options.memory; this.increasing = options.increasing; } static getDefaults(): TimelineOptions { return { memory: Infinity, increasing: false, }; } /** * The number of items in the timeline. */ get length(): number { return this._timeline.length; } /** * Insert an event object onto the timeline. Events must have a "time" attribute. * @param event The event object to insert into the timeline. */ add(event: GenericEvent): this { // the event needs to have a time attribute assert(Reflect.has(event, "time"), "Timeline: events must have a time attribute"); event.time = event.time.valueOf(); if (this.increasing && this.length) { const lastValue = this._timeline[this.length - 1] as GenericEvent; assert(GTE(event.time, lastValue.time), "The time must be greater than or equal to the last scheduled time"); this._timeline.push(event); } else { const index = this._search(event.time); this._timeline.splice(index + 1, 0, event); } // if the length is more than the memory, remove the previous ones if (this.length > this.memory) { const diff = this.length - this.memory; this._timeline.splice(0, diff); } return this; } /** * Remove an event from the timeline. * @param {Object} event The event object to remove from the list. * @returns {Timeline} this */ remove(event: GenericEvent): this { const index = this._timeline.indexOf(event); if (index !== -1) { this._timeline.splice(index, 1); } return this; } /** * Get the nearest event whose time is less than or equal to the given time. * @param time The time to query. */ get(time: number, param: TimelineSearchParam = "time"): GenericEvent | null { const index = this._search(time, param); if (index !== -1) { return this._timeline[index]; } else { return null; } } /** * Return the first event in the timeline without removing it * @returns {Object} The first event object */ peek(): GenericEvent | undefined { return this._timeline[0]; } /** * Return the first event in the timeline and remove it */ shift(): GenericEvent | undefined { return this._timeline.shift(); } /** * Get the event which is scheduled after the given time. * @param time The time to query. */ getAfter(time: number, param: TimelineSearchParam = "time"): GenericEvent | null { const index = this._search(time, param); if (index + 1 < this._timeline.length) { return this._timeline[index + 1]; } else { return null; } } /** * Get the event before the event at the given time. * @param time The time to query. */ getBefore(time: number): GenericEvent | null { const len = this._timeline.length; // if it's after the last item, return the last item if (len > 0 && this._timeline[len - 1].time < time) { return this._timeline[len - 1]; } const index = this._search(time); if (index - 1 >= 0) { return this._timeline[index - 1]; } else { return null; } } /** * Cancel events at and after the given time * @param after The time to query. */ cancel(after: number): this { if (this._timeline.length > 1) { let index = this._search(after); if (index >= 0) { if (EQ(this._timeline[index].time, after)) { // get the first item with that time for (let i = index; i >= 0; i--) { if (EQ(this._timeline[i].time, after)) { index = i; } else { break; } } this._timeline = this._timeline.slice(0, index); } else { this._timeline = this._timeline.slice(0, index + 1); } } else { this._timeline = []; } } else if (this._timeline.length === 1) { // the first item's time if (GTE(this._timeline[0].time, after)) { this._timeline = []; } } return this; } /** * Cancel events before or equal to the given time. * @param time The time to cancel before. */ cancelBefore(time: number): this { const index = this._search(time); if (index >= 0) { this._timeline = this._timeline.slice(index + 1); } return this; } /** * Returns the previous event if there is one. null otherwise * @param event The event to find the previous one of * @return The event right before the given event */ previousEvent(event: GenericEvent): GenericEvent | null { const index = this._timeline.indexOf(event); if (index > 0) { return this._timeline[index - 1]; } else { return null; } } /** * Does a binary search on the timeline array and returns the * nearest event index whose time is after or equal to the given time. * If a time is searched before the first index in the timeline, -1 is returned. * If the time is after the end, the index of the last item is returned. */ protected _search(time: number, param: TimelineSearchParam = "time"): number { if (this._timeline.length === 0) { return -1; } let beginning = 0; const len = this._timeline.length; let end = len; if (len > 0 && this._timeline[len - 1][param] <= time) { return len - 1; } while (beginning < end) { // calculate the midpoint for roughly equal partition let midPoint = Math.floor(beginning + (end - beginning) / 2); const event = this._timeline[midPoint]; const nextEvent = this._timeline[midPoint + 1]; if (EQ(event[param], time)) { // choose the last one that has the same time for (let i = midPoint; i < this._timeline.length; i++) { const testEvent = this._timeline[i]; if (EQ(testEvent[param], time)) { midPoint = i; } else { break; } } return midPoint; } else if (LT(event[param], time) && GT(nextEvent[param], time)) { return midPoint; } else if (GT(event[param], time)) { // search lower end = midPoint; } else { // search upper beginning = midPoint + 1; } } return -1; } /** * Internal iterator. Applies extra safety checks for * removing items from the array. */ private _iterate( callback: (event: GenericEvent) => void, lowerBound = 0, upperBound = this._timeline.length - 1, ): void { this._timeline.slice(lowerBound, upperBound + 1).forEach(callback); } /** * Iterate over everything in the array * @param callback The callback to invoke with every item */ forEach(callback: (event: GenericEvent) => void): this { this._iterate(callback); return this; } /** * Iterate over everything in the array at or before the given time. * @param time The time to check if items are before * @param callback The callback to invoke with every item */ forEachBefore(time: Seconds, callback: (event: GenericEvent) => void): this { // iterate over the items in reverse so that removing an item doesn't break things const upperBound = this._search(time); if (upperBound !== -1) { this._iterate(callback, 0, upperBound); } return this; } /** * Iterate over everything in the array after the given time. * @param time The time to check if items are before * @param callback The callback to invoke with every item */ forEachAfter(time: Seconds, callback: (event: GenericEvent) => void): this { // iterate over the items in reverse so that removing an item doesn't break things const lowerBound = this._search(time); this._iterate(callback, lowerBound + 1); return this; } /** * Iterate over everything in the array between the startTime and endTime. * The timerange is inclusive of the startTime, but exclusive of the endTime. * range = [startTime, endTime). * @param startTime The time to check if items are before * @param endTime The end of the test interval. * @param callback The callback to invoke with every item */ forEachBetween(startTime: number, endTime: number, callback: (event: GenericEvent) => void): this { let lowerBound = this._search(startTime); let upperBound = this._search(endTime); if (lowerBound !== -1 && upperBound !== -1) { if (this._timeline[lowerBound].time !== startTime) { lowerBound += 1; } // exclusive of the end time if (this._timeline[upperBound].time === endTime) { upperBound -= 1; } this._iterate(callback, lowerBound, upperBound); } else if (lowerBound === -1) { this._iterate(callback, 0, upperBound); } return this; } /** * Iterate over everything in the array at or after the given time. Similar to * forEachAfter, but includes the item(s) at the given time. * @param time The time to check if items are before * @param callback The callback to invoke with every item */ forEachFrom(time: number, callback: (event: GenericEvent) => void): this { // iterate over the items in reverse so that removing an item doesn't break things let lowerBound = this._search(time); // work backwards until the event time is less than time while (lowerBound >= 0 && this._timeline[lowerBound].time >= time) { lowerBound--; } this._iterate(callback, lowerBound + 1); return this; } /** * Iterate over everything in the array at the given time * @param time The time to check if items are before * @param callback The callback to invoke with every item */ forEachAtTime(time: number, callback: (event: GenericEvent) => void): this { // iterate over the items in reverse so that removing an item doesn't break things const upperBound = this._search(time); if (upperBound !== -1 && EQ(this._timeline[upperBound].time, time)) { let lowerBound = upperBound; for (let i = upperBound; i >= 0; i--) { if (EQ(this._timeline[i].time, time)) { lowerBound = i; } else { break; } } this._iterate(event => { callback(event); }, lowerBound, upperBound); } return this; } /** * Clean up. */ dispose(): this { super.dispose(); this._timeline = []; return this; } }