mirror of
https://github.com/Tonejs/Tone.js
synced 2024-12-26 19:43:12 +00:00
aaf880c925
* WIP moving tests to web-test-runner * updating thresholds * Adding file extensions * Testing integrations * linting * fixing dep * moving back to root dir * prettier all of the files * updating eslint rules to use with prettier * remove import package * moving tsignore around * removing unneeded ignores * all tests run on puppeteer, no need for testing guards * linting * import type syntax * cleaning up * Update package.json
397 lines
10 KiB
TypeScript
397 lines
10 KiB
TypeScript
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";
|
|
|
|
type onStopCallback = (source: Source<any>) => void;
|
|
|
|
export interface SourceOptions extends ToneAudioNodeOptions {
|
|
volume: Decibels;
|
|
mute: boolean;
|
|
onstop: onStopCallback;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* // OK
|
|
* state.start().stop("+0.2");
|
|
* // OK
|
|
* state.start().stop("+0.2").start("+0.4").stop("+0.7")
|
|
* // BAD
|
|
* state.stop("+0.2").start();
|
|
* // BAD
|
|
* state.start("+0.3").stop("+0.2");
|
|
* ```
|
|
*/
|
|
export abstract class Source<
|
|
Options extends SourceOptions,
|
|
> extends ToneAudioNode<Options> {
|
|
/**
|
|
* The output volume node
|
|
*/
|
|
private _volume: Volume;
|
|
|
|
/**
|
|
* The output node
|
|
*/
|
|
output: OutputNode;
|
|
|
|
/**
|
|
* Sources have no inputs
|
|
*/
|
|
input = undefined;
|
|
|
|
/**
|
|
* The volume of the output in decibels.
|
|
* @example
|
|
* const source = new Tone.PWMOscillator().toDestination();
|
|
* source.volume.value = -6;
|
|
*/
|
|
volume: Param<"decibels">;
|
|
|
|
/**
|
|
* The callback to invoke when the source is stopped.
|
|
*/
|
|
onstop: onStopCallback;
|
|
|
|
/**
|
|
* Keep track of the scheduled state.
|
|
*/
|
|
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");
|
|
|
|
/**
|
|
* The synced `start` callback function from the transport
|
|
*/
|
|
protected _synced = false;
|
|
|
|
/**
|
|
* Keep track of all of the scheduled event ids
|
|
*/
|
|
private _scheduled: number[] = [];
|
|
|
|
/**
|
|
* Placeholder functions for syncing/unsyncing to transport
|
|
*/
|
|
private _syncedStart: (time: Seconds, offset: Seconds) => void = noOp;
|
|
private _syncedStop: (time: Seconds) => void = noOp;
|
|
|
|
constructor(options: SourceOptions) {
|
|
super(options);
|
|
this._state.memory = 100;
|
|
this._state.increasing = true;
|
|
|
|
this._volume = this.output = new Volume({
|
|
context: this.context,
|
|
mute: options.mute,
|
|
volume: options.volume,
|
|
});
|
|
this.volume = this._volume.volume;
|
|
readOnly(this, "volume");
|
|
this.onstop = options.onstop;
|
|
}
|
|
|
|
static getDefaults(): SourceOptions {
|
|
return Object.assign(ToneAudioNode.getDefaults(), {
|
|
mute: false,
|
|
onstop: noOp,
|
|
volume: 0,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the playback state of the source, either "started" or "stopped".
|
|
* @example
|
|
* const player = new Tone.Player("https://tonejs.github.io/audio/berklee/ahntone_c3.mp3", () => {
|
|
* player.start();
|
|
* console.log(player.state);
|
|
* }).toDestination();
|
|
*/
|
|
get state(): BasicPlaybackState {
|
|
if (this._synced) {
|
|
if (this.context.transport.state === "started") {
|
|
return this._state.getValueAtTime(
|
|
this.context.transport.seconds
|
|
) as BasicPlaybackState;
|
|
} else {
|
|
return "stopped";
|
|
}
|
|
} else {
|
|
return this._state.getValueAtTime(this.now()) as BasicPlaybackState;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mute the output.
|
|
* @example
|
|
* const osc = new Tone.Oscillator().toDestination().start();
|
|
* // mute the output
|
|
* osc.mute = true;
|
|
*/
|
|
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;
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
* 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);
|
|
computedTime = this._clampToCurrentTime(computedTime);
|
|
// 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);
|
|
this.log("restart", computedTime);
|
|
this.restart(computedTime, offset, duration);
|
|
} else {
|
|
this.log("start", computedTime);
|
|
this._state.setStateAtTime("started", computedTime);
|
|
if (this._synced) {
|
|
// add the offset time to the event
|
|
const event = this._state.get(computedTime);
|
|
if (event) {
|
|
event.offset = this.toSeconds(defaultArg(offset, 0));
|
|
event.duration = duration
|
|
? this.toSeconds(duration)
|
|
: undefined;
|
|
}
|
|
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
|
|
);
|
|
}
|
|
} else {
|
|
assertContextRunning(this.context);
|
|
this._start(computedTime, offset, duration);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
* source.start();
|
|
* 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);
|
|
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);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @example
|
|
* const osc = new Tone.Oscillator().toDestination();
|
|
* // 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();
|
|
* // set it to loop once a second
|
|
* Tone.Transport.loop = true;
|
|
* Tone.Transport.loopEnd = 1;
|
|
*/
|
|
sync(): this {
|
|
if (!this._synced) {
|
|
this._synced = true;
|
|
this._syncedStart = (time, offset) => {
|
|
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
|
|
) {
|
|
// get the offset
|
|
const startOffset =
|
|
offset - this.toSeconds(stateEvent.time);
|
|
let duration: number | undefined;
|
|
if (stateEvent.duration) {
|
|
duration =
|
|
this.toSeconds(stateEvent.duration) -
|
|
startOffset;
|
|
}
|
|
this._start(
|
|
time,
|
|
this.toSeconds(stateEvent.offset) + startOffset,
|
|
duration
|
|
);
|
|
}
|
|
}
|
|
};
|
|
this._syncedStop = (time) => {
|
|
const seconds = this.context.transport.getSecondsAtTime(
|
|
Math.max(time - this.sampleTime, 0)
|
|
);
|
|
if (this._state.getValueAtTime(seconds) === "started") {
|
|
this._stop(time);
|
|
}
|
|
};
|
|
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.
|
|
* @see {@link sync}
|
|
*/
|
|
unsync(): this {
|
|
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);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Clean up.
|
|
*/
|
|
dispose(): this {
|
|
super.dispose();
|
|
this.onstop = noOp;
|
|
this.unsync();
|
|
this._volume.dispose();
|
|
this._state.dispose();
|
|
return this;
|
|
}
|
|
}
|