Tone.js/Tone/instrument/Sampler.ts

373 lines
10 KiB
TypeScript
Raw Permalink Normal View History

import { ToneAudioBuffer } from "../core/context/ToneAudioBuffer.js";
import { ToneAudioBuffers } from "../core/context/ToneAudioBuffers.js";
import { ftomf, intervalToFrequencyRatio } from "../core/type/Conversions.js";
import { FrequencyClass } from "../core/type/Frequency.js";
import {
Frequency,
Interval,
MidiNote,
NormalRange,
Note,
Time,
} from "../core/type/Units.js";
import { optionsFromArguments } from "../core/util/Defaults.js";
import { noOp } from "../core/util/Interface.js";
import { isArray, isNote, isNumber } from "../core/util/TypeCheck.js";
import { Instrument, InstrumentOptions } from "../instrument/Instrument.js";
import {
ToneBufferSource,
ToneBufferSourceCurve,
} from "../source/buffer/ToneBufferSource.js";
import { timeRange } from "../core/util/Decorator.js";
import { assert } from "../core/util/Debug.js";
2019-07-22 20:18:16 +00:00
interface SamplesMap {
[note: string]: ToneAudioBuffer | AudioBuffer | string;
[midi: number]: ToneAudioBuffer | AudioBuffer | string;
}
2019-09-04 22:29:24 +00:00
export interface SamplerOptions extends InstrumentOptions {
2019-07-22 20:18:16 +00:00
attack: Time;
release: Time;
onload: () => void;
onerror: (error: Error) => void;
2019-07-22 20:18:16 +00:00
baseUrl: string;
curve: ToneBufferSourceCurve;
urls: SamplesMap;
2019-07-22 20:18:16 +00:00
}
/**
* 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,
2024-04-29 14:48:37 +00:00
* use {@link Player}.
2019-07-22 20:18:16 +00:00
* @example
* const sampler = new Tone.Sampler({
2019-10-25 20:54:33 +00:00
* urls: {
2020-07-20 18:11:00 +00:00
* A1: "A1.mp3",
* A2: "A2.mp3",
2019-10-25 20:54:33 +00:00
* },
2020-07-20 18:11:00 +00:00
* baseUrl: "https://tonejs.github.io/audio/casio/",
2019-10-25 20:54:33 +00:00
* onload: () => {
* sampler.triggerAttackRelease(["C1", "E1", "G1", "B1"], 0.5);
2020-07-20 18:11:00 +00:00
* }
* }).toDestination();
2019-09-16 14:15:23 +00:00
* @category Instrument
2019-07-22 20:18:16 +00:00
*/
export class Sampler extends Instrument<SamplerOptions> {
2019-09-04 23:18:44 +00:00
readonly name: string = "Sampler";
2019-07-22 20:18:16 +00:00
/**
* 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.
2019-09-24 21:21:59 +00:00
* @min 0
* @max 1
2019-07-22 20:18:16 +00:00
*/
@timeRange(0)
2019-07-22 20:18:16 +00:00
attack: Time;
2019-07-22 20:18:16 +00:00
/**
* The envelope applied to the end of the envelope.
2019-09-24 21:21:59 +00:00
* @min 0
* @max 1
2019-07-22 20:18:16 +00:00
*/
@timeRange(0)
2019-07-22 20:18:16 +00:00
release: Time;
/**
2019-09-14 20:39:18 +00:00
* The shape of the attack/release curve.
* Either "linear" or "exponential"
2019-07-22 20:18:16 +00:00
*/
curve: ToneBufferSourceCurve;
2019-08-27 15:57:00 +00:00
/**
* @param samples An object of samples mapping either Midi Note Numbers or
2019-09-14 20:39:18 +00:00
* Scientific Pitch Notation to the url of that sample.
2019-08-27 15:57:00 +00:00
* @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.
*/
2019-07-22 20:18:16 +00:00
constructor(samples?: SamplesMap, onload?: () => void, baseUrl?: string);
2019-08-27 15:57:00 +00:00
/**
* @param samples An object of samples mapping either Midi Note Numbers or
2019-09-14 20:39:18 +00:00
* Scientific Pitch Notation to the url of that sample.
2019-08-27 15:57:00 +00:00
* @param options The remaining options associated with the sampler
*/
constructor(
samples?: SamplesMap,
options?: Partial<Omit<SamplerOptions, "urls">>
);
2019-08-27 15:57:00 +00:00
constructor(options?: Partial<SamplerOptions>);
2019-07-22 20:18:16 +00:00
constructor() {
const options = optionsFromArguments(
Sampler.getDefaults(),
arguments,
["urls", "onload", "baseUrl"],
"urls"
);
super(options);
2019-07-22 20:18:16 +00:00
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,
});
2019-07-22 20:18:16 +00:00
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);
}
2019-07-22 20:18:16 +00:00
}
static getDefaults(): SamplerOptions {
return Object.assign(Instrument.getDefaults(), {
2019-09-14 22:12:44 +00:00
attack: 0,
baseUrl: "",
2021-10-13 22:38:30 +00:00
curve: "exponential" as const,
2019-09-14 22:12:44 +00:00
onload: noOp,
onerror: noOp,
2019-09-14 22:12:44 +00:00
release: 0.1,
2019-07-22 20:18:16 +00:00
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 {
2019-07-22 20:18:16 +00:00
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;
2019-07-22 20:18:16 +00:00
// 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
);
2019-07-22 20:18:16 +00:00
// play that note
const source = new ToneBufferSource({
url: buffer,
2019-07-22 20:18:16 +00:00
context: this.context,
2019-09-14 22:12:44 +00:00
curve: this.curve,
fadeIn: this.attack,
fadeOut: this.release,
2019-07-22 20:18:16 +00:00
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[];
2019-07-22 20:18:16 +00:00
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) => {
2019-07-22 20:18:16 +00:00
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[];
2019-07-22 20:18:16 +00:00
time = this.toSeconds(time);
sources.forEach((source) => {
source.stop(time);
});
this._activeSources.set(midi, []);
2019-07-22 20:18:16 +00:00
}
});
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) => {
2019-07-22 20:18:16 +00:00
while (sources.length) {
const source = sources.shift() as ToneBufferSource;
source.stop(computedTime);
}
});
return this;
}
sync(): this {
2020-08-26 08:58:14 +00:00
if (this._syncState()) {
this._syncMethod("triggerAttack", 1);
this._syncMethod("triggerRelease", 1);
}
2019-07-22 20:18:16 +00:00
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
2019-07-22 20:18:16 +00:00
): 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"
);
2019-07-22 20:18:16 +00:00
(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;
}
/**
2019-08-04 17:12:23 +00:00
* Add a note to the sampler.
* @param note The buffer's pitch.
2019-09-16 14:15:23 +00:00
* @param url Either the url of the buffer, or a buffer which will be added with the given name.
2019-08-04 17:12:23 +00:00
* @param callback The callback to invoke when the url is loaded.
2019-07-22 20:18:16 +00:00
*/
add(
note: Note | MidiNote,
url: string | ToneAudioBuffer | AudioBuffer,
callback?: () => void
): this {
assert(
isNote(note) || isFinite(note),
`note must be a pitch or midi: ${note}`
);
2019-07-22 20:18:16 +00:00
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());
2019-07-22 20:18:16 +00:00
});
this._activeSources.clear();
return this;
}
}