UNPKG

13.4 kBPlain TextView Raw
1import { MidiClass } from "../core/type/Midi.js";
2import {
3 Frequency,
4 MidiNote,
5 NormalRange,
6 Seconds,
7 Time,
8} from "../core/type/Units.js";
9import {
10 deepMerge,
11 omitFromObject,
12 optionsFromArguments,
13} from "../core/util/Defaults.js";
14import { RecursivePartial } from "../core/util/Interface.js";
15import { isArray, isNumber } from "../core/util/TypeCheck.js";
16import { Instrument, InstrumentOptions } from "./Instrument.js";
17import { MembraneSynth, MembraneSynthOptions } from "./MembraneSynth.js";
18import { FMSynth, FMSynthOptions } from "./FMSynth.js";
19import { AMSynth, AMSynthOptions } from "./AMSynth.js";
20import { MonoSynth, MonoSynthOptions } from "./MonoSynth.js";
21import { MetalSynth, MetalSynthOptions } from "./MetalSynth.js";
22import { Monophonic } from "./Monophonic.js";
23import { Synth, SynthOptions } from "./Synth.js";
24import { assert, warn } from "../core/util/Debug.js";
25
26type VoiceConstructor<V> = {
27 getDefaults: () => VoiceOptions<V>;
28} & (new (...args: any[]) => V);
29
30type OmitMonophonicOptions<T> = Omit<T, "context" | "onsilence">;
31
32type VoiceOptions<T> = T extends MembraneSynth
33 ? MembraneSynthOptions
34 : T extends MetalSynth
35 ? MetalSynthOptions
36 : T extends FMSynth
37 ? FMSynthOptions
38 : T extends MonoSynth
39 ? MonoSynthOptions
40 : T extends AMSynth
41 ? AMSynthOptions
42 : T extends Synth
43 ? SynthOptions
44 : T extends Monophonic<infer U>
45 ? U
46 : never;
47
48/**
49 * The settable synth options. excludes monophonic options.
50 */
51type PartialVoiceOptions<T> = RecursivePartial<
52 OmitMonophonicOptions<VoiceOptions<T>>
53>;
54
55export interface PolySynthOptions<Voice> extends InstrumentOptions {
56 maxPolyphony: number;
57 voice: VoiceConstructor<Voice>;
58 options: PartialVoiceOptions<Voice>;
59}
60
61/**
62 * PolySynth handles voice creation and allocation for any
63 * instruments passed in as the second parameter. PolySynth is
64 * not a synthesizer by itself, it merely manages voices of
65 * one of the other types of synths, allowing any of the
66 * monophonic synthesizers to be polyphonic.
67 *
68 * @example
69 * const synth = new Tone.PolySynth().toDestination();
70 * // set the attributes across all the voices using 'set'
71 * synth.set({ detune: -1200 });
72 * // play a chord
73 * synth.triggerAttackRelease(["C4", "E4", "A4"], 1);
74 * @category Instrument
75 */
76export class PolySynth<
77 Voice extends Monophonic<any> = Synth,
78> extends Instrument<VoiceOptions<Voice>> {
79 readonly name: string = "PolySynth";
80
81 /**
82 * The voices which are not currently in use
83 */
84 private _availableVoices: Voice[] = [];
85
86 /**
87 * The currently active voices
88 */
89 private _activeVoices: Array<{
90 midi: MidiNote;
91 voice: Voice;
92 released: boolean;
93 }> = [];
94
95 /**
96 * All of the allocated voices for this synth.
97 */
98 private _voices: Voice[] = [];
99
100 /**
101 * The options that are set on the synth.
102 */
103 private options: VoiceOptions<Voice>;
104
105 /**
106 * The polyphony limit.
107 */
108 maxPolyphony: number;
109
110 /**
111 * The voice constructor
112 */
113 private readonly voice: VoiceConstructor<Voice>;
114
115 /**
116 * A voice used for holding the get/set values
117 */
118 private _dummyVoice: Voice;
119
120 /**
121 * The GC timeout. Held so that it could be cancelled when the node is disposed.
122 */
123 private _gcTimeout = -1;
124
125 /**
126 * A moving average of the number of active voices
127 */
128 private _averageActiveVoices = 0;
129
130 /**
131 * @param voice The constructor of the voices
132 * @param options The options object to set the synth voice
133 */
134 constructor(
135 voice?: VoiceConstructor<Voice>,
136 options?: PartialVoiceOptions<Voice>
137 );
138 constructor(options?: Partial<PolySynthOptions<Voice>>);
139 constructor() {
140 const options = optionsFromArguments(
141 PolySynth.getDefaults(),
142 arguments,
143 ["voice", "options"]
144 );
145 super(options);
146
147 // check against the old API (pre 14.3.0)
148 assert(
149 !isNumber(options.voice),
150 "DEPRECATED: The polyphony count is no longer the first argument."
151 );
152
153 const defaults = options.voice.getDefaults();
154 this.options = Object.assign(
155 defaults,
156 options.options
157 ) as VoiceOptions<Voice>;
158 this.voice = options.voice as unknown as VoiceConstructor<Voice>;
159 this.maxPolyphony = options.maxPolyphony;
160
161 // create the first voice
162 this._dummyVoice = this._getNextAvailableVoice() as Voice;
163 // remove it from the voices list
164 const index = this._voices.indexOf(this._dummyVoice);
165 this._voices.splice(index, 1);
166 // kick off the GC interval
167 this._gcTimeout = this.context.setInterval(
168 this._collectGarbage.bind(this),
169 1
170 );
171 }
172
173 static getDefaults(): PolySynthOptions<Synth> {
174 return Object.assign(Instrument.getDefaults(), {
175 maxPolyphony: 32,
176 options: {},
177 voice: Synth,
178 });
179 }
180
181 /**
182 * The number of active voices.
183 */
184 get activeVoices(): number {
185 return this._activeVoices.length;
186 }
187
188 /**
189 * Invoked when the source is done making sound, so that it can be
190 * readded to the pool of available voices
191 */
192 private _makeVoiceAvailable(voice: Voice): void {
193 this._availableVoices.push(voice);
194 // remove the midi note from 'active voices'
195 const activeVoiceIndex = this._activeVoices.findIndex(
196 (e) => e.voice === voice
197 );
198 this._activeVoices.splice(activeVoiceIndex, 1);
199 }
200
201 /**
202 * Get an available voice from the pool of available voices.
203 * If one is not available and the maxPolyphony limit is reached,
204 * steal a voice, otherwise return null.
205 */
206 private _getNextAvailableVoice(): Voice | undefined {
207 // if there are available voices, return the first one
208 if (this._availableVoices.length) {
209 return this._availableVoices.shift();
210 } else if (this._voices.length < this.maxPolyphony) {
211 // otherwise if there is still more maxPolyphony, make a new voice
212 const voice = new this.voice(
213 Object.assign(this.options, {
214 context: this.context,
215 onsilence: this._makeVoiceAvailable.bind(this),
216 })
217 );
218 assert(
219 voice instanceof Monophonic,
220 "Voice must extend Monophonic class"
221 );
222 voice.connect(this.output);
223 this._voices.push(voice);
224 return voice;
225 } else {
226 warn("Max polyphony exceeded. Note dropped.");
227 }
228 }
229
230 /**
231 * Occasionally check if there are any allocated voices which can be cleaned up.
232 */
233 private _collectGarbage(): void {
234 this._averageActiveVoices = Math.max(
235 this._averageActiveVoices * 0.95,
236 this.activeVoices
237 );
238 if (
239 this._availableVoices.length &&
240 this._voices.length > Math.ceil(this._averageActiveVoices + 1)
241 ) {
242 // take off an available note
243 const firstAvail = this._availableVoices.shift() as Voice;
244 const index = this._voices.indexOf(firstAvail);
245 this._voices.splice(index, 1);
246 if (!this.context.isOffline) {
247 firstAvail.dispose();
248 }
249 }
250 }
251
252 /**
253 * Internal method which triggers the attack
254 */
255 private _triggerAttack(
256 notes: Frequency[],
257 time: Seconds,
258 velocity?: NormalRange
259 ): void {
260 notes.forEach((note) => {
261 const midiNote = new MidiClass(this.context, note).toMidi();
262 const voice = this._getNextAvailableVoice();
263 if (voice) {
264 voice.triggerAttack(note, time, velocity);
265 this._activeVoices.push({
266 midi: midiNote,
267 voice,
268 released: false,
269 });
270 this.log("triggerAttack", note, time);
271 }
272 });
273 }
274
275 /**
276 * Internal method which triggers the release
277 */
278 private _triggerRelease(notes: Frequency[], time: Seconds): void {
279 notes.forEach((note) => {
280 const midiNote = new MidiClass(this.context, note).toMidi();
281 const event = this._activeVoices.find(
282 ({ midi, released }) => midi === midiNote && !released
283 );
284 if (event) {
285 // trigger release on that note
286 event.voice.triggerRelease(time);
287 // mark it as released
288 event.released = true;
289 this.log("triggerRelease", note, time);
290 }
291 });
292 }
293
294 /**
295 * Schedule the attack/release events. If the time is in the future, then it should set a timeout
296 * to wait for just-in-time scheduling
297 */
298 private _scheduleEvent(
299 type: "attack" | "release",
300 notes: Frequency[],
301 time: Seconds,
302 velocity?: NormalRange
303 ): void {
304 assert(!this.disposed, "Synth was already disposed");
305 // if the notes are greater than this amount of time in the future, they should be scheduled with setTimeout
306 if (time <= this.now()) {
307 // do it immediately
308 if (type === "attack") {
309 this._triggerAttack(notes, time, velocity);
310 } else {
311 this._triggerRelease(notes, time);
312 }
313 } else {
314 // schedule it to start in the future
315 this.context.setTimeout(() => {
316 if (!this.disposed) {
317 this._scheduleEvent(type, notes, time, velocity);
318 }
319 }, time - this.now());
320 }
321 }
322
323 /**
324 * Trigger the attack portion of the note
325 * @param notes The notes to play. Accepts a single Frequency or an array of frequencies.
326 * @param time The start time of the note.
327 * @param velocity The velocity of the note.
328 * @example
329 * const synth = new Tone.PolySynth(Tone.FMSynth).toDestination();
330 * // trigger a chord immediately with a velocity of 0.2
331 * synth.triggerAttack(["Ab3", "C4", "F5"], Tone.now(), 0.2);
332 */
333 triggerAttack(
334 notes: Frequency | Frequency[],
335 time?: Time,
336 velocity?: NormalRange
337 ): this {
338 if (!Array.isArray(notes)) {
339 notes = [notes];
340 }
341 const computedTime = this.toSeconds(time);
342 this._scheduleEvent("attack", notes, computedTime, velocity);
343 return this;
344 }
345
346 /**
347 * Trigger the release of the note. Unlike monophonic instruments,
348 * a note (or array of notes) needs to be passed in as the first argument.
349 * @param notes The notes to play. Accepts a single Frequency or an array of frequencies.
350 * @param time When the release will be triggered.
351 * @example
352 * const poly = new Tone.PolySynth(Tone.AMSynth).toDestination();
353 * poly.triggerAttack(["Ab3", "C4", "F5"]);
354 * // trigger the release of the given notes.
355 * poly.triggerRelease(["Ab3", "C4"], "+1");
356 * poly.triggerRelease("F5", "+3");
357 */
358 triggerRelease(notes: Frequency | Frequency[], time?: Time): this {
359 if (!Array.isArray(notes)) {
360 notes = [notes];
361 }
362 const computedTime = this.toSeconds(time);
363 this._scheduleEvent("release", notes, computedTime);
364 return this;
365 }
366
367 /**
368 * Trigger the attack and release after the specified duration
369 * @param notes The notes to play. Accepts a single Frequency or an array of frequencies.
370 * @param duration the duration of the note
371 * @param time if no time is given, defaults to now
372 * @param velocity the velocity of the attack (0-1)
373 * @example
374 * const poly = new Tone.PolySynth(Tone.AMSynth).toDestination();
375 * // can pass in an array of durations as well
376 * poly.triggerAttackRelease(["Eb3", "G4", "Bb4", "D5"], [4, 3, 2, 1]);
377 */
378 triggerAttackRelease(
379 notes: Frequency | Frequency[],
380 duration: Time | Time[],
381 time?: Time,
382 velocity?: NormalRange
383 ): this {
384 const computedTime = this.toSeconds(time);
385 this.triggerAttack(notes, computedTime, velocity);
386 if (isArray(duration)) {
387 assert(
388 isArray(notes),
389 "If the duration is an array, the notes must also be an array"
390 );
391 notes = notes as Frequency[];
392 for (let i = 0; i < notes.length; i++) {
393 const d = duration[Math.min(i, duration.length - 1)];
394 const durationSeconds = this.toSeconds(d);
395 assert(
396 durationSeconds > 0,
397 "The duration must be greater than 0"
398 );
399 this.triggerRelease(notes[i], computedTime + durationSeconds);
400 }
401 } else {
402 const durationSeconds = this.toSeconds(duration);
403 assert(durationSeconds > 0, "The duration must be greater than 0");
404 this.triggerRelease(notes, computedTime + durationSeconds);
405 }
406 return this;
407 }
408
409 sync(): this {
410 if (this._syncState()) {
411 this._syncMethod("triggerAttack", 1);
412 this._syncMethod("triggerRelease", 1);
413
414 // make sure that the sound doesn't play after its been stopped
415 this.context.transport.on("stop", this._syncedRelease);
416 this.context.transport.on("pause", this._syncedRelease);
417 this.context.transport.on("loopEnd", this._syncedRelease);
418 }
419 return this;
420 }
421
422 /**
423 * The release which is scheduled to the timeline.
424 */
425 protected _syncedRelease = (time: number) => this.releaseAll(time);
426
427 /**
428 * Set a member/attribute of the voices
429 * @example
430 * const poly = new Tone.PolySynth().toDestination();
431 * // set all of the voices using an options object for the synth type
432 * poly.set({
433 * envelope: {
434 * attack: 0.25
435 * }
436 * });
437 * poly.triggerAttackRelease("Bb3", 0.2);
438 */
439 set(options: RecursivePartial<VoiceOptions<Voice>>): this {
440 // remove options which are controlled by the PolySynth
441 const sanitizedOptions = omitFromObject(options, [
442 "onsilence",
443 "context",
444 ]);
445 // store all of the options
446 this.options = deepMerge(this.options, sanitizedOptions);
447 this._voices.forEach((voice) => voice.set(sanitizedOptions));
448 this._dummyVoice.set(sanitizedOptions);
449 return this;
450 }
451
452 get(): VoiceOptions<Voice> {
453 return this._dummyVoice.get();
454 }
455
456 /**
457 * Trigger the release portion of all the currently active voices immediately.
458 * Useful for silencing the synth.
459 */
460 releaseAll(time?: Time): this {
461 const computedTime = this.toSeconds(time);
462 this._activeVoices.forEach(({ voice }) => {
463 voice.triggerRelease(computedTime);
464 });
465 return this;
466 }
467
468 dispose(): this {
469 super.dispose();
470 this._dummyVoice.dispose();
471 this._voices.forEach((v) => v.dispose());
472 this._activeVoices = [];
473 this._availableVoices = [];
474 this.context.clearInterval(this._gcTimeout);
475 return this;
476 }
477}