/
Sampler.ts
329 lines (304 loc) · 9.95 KB
/
Sampler.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import { ToneAudioBuffer } from "../core/context/ToneAudioBuffer";
import { ToneAudioBuffers } from "../core/context/ToneAudioBuffers";
import { ftomf, intervalToFrequencyRatio } from "../core/type/Conversions";
import { FrequencyClass } from "../core/type/Frequency";
import { Frequency, Interval, MidiNote, NormalRange, Note, Time } from "../core/type/Units";
import { optionsFromArguments } from "../core/util/Defaults";
import { noOp } from "../core/util/Interface";
import { isArray, isNote, isNumber } from "../core/util/TypeCheck";
import { Instrument, InstrumentOptions } from "../instrument/Instrument";
import { ToneBufferSource, ToneBufferSourceCurve } from "../source/buffer/ToneBufferSource";
import { timeRange } from "../core/util/Decorator";
import { assert } from "../core/util/Debug";
interface SamplesMap {
[note: string]: ToneAudioBuffer | AudioBuffer | string;
[midi: number]: ToneAudioBuffer | AudioBuffer | string;
}
export interface SamplerOptions extends InstrumentOptions {
attack: Time;
release: Time;
onload: () => void;
onerror: (error: Error) => void;
baseUrl: string;
curve: ToneBufferSourceCurve;
urls: SamplesMap;
}
/**
* Pass in an object which maps the note's pitch or midi value to the url,
* then you can trigger the attack and release of that note like other instruments.
* By automatically repitching the samples, it is possible to play pitches which
* were not explicitly included which can save loading time.
*
* For sample or buffer playback where repitching is not necessary,
* use [[Player]].
* @example
* const sampler = new Tone.Sampler({
* urls: {
* A1: "A1.mp3",
* A2: "A2.mp3",
* },
* baseUrl: "https://tonejs.github.io/audio/casio/",
* onload: () => {
* sampler.triggerAttackRelease(["C1", "E1", "G1", "B1"], 0.5);
* }
* }).toDestination();
* @category Instrument
*/
export class Sampler extends Instrument<SamplerOptions> {
readonly name: string = "Sampler";
/**
* The stored and loaded buffers
*/
private _buffers: ToneAudioBuffers;
/**
* The object of all currently playing BufferSources
*/
private _activeSources: Map<MidiNote, ToneBufferSource[]> = new Map();
/**
* The envelope applied to the beginning of the sample.
* @min 0
* @max 1
*/
@timeRange(0)
attack: Time;
/**
* The envelope applied to the end of the envelope.
* @min 0
* @max 1
*/
@timeRange(0)
release: Time;
/**
* The shape of the attack/release curve.
* Either "linear" or "exponential"
*/
curve: ToneBufferSourceCurve;
/**
* @param samples An object of samples mapping either Midi Note Numbers or
* Scientific Pitch Notation to the url of that sample.
* @param onload The callback to invoke when all of the samples are loaded.
* @param baseUrl The root URL of all of the samples, which is prepended to all the URLs.
*/
constructor(samples?: SamplesMap, onload?: () => void, baseUrl?: string);
/**
* @param samples An object of samples mapping either Midi Note Numbers or
* Scientific Pitch Notation to the url of that sample.
* @param options The remaining options associated with the sampler
*/
constructor(samples?: SamplesMap, options?: Partial<Omit<SamplerOptions, "urls">>);
constructor(options?: Partial<SamplerOptions>);
constructor() {
super(optionsFromArguments(Sampler.getDefaults(), arguments, ["urls", "onload", "baseUrl"], "urls"));
const options = optionsFromArguments(Sampler.getDefaults(), arguments, ["urls", "onload", "baseUrl"], "urls");
const urlMap = {};
Object.keys(options.urls).forEach((note) => {
const noteNumber = parseInt(note, 10);
assert(isNote(note)
|| (isNumber(noteNumber) && isFinite(noteNumber)), `url key is neither a note or midi pitch: ${note}`);
if (isNote(note)) {
// convert the note name to MIDI
const mid = new FrequencyClass(this.context, note).toMidi();
urlMap[mid] = options.urls[note];
} else if (isNumber(noteNumber) && isFinite(noteNumber)) {
// otherwise if it's numbers assume it's midi
urlMap[noteNumber] = options.urls[noteNumber];
}
});
this._buffers = new ToneAudioBuffers({
urls: urlMap,
onload: options.onload,
baseUrl: options.baseUrl,
onerror: options.onerror,
});
this.attack = options.attack;
this.release = options.release;
this.curve = options.curve;
// invoke the callback if it's already loaded
if (this._buffers.loaded) {
// invoke onload deferred
Promise.resolve().then(options.onload);
}
}
static getDefaults(): SamplerOptions {
return Object.assign(Instrument.getDefaults(), {
attack: 0,
baseUrl: "",
curve: "exponential" as "exponential",
onload: noOp,
onerror: noOp,
release: 0.1,
urls: {},
});
}
/**
* Returns the difference in steps between the given midi note at the closets sample.
*/
private _findClosest(midi: MidiNote): Interval {
// searches within 8 octaves of the given midi note
const MAX_INTERVAL = 96;
let interval = 0;
while (interval < MAX_INTERVAL) {
// check above and below
if (this._buffers.has(midi + interval)) {
return -interval;
} else if (this._buffers.has(midi - interval)) {
return interval;
}
interval++;
}
throw new Error(`No available buffers for note: ${midi}`);
}
/**
* @param notes The note to play, or an array of notes.
* @param time When to play the note
* @param velocity The velocity to play the sample back.
*/
triggerAttack(notes: Frequency | Frequency[], time?: Time, velocity: NormalRange = 1): this {
this.log("triggerAttack", notes, time, velocity);
if (!Array.isArray(notes)) {
notes = [notes];
}
notes.forEach(note => {
const midiFloat = ftomf(new FrequencyClass(this.context, note).toFrequency());
const midi = Math.round(midiFloat) as MidiNote;
const remainder = midiFloat - midi;
// find the closest note pitch
const difference = this._findClosest(midi);
const closestNote = midi - difference;
const buffer = this._buffers.get(closestNote);
const playbackRate = intervalToFrequencyRatio(difference + remainder);
// play that note
const source = new ToneBufferSource({
url: buffer,
context: this.context,
curve: this.curve,
fadeIn: this.attack,
fadeOut: this.release,
playbackRate,
}).connect(this.output);
source.start(time, 0, buffer.duration / playbackRate, velocity);
// add it to the active sources
if (!isArray(this._activeSources.get(midi))) {
this._activeSources.set(midi, []);
}
(this._activeSources.get(midi) as ToneBufferSource[]).push(source);
// remove it when it's done
source.onended = () => {
if (this._activeSources && this._activeSources.has(midi)) {
const sources = this._activeSources.get(midi) as ToneBufferSource[];
const index = sources.indexOf(source);
if (index !== -1) {
sources.splice(index, 1);
}
}
};
});
return this;
}
/**
* @param notes The note to release, or an array of notes.
* @param time When to release the note.
*/
triggerRelease(notes: Frequency | Frequency[], time?: Time): this {
this.log("triggerRelease", notes, time);
if (!Array.isArray(notes)) {
notes = [notes];
}
notes.forEach(note => {
const midi = new FrequencyClass(this.context, note).toMidi();
// find the note
if (this._activeSources.has(midi) && (this._activeSources.get(midi) as ToneBufferSource[]).length) {
const sources = this._activeSources.get(midi) as ToneBufferSource[];
time = this.toSeconds(time);
sources.forEach(source => {
source.stop(time);
});
this._activeSources.set(midi, []);
}
});
return this;
}
/**
* Release all currently active notes.
* @param time When to release the notes.
*/
releaseAll(time?: Time): this {
const computedTime = this.toSeconds(time);
this._activeSources.forEach(sources => {
while (sources.length) {
const source = sources.shift() as ToneBufferSource;
source.stop(computedTime);
}
});
return this;
}
sync(): this {
if (this._syncState()) {
this._syncMethod("triggerAttack", 1);
this._syncMethod("triggerRelease", 1);
}
return this;
}
/**
* Invoke the attack phase, then after the duration, invoke the release.
* @param notes The note to play and release, or an array of notes.
* @param duration The time the note should be held
* @param time When to start the attack
* @param velocity The velocity of the attack
*/
triggerAttackRelease(
notes: Frequency[] | Frequency,
duration: Time | Time[],
time?: Time,
velocity: NormalRange = 1,
): this {
const computedTime = this.toSeconds(time);
this.triggerAttack(notes, computedTime, velocity);
if (isArray(duration)) {
assert(isArray(notes), "notes must be an array when duration is array");
(notes as Frequency[]).forEach((note, index) => {
const d = duration[Math.min(index, duration.length - 1)];
this.triggerRelease(note, computedTime + this.toSeconds(d));
});
} else {
this.triggerRelease(notes, computedTime + this.toSeconds(duration));
}
return this;
}
/**
* Add a note to the sampler.
* @param note The buffer's pitch.
* @param url Either the url of the buffer, or a buffer which will be added with the given name.
* @param callback The callback to invoke when the url is loaded.
*/
add(note: Note | MidiNote, url: string | ToneAudioBuffer | AudioBuffer, callback?: () => void): this {
assert(isNote(note) || isFinite(note), `note must be a pitch or midi: ${note}`);
if (isNote(note)) {
// convert the note name to MIDI
const mid = new FrequencyClass(this.context, note).toMidi();
this._buffers.add(mid, url, callback);
} else {
// otherwise if it's numbers assume it's midi
this._buffers.add(note, url, callback);
}
return this;
}
/**
* If the buffers are loaded or not
*/
get loaded(): boolean {
return this._buffers.loaded;
}
/**
* Clean up
*/
dispose(): this {
super.dispose();
this._buffers.dispose();
this._activeSources.forEach(sources => {
sources.forEach(source => source.dispose());
});
this._activeSources.clear();
return this;
}
}