1 | import { MidiClass } from "../core/type/Midi.js";
|
2 | import {
|
3 | Frequency,
|
4 | MidiNote,
|
5 | NormalRange,
|
6 | Seconds,
|
7 | Time,
|
8 | } from "../core/type/Units.js";
|
9 | import {
|
10 | deepMerge,
|
11 | omitFromObject,
|
12 | optionsFromArguments,
|
13 | } from "../core/util/Defaults.js";
|
14 | import { RecursivePartial } from "../core/util/Interface.js";
|
15 | import { isArray, isNumber } from "../core/util/TypeCheck.js";
|
16 | import { Instrument, InstrumentOptions } from "./Instrument.js";
|
17 | import { MembraneSynth, MembraneSynthOptions } from "./MembraneSynth.js";
|
18 | import { FMSynth, FMSynthOptions } from "./FMSynth.js";
|
19 | import { AMSynth, AMSynthOptions } from "./AMSynth.js";
|
20 | import { MonoSynth, MonoSynthOptions } from "./MonoSynth.js";
|
21 | import { MetalSynth, MetalSynthOptions } from "./MetalSynth.js";
|
22 | import { Monophonic } from "./Monophonic.js";
|
23 | import { Synth, SynthOptions } from "./Synth.js";
|
24 | import { assert, warn } from "../core/util/Debug.js";
|
25 |
|
26 | type VoiceConstructor<V> = {
|
27 | getDefaults: () => VoiceOptions<V>;
|
28 | } & (new (...args: any[]) => V);
|
29 |
|
30 | type OmitMonophonicOptions<T> = Omit<T, "context" | "onsilence">;
|
31 |
|
32 | type 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 | */
|
51 | type PartialVoiceOptions<T> = RecursivePartial<
|
52 | OmitMonophonicOptions<VoiceOptions<T>>
|
53 | >;
|
54 |
|
55 | export 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 | */
|
76 | export 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 |
|
286 | event.voice.triggerRelease(time);
|
287 |
|
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 |
|
429 |
|
430 |
|
431 |
|
432 |
|
433 |
|
434 |
|
435 |
|
436 |
|
437 |
|
438 |
|
439 | set(options: RecursivePartial<VoiceOptions<Voice>>): this {
|
440 |
|
441 | const sanitizedOptions = omitFromObject(options, [
|
442 | "onsilence",
|
443 | "context",
|
444 | ]);
|
445 |
|
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 |
|
458 |
|
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 | }
|