Tone.js/Tone/source/Source.ts

398 lines
10 KiB
TypeScript
Raw Permalink Normal View History

import { Volume } from "../component/channel/Volume.js";
import "../core/context/Destination.js";
import "../core/clock/Transport.js";
import { Param } from "../core/context/Param.js";
import {
OutputNode,
ToneAudioNode,
ToneAudioNodeOptions,
} from "../core/context/ToneAudioNode.js";
import { Decibels, Seconds, Time } from "../core/type/Units.js";
import { defaultArg } from "../core/util/Defaults.js";
import { noOp, readOnly } from "../core/util/Interface.js";
import {
BasicPlaybackState,
StateTimeline,
StateTimelineEvent,
} from "../core/util/StateTimeline.js";
import { isDefined, isUndef } from "../core/util/TypeCheck.js";
import { assert, assertContextRunning } from "../core/util/Debug.js";
import { GT } from "../core/util/Math.js";
2019-06-19 14:18:06 +00:00
2019-08-10 15:51:35 +00:00
type onStopCallback = (source: Source<any>) => void;
2019-06-19 14:18:06 +00:00
export interface SourceOptions extends ToneAudioNodeOptions {
volume: Decibels;
mute: boolean;
2019-08-10 15:51:35 +00:00
onstop: onStopCallback;
2019-06-19 14:18:06 +00:00
}
/**
* Base class for sources.
2019-08-27 15:47:52 +00:00
* start/stop of this.context.transport.
*
* ```
2019-10-30 03:13:28 +00:00
* // Multiple state change events can be chained together,
* // but must be set in the correct order and with ascending times
2019-06-19 14:18:06 +00:00
* // OK
* state.start().stop("+0.2");
2019-10-30 03:13:28 +00:00
* // OK
2019-06-19 14:18:06 +00:00
* state.start().stop("+0.2").start("+0.4").stop("+0.7")
* // BAD
* state.stop("+0.2").start();
2019-10-30 03:13:28 +00:00
* // BAD
2019-06-19 14:18:06 +00:00
* state.start("+0.3").stop("+0.2");
2019-10-25 20:54:33 +00:00
* ```
2019-06-19 14:18:06 +00:00
*/
export abstract class Source<
Options extends SourceOptions,
> extends ToneAudioNode<Options> {
2019-06-19 14:18:06 +00:00
/**
2019-09-14 20:39:18 +00:00
* The output volume node
2019-06-19 14:18:06 +00:00
*/
private _volume: Volume;
2019-06-19 14:18:06 +00:00
/**
2022-06-29 16:06:16 +00:00
* The output node
2019-06-19 14:18:06 +00:00
*/
output: OutputNode;
2019-06-19 14:18:06 +00:00
/**
* Sources have no inputs
2019-06-19 14:18:06 +00:00
*/
input = undefined;
/**
* The volume of the output in decibels.
* @example
* const source = new Tone.PWMOscillator().toDestination();
2019-06-19 14:18:06 +00:00
* source.volume.value = -6;
*/
volume: Param<"decibels">;
2019-06-19 14:18:06 +00:00
2019-08-10 15:51:35 +00:00
/**
* The callback to invoke when the source is stopped.
*/
onstop: onStopCallback;
2019-06-19 14:18:06 +00:00
/**
2019-09-14 20:39:18 +00:00
* Keep track of the scheduled state.
2019-06-19 14:18:06 +00:00
*/
protected _state: StateTimeline<{
duration?: Seconds;
offset?: Seconds;
/**
* Either the buffer is explicitly scheduled to end using the stop method,
* or it's implicitly ended when the buffer is over.
*/
implicitEnd?: boolean;
}> = new StateTimeline("stopped");
2019-06-19 14:18:06 +00:00
/**
* The synced `start` callback function from the transport
2019-06-19 14:18:06 +00:00
*/
2019-11-17 18:09:19 +00:00
protected _synced = false;
2019-06-19 14:18:06 +00:00
/**
2019-09-14 20:39:18 +00:00
* Keep track of all of the scheduled event ids
2019-06-19 14:18:06 +00:00
*/
private _scheduled: number[] = [];
/**
* Placeholder functions for syncing/unsyncing to transport
*/
private _syncedStart: (time: Seconds, offset: Seconds) => void = noOp;
private _syncedStop: (time: Seconds) => void = noOp;
2019-06-19 14:18:06 +00:00
constructor(options: SourceOptions) {
super(options);
2019-06-19 14:18:06 +00:00
this._state.memory = 100;
this._state.increasing = true;
2019-06-19 14:18:06 +00:00
this._volume = this.output = new Volume({
context: this.context,
mute: options.mute,
volume: options.volume,
});
this.volume = this._volume.volume;
readOnly(this, "volume");
2019-08-10 15:51:35 +00:00
this.onstop = options.onstop;
2019-06-19 14:18:06 +00:00
}
static getDefaults(): SourceOptions {
return Object.assign(ToneAudioNode.getDefaults(), {
mute: false,
2019-08-10 15:51:35 +00:00
onstop: noOp,
2019-06-19 14:18:06 +00:00
volume: 0,
});
}
/**
2019-09-14 20:39:18 +00:00
* Returns the playback state of the source, either "started" or "stopped".
2019-10-25 20:54:33 +00:00
* @example
2020-07-19 20:22:47 +00:00
* const player = new Tone.Player("https://tonejs.github.io/audio/berklee/ahntone_c3.mp3", () => {
2019-10-25 20:54:33 +00:00
* player.start();
* console.log(player.state);
* }).toDestination();
2019-06-19 14:18:06 +00:00
*/
get state(): BasicPlaybackState {
if (this._synced) {
2019-06-19 14:18:06 +00:00
if (this.context.transport.state === "started") {
return this._state.getValueAtTime(
this.context.transport.seconds
) as BasicPlaybackState;
2019-06-19 14:18:06 +00:00
} else {
return "stopped";
}
} else {
return this._state.getValueAtTime(this.now()) as BasicPlaybackState;
2019-06-19 14:18:06 +00:00
}
}
/**
* Mute the output.
* @example
* const osc = new Tone.Oscillator().toDestination().start();
2019-10-25 20:54:33 +00:00
* // mute the output
* osc.mute = true;
2019-06-19 14:18:06 +00:00
*/
get mute(): boolean {
return this._volume.mute;
}
set mute(mute: boolean) {
this._volume.mute = mute;
}
// 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;
2019-06-19 14:18:06 +00:00
/**
* Ensure that the scheduled time is not before the current time.
* Should only be used when scheduled unsynced.
*/
private _clampToCurrentTime(time: Seconds): Seconds {
if (this._synced) {
return time;
} else {
return Math.max(time, this.context.currentTime);
}
}
2019-06-19 14:18:06 +00:00
/**
2019-09-14 20:39:18 +00:00
* Start the source at the specified time. If no time is given,
* start the source now.
* @param time When the source should be started.
* @example
* const source = new Tone.Oscillator().toDestination();
2019-10-25 20:54:33 +00:00
* source.start("+0.5"); // starts the source 0.5 seconds from now
2019-06-19 14:18:06 +00:00
*/
start(time?: Time, offset?: Time, duration?: Time): this {
let computedTime =
isUndef(time) && this._synced
? this.context.transport.seconds
: this.toSeconds(time);
computedTime = this._clampToCurrentTime(computedTime);
2019-06-19 14:18:06 +00:00
// if it's started, stop it and restart it
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"
);
this._state.cancel(computedTime);
this._state.setStateAtTime("started", computedTime);
2019-09-20 04:16:45 +00:00
this.log("restart", computedTime);
this.restart(computedTime, offset, duration);
2019-06-19 14:18:06 +00:00
} else {
2019-09-20 04:16:45 +00:00
this.log("start", computedTime);
this._state.setStateAtTime("started", computedTime);
if (this._synced) {
2019-06-19 14:18:06 +00:00
// add the offset time to the event
const event = this._state.get(computedTime);
2019-06-19 14:18:06 +00:00
if (event) {
event.offset = this.toSeconds(defaultArg(offset, 0));
event.duration = duration
? this.toSeconds(duration)
: undefined;
2019-06-19 14:18:06 +00:00
}
const sched = this.context.transport.schedule((t) => {
2019-06-19 14:18:06 +00:00
this._start(t, offset, duration);
}, computedTime);
2019-06-19 14:18:06 +00:00
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
);
2019-06-19 14:18:06 +00:00
}
} else {
2020-05-01 21:58:23 +00:00
assertContextRunning(this.context);
this._start(computedTime, offset, duration);
2019-06-19 14:18:06 +00:00
}
}
return this;
}
/**
2019-09-14 20:39:18 +00:00
* Stop the source at the specified time. If no time is given,
* stop the source now.
* @param time When the source should be stopped.
* @example
* const source = new Tone.Oscillator().toDestination();
2019-10-25 20:54:33 +00:00
* source.start();
* source.stop("+0.5"); // stops the source 0.5 seconds from now
2019-06-19 14:18:06 +00:00
*/
2019-06-24 17:41:38 +00:00
stop(time?: Time): this {
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))
) {
this.log("stop", computedTime);
if (!this._synced) {
this._stop(computedTime);
} else {
const sched = this.context.transport.schedule(
this._stop.bind(this),
computedTime
);
this._scheduled.push(sched);
}
this._state.cancel(computedTime);
this._state.setStateAtTime("stopped", computedTime);
}
return this;
}
/**
* Restart the source.
*/
restart(time?: Time, offset?: Time, duration?: Time): this {
time = this.toSeconds(time);
if (this._state.getValueAtTime(time) === "started") {
this._state.cancel(time);
this._restart(time, offset, duration);
2019-06-19 14:18:06 +00:00
}
return this;
}
/**
2019-09-14 20:39:18 +00:00
* Sync the source to the Transport so that all subsequent
* calls to `start` and `stop` are synced to the TransportTime
* instead of the AudioContext time.
2019-06-19 14:18:06 +00:00
*
* @example
* const osc = new Tone.Oscillator().toDestination();
2019-10-25 20:54:33 +00:00
* // sync the source so that it plays between 0 and 0.3 on the Transport's timeline
* osc.sync().start(0).stop(0.3);
* // start the transport.
* Tone.Transport.start();
2019-11-08 19:40:39 +00:00
* // set it to loop once a second
* Tone.Transport.loop = true;
* Tone.Transport.loopEnd = 1;
2019-06-19 14:18:06 +00:00
*/
sync(): this {
if (!this._synced) {
2019-06-19 14:18:06 +00:00
this._synced = true;
this._syncedStart = (time, offset) => {
if (GT(offset, 0)) {
2019-06-19 14:18:06 +00:00
// 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
) {
2019-06-19 14:18:06 +00:00
// get the offset
const startOffset =
offset - this.toSeconds(stateEvent.time);
let duration: number | undefined;
2019-06-19 14:18:06 +00:00
if (stateEvent.duration) {
duration =
this.toSeconds(stateEvent.duration) -
startOffset;
2019-06-19 14:18:06 +00:00
}
this._start(
time,
this.toSeconds(stateEvent.offset) + startOffset,
duration
);
2019-06-19 14:18:06 +00:00
}
}
};
this._syncedStop = (time) => {
const seconds = this.context.transport.getSecondsAtTime(
Math.max(time - this.sampleTime, 0)
);
if (this._state.getValueAtTime(seconds) === "started") {
this._stop(time);
2019-06-19 14:18:06 +00:00
}
};
this.context.transport.on("start", this._syncedStart);
this.context.transport.on("loopStart", this._syncedStart);
this.context.transport.on("stop", this._syncedStop);
this.context.transport.on("pause", this._syncedStop);
this.context.transport.on("loopEnd", this._syncedStop);
}
return this;
}
/**
* Unsync the source to the Transport.
2024-04-29 16:59:49 +00:00
* @see {@link sync}
2019-06-19 14:18:06 +00:00
*/
unsync(): this {
2019-06-24 17:41:38 +00:00
if (this._synced) {
this.context.transport.off("stop", this._syncedStop);
this.context.transport.off("pause", this._syncedStop);
this.context.transport.off("loopEnd", this._syncedStop);
this.context.transport.off("start", this._syncedStart);
this.context.transport.off("loopStart", this._syncedStart);
}
this._synced = false;
// clear all of the scheduled ids
this._scheduled.forEach((id) => this.context.transport.clear(id));
this._scheduled = [];
this._state.cancel(0);
// stop it also
this._stop(0);
2019-06-19 14:18:06 +00:00
return this;
}
/**
* Clean up.
*/
dispose(): this {
super.dispose();
2019-08-10 15:51:35 +00:00
this.onstop = noOp;
2019-06-19 14:18:06 +00:00
this.unsync();
this._volume.dispose();
this._state.dispose();
return this;
}
}