UNPKG

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