UNPKG

10.6 kBPlain TextView Raw
1import { Volume } from "../component/channel/Volume.js";
2import "../core/context/Destination.js";
3import "../core/clock/Transport.js";
4import { Param } from "../core/context/Param.js";
5import {
6 OutputNode,
7 ToneAudioNode,
8 ToneAudioNodeOptions,
9} from "../core/context/ToneAudioNode.js";
10import { Decibels, Seconds, Time } from "../core/type/Units.js";
11import { defaultArg } from "../core/util/Defaults.js";
12import { noOp, readOnly } from "../core/util/Interface.js";
13import {
14 BasicPlaybackState,
15 StateTimeline,
16 StateTimelineEvent,
17} from "../core/util/StateTimeline.js";
18import { isDefined, isUndef } from "../core/util/TypeCheck.js";
19import { assert, assertContextRunning } from "../core/util/Debug.js";
20import { GT } from "../core/util/Math.js";
21
22type onStopCallback = (source: Source<any>) => void;
23
24export interface SourceOptions extends ToneAudioNodeOptions {
25 volume: Decibels;
26 mute: boolean;
27 onstop: onStopCallback;
28}
29
30/**
31 * Base class for sources.
32 * start/stop of this.context.transport.
33 *
34 * ```
35 * // Multiple state change events can be chained together,
36 * // but must be set in the correct order and with ascending times
37 * // OK
38 * state.start().stop("+0.2");
39 * // OK
40 * state.start().stop("+0.2").start("+0.4").stop("+0.7")
41 * // BAD
42 * state.stop("+0.2").start();
43 * // BAD
44 * state.start("+0.3").stop("+0.2");
45 * ```
46 */
47export abstract class Source<
48 Options extends SourceOptions,
49> extends ToneAudioNode<Options> {
50 /**
51 * The output volume node
52 */
53 private _volume: Volume;
54
55 /**
56 * The output node
57 */
58 output: OutputNode;
59
60 /**
61 * Sources have no inputs
62 */
63 input = undefined;
64
65 /**
66 * The volume of the output in decibels.
67 * @example
68 * const source = new Tone.PWMOscillator().toDestination();
69 * source.volume.value = -6;
70 */
71 volume: Param<"decibels">;
72
73 /**
74 * The callback to invoke when the source is stopped.
75 */
76 onstop: onStopCallback;
77
78 /**
79 * Keep track of the scheduled state.
80 */
81 protected _state: StateTimeline<{
82 duration?: Seconds;
83 offset?: Seconds;
84 /**
85 * Either the buffer is explicitly scheduled to end using the stop method,
86 * or it's implicitly ended when the buffer is over.
87 */
88 implicitEnd?: boolean;
89 }> = new StateTimeline("stopped");
90
91 /**
92 * The synced `start` callback function from the transport
93 */
94 protected _synced = false;
95
96 /**
97 * Keep track of all of the scheduled event ids
98 */
99 private _scheduled: number[] = [];
100
101 /**
102 * Placeholder functions for syncing/unsyncing to transport
103 */
104 private _syncedStart: (time: Seconds, offset: Seconds) => void = noOp;
105 private _syncedStop: (time: Seconds) => void = noOp;
106
107 constructor(options: SourceOptions) {
108 super(options);
109 this._state.memory = 100;
110 this._state.increasing = true;
111
112 this._volume = this.output = new Volume({
113 context: this.context,
114 mute: options.mute,
115 volume: options.volume,
116 });
117 this.volume = this._volume.volume;
118 readOnly(this, "volume");
119 this.onstop = options.onstop;
120 }
121
122 static getDefaults(): SourceOptions {
123 return Object.assign(ToneAudioNode.getDefaults(), {
124 mute: false,
125 onstop: noOp,
126 volume: 0,
127 });
128 }
129
130 /**
131 * Returns the playback state of the source, either "started" or "stopped".
132 * @example
133 * const player = new Tone.Player("https://tonejs.github.io/audio/berklee/ahntone_c3.mp3", () => {
134 * player.start();
135 * console.log(player.state);
136 * }).toDestination();
137 */
138 get state(): BasicPlaybackState {
139 if (this._synced) {
140 if (this.context.transport.state === "started") {
141 return this._state.getValueAtTime(
142 this.context.transport.seconds
143 ) as BasicPlaybackState;
144 } else {
145 return "stopped";
146 }
147 } else {
148 return this._state.getValueAtTime(this.now()) as BasicPlaybackState;
149 }
150 }
151
152 /**
153 * Mute the output.
154 * @example
155 * const osc = new Tone.Oscillator().toDestination().start();
156 * // mute the output
157 * osc.mute = true;
158 */
159 get mute(): boolean {
160 return this._volume.mute;
161 }
162 set mute(mute: boolean) {
163 this._volume.mute = mute;
164 }
165
166 // overwrite these functions
167 protected abstract _start(time: Time, offset?: Time, duration?: Time): void;
168 protected abstract _stop(time: Time): void;
169 protected abstract _restart(
170 time: Seconds,
171 offset?: Time,
172 duration?: Time
173 ): void;
174
175 /**
176 * Ensure that the scheduled time is not before the current time.
177 * Should only be used when scheduled unsynced.
178 */
179 private _clampToCurrentTime(time: Seconds): Seconds {
180 if (this._synced) {
181 return time;
182 } else {
183 return Math.max(time, this.context.currentTime);
184 }
185 }
186
187 /**
188 * Start the source at the specified time. If no time is given,
189 * start the source now.
190 * @param time When the source should be started.
191 * @example
192 * const source = new Tone.Oscillator().toDestination();
193 * source.start("+0.5"); // starts the source 0.5 seconds from now
194 */
195 start(time?: Time, offset?: Time, duration?: Time): this {
196 let computedTime =
197 isUndef(time) && this._synced
198 ? this.context.transport.seconds
199 : this.toSeconds(time);
200 computedTime = this._clampToCurrentTime(computedTime);
201 // if it's started, stop it and restart it
202 if (
203 !this._synced &&
204 this._state.getValueAtTime(computedTime) === "started"
205 ) {
206 // time should be strictly greater than the previous start time
207 assert(
208 GT(
209 computedTime,
210 (this._state.get(computedTime) as StateTimelineEvent).time
211 ),
212 "Start time must be strictly greater than previous start time"
213 );
214 this._state.cancel(computedTime);
215 this._state.setStateAtTime("started", computedTime);
216 this.log("restart", computedTime);
217 this.restart(computedTime, offset, duration);
218 } else {
219 this.log("start", computedTime);
220 this._state.setStateAtTime("started", computedTime);
221 if (this._synced) {
222 // add the offset time to the event
223 const event = this._state.get(computedTime);
224 if (event) {
225 event.offset = this.toSeconds(defaultArg(offset, 0));
226 event.duration = duration
227 ? this.toSeconds(duration)
228 : undefined;
229 }
230 const sched = this.context.transport.schedule((t) => {
231 this._start(t, offset, duration);
232 }, computedTime);
233 this._scheduled.push(sched);
234
235 // if the transport is already started
236 // and the time is greater than where the transport is
237 if (
238 this.context.transport.state === "started" &&
239 this.context.transport.getSecondsAtTime(this.immediate()) >
240 computedTime
241 ) {
242 this._syncedStart(
243 this.now(),
244 this.context.transport.seconds
245 );
246 }
247 } else {
248 assertContextRunning(this.context);
249 this._start(computedTime, offset, duration);
250 }
251 }
252 return this;
253 }
254
255 /**
256 * Stop the source at the specified time. If no time is given,
257 * stop the source now.
258 * @param time When the source should be stopped.
259 * @example
260 * const source = new Tone.Oscillator().toDestination();
261 * source.start();
262 * source.stop("+0.5"); // stops the source 0.5 seconds from now
263 */
264 stop(time?: Time): this {
265 let computedTime =
266 isUndef(time) && this._synced
267 ? this.context.transport.seconds
268 : this.toSeconds(time);
269 computedTime = this._clampToCurrentTime(computedTime);
270 if (
271 this._state.getValueAtTime(computedTime) === "started" ||
272 isDefined(this._state.getNextState("started", computedTime))
273 ) {
274 this.log("stop", computedTime);
275 if (!this._synced) {
276 this._stop(computedTime);
277 } else {
278 const sched = this.context.transport.schedule(
279 this._stop.bind(this),
280 computedTime
281 );
282 this._scheduled.push(sched);
283 }
284 this._state.cancel(computedTime);
285 this._state.setStateAtTime("stopped", computedTime);
286 }
287 return this;
288 }
289
290 /**
291 * Restart the source.
292 */
293 restart(time?: Time, offset?: Time, duration?: Time): this {
294 time = this.toSeconds(time);
295 if (this._state.getValueAtTime(time) === "started") {
296 this._state.cancel(time);
297 this._restart(time, offset, duration);
298 }
299 return this;
300 }
301
302 /**
303 * Sync the source to the Transport so that all subsequent
304 * calls to `start` and `stop` are synced to the TransportTime
305 * instead of the AudioContext time.
306 *
307 * @example
308 * const osc = new Tone.Oscillator().toDestination();
309 * // sync the source so that it plays between 0 and 0.3 on the Transport's timeline
310 * osc.sync().start(0).stop(0.3);
311 * // start the transport.
312 * Tone.Transport.start();
313 * // set it to loop once a second
314 * Tone.Transport.loop = true;
315 * Tone.Transport.loopEnd = 1;
316 */
317 sync(): this {
318 if (!this._synced) {
319 this._synced = true;
320 this._syncedStart = (time, offset) => {
321 if (GT(offset, 0)) {
322 // get the playback state at that time
323 const stateEvent = this._state.get(offset);
324 // listen for start events which may occur in the middle of the sync'ed time
325 if (
326 stateEvent &&
327 stateEvent.state === "started" &&
328 stateEvent.time !== offset
329 ) {
330 // get the offset
331 const startOffset =
332 offset - this.toSeconds(stateEvent.time);
333 let duration: number | undefined;
334 if (stateEvent.duration) {
335 duration =
336 this.toSeconds(stateEvent.duration) -
337 startOffset;
338 }
339 this._start(
340 time,
341 this.toSeconds(stateEvent.offset) + startOffset,
342 duration
343 );
344 }
345 }
346 };
347 this._syncedStop = (time) => {
348 const seconds = this.context.transport.getSecondsAtTime(
349 Math.max(time - this.sampleTime, 0)
350 );
351 if (this._state.getValueAtTime(seconds) === "started") {
352 this._stop(time);
353 }
354 };
355 this.context.transport.on("start", this._syncedStart);
356 this.context.transport.on("loopStart", this._syncedStart);
357 this.context.transport.on("stop", this._syncedStop);
358 this.context.transport.on("pause", this._syncedStop);
359 this.context.transport.on("loopEnd", this._syncedStop);
360 }
361 return this;
362 }
363
364 /**
365 * Unsync the source to the Transport.
366 * @see {@link sync}
367 */
368 unsync(): this {
369 if (this._synced) {
370 this.context.transport.off("stop", this._syncedStop);
371 this.context.transport.off("pause", this._syncedStop);
372 this.context.transport.off("loopEnd", this._syncedStop);
373 this.context.transport.off("start", this._syncedStart);
374 this.context.transport.off("loopStart", this._syncedStart);
375 }
376 this._synced = false;
377 // clear all of the scheduled ids
378 this._scheduled.forEach((id) => this.context.transport.clear(id));
379 this._scheduled = [];
380 this._state.cancel(0);
381 // stop it also
382 this._stop(0);
383 return this;
384 }
385
386 /**
387 * Clean up.
388 */
389 dispose(): this {
390 super.dispose();
391 this.onstop = noOp;
392 this.unsync();
393 this._volume.dispose();
394 this._state.dispose();
395 return this;
396 }
397}