Skip to content

Adding loop functionality to Sampler (#1310) #1350

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions Tone/instrument/Sampler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CompareToFile } from "../../test/helper/CompareToFile.js";
import { InstrumentTest } from "../../test/helper/InstrumentTests.js";
import { atTime, Offline } from "../../test/helper/Offline.js";
import { ToneAudioBuffer } from "../core/context/ToneAudioBuffer.js";
import { getContext } from "../core/Global.js";
import { Sampler } from "./Sampler.js";

describe("Sampler", () => {
Expand Down Expand Up @@ -56,6 +57,7 @@ describe("Sampler", () => {
{
attack: 0.2,
release: 0.3,
loop: true
}
);
expect(sampler.attack).to.equal(0.2);
Expand Down Expand Up @@ -267,6 +269,97 @@ describe("Sampler", () => {
});
});

context("Looping", () => {

it("can be set to loop", () => {
const sampler = new Sampler();
sampler.loop = true;
expect(sampler.loop).to.be.true;
sampler.dispose();
});

it("can set the loop points", () => {
const sampler = new Sampler();
sampler.loopStart = 0.2;
expect(sampler.loopStart).to.equal(0.2);
sampler.loopEnd = 0.7;
expect(sampler.loopEnd).to.equal(0.7);
sampler.setLoopPoints(0, 0.5);
expect(sampler.loopStart).to.equal(0);
expect(sampler.loopEnd).to.equal(0.5);
sampler.dispose();
});

it("loops the audio", async () => {
const buff = await Offline(() => {
const sampler = new Sampler({
urls: {
A4: A4_buffer
}
});
sampler.loop = true;
sampler.toDestination();
sampler.triggerAttack("A4");
}, A4_buffer.duration * 1.5);
expect(buff.getRmsAtTime(0)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration * 0.5)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration * 1.2)).to.be.above(0);
});

it("setting the loop multiple times has no affect", async () => {
const buff = await Offline(() => {
const sampler = new Sampler({
urls: {
A4: A4_buffer
}
});
sampler.loop = true;
sampler.loop = true;
sampler.toDestination();
sampler.triggerAttack("A4");
}, A4_buffer.duration * 1.5);
expect(buff.getRmsAtTime(0)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration * 0.5)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration * 1.2)).to.be.above(0);
});

it("loops the audio when loop is set after start", async () => {
const buff = await Offline(() => {
const sampler = new Sampler({
urls: {
A4: A4_buffer
}
});
sampler.toDestination();
sampler.triggerAttack("A4");
sampler.loop = true;
}, A4_buffer.duration * 1.5);
expect(buff.getRmsAtTime(0)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration * 0.5)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration)).to.be.above(0);
expect(buff.getRmsAtTime(A4_buffer.duration * 1.2)).to.be.above(0);
});

it("starts buffers at loopStart when set to loop", async () => {
const testSample =
A4_buffer.toArray(0)[Math.floor(0.1 * getContext().sampleRate)];
const buff = await Offline(() => {
const sampler = new Sampler({
urls: {
A4: A4_buffer
}
});
sampler.loopStart = 0.1;
sampler.loop = true;
sampler.toDestination();
sampler.triggerAttack("A4");
}, 0.05);
expect(buff.toArray()[0][0]).to.equal(testSample);
});
});

context("add samples", () => {
it("can add a note with its midi value", async () => {
const buffer = await Offline(() => {
Expand Down
134 changes: 131 additions & 3 deletions Tone/instrument/Sampler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import {
Note,
Time,
} from "../core/type/Units.js";
import { assert } from "../core/util/Debug.js";
import { assert, assertRange } from "../core/util/Debug.js";
import { timeRange } from "../core/util/Decorator.js";
import { optionsFromArguments } from "../core/util/Defaults.js";
import { defaultArg, 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";
Expand All @@ -34,6 +34,9 @@ export interface SamplerOptions extends InstrumentOptions {
baseUrl: string;
curve: ToneBufferSourceCurve;
urls: SamplesMap;
loop: boolean;
loopEnd: number;
loopStart: number;
}

/**
Expand Down Expand Up @@ -70,6 +73,26 @@ export class Sampler extends Instrument<SamplerOptions> {
*/
private _activeSources: Map<MidiNote, ToneBufferSource[]> = new Map();

/**
* The list of all provided midi notes
*/
private _providedMidiNotes: MidiNote[] = [];

/**
* if the buffer should loop once its over
*/
private _loop: boolean;

/**
* if 'loop' is true, the loop will start at this position
*/
private _loopStart: Time;

/**
* if 'loop' is true, the loop will end at this position
*/
private _loopEnd: Time;

/**
* The envelope applied to the beginning of the sample.
* @min 0
Expand Down Expand Up @@ -144,6 +167,9 @@ export class Sampler extends Instrument<SamplerOptions> {
this.attack = options.attack;
this.release = options.release;
this.curve = options.curve;
this._loop = options.loop;
this._loopStart = options.loopStart;
this._loopEnd = options.loopEnd;

// invoke the callback if it's already loaded
if (this._buffers.loaded) {
Expand All @@ -161,6 +187,9 @@ export class Sampler extends Instrument<SamplerOptions> {
onerror: noOp,
release: 0.1,
urls: {},
loop: false,
loopEnd: 0,
loopStart: 0,
});
}

Expand Down Expand Up @@ -197,6 +226,7 @@ export class Sampler extends Instrument<SamplerOptions> {
if (!Array.isArray(notes)) {
notes = [notes];
}
const offset = defaultArg(this._loopStart, 0);
notes.forEach((note) => {
const midiFloat = ftomf(
new FrequencyClass(this.context, note).toFrequency()
Expand All @@ -210,16 +240,22 @@ export class Sampler extends Instrument<SamplerOptions> {
const playbackRate = intervalToFrequencyRatio(
difference + remainder
);
const duration = this._loop
? undefined
: buffer.duration / playbackRate;
// play that note
const source = new ToneBufferSource({
url: buffer,
context: this.context,
curve: this.curve,
fadeIn: this.attack,
fadeOut: this.release,
loop: this._loop,
loopStart: this._loopStart,
loopEnd: this._loopEnd,
playbackRate,
}).connect(this.output);
source.start(time, 0, buffer.duration / playbackRate, velocity);
source.start(time, offset, duration, velocity);
// add it to the active sources
if (!isArray(this._activeSources.get(midi))) {
this._activeSources.set(midi, []);
Expand Down Expand Up @@ -357,6 +393,98 @@ export class Sampler extends Instrument<SamplerOptions> {
return this._buffers.loaded;
}

/**
* Set the loop start and end. Will only loop if loop is set to true.
* @param loopStart The loop start time
* @param loopEnd The loop end time
* @example
* const sampler = new Tone.Sampler({
* urls: {
* A1: "https://tonejs.github.io/audio/berklee/guitar_chord4.mp3",
* },
* }).toDestination();
* // loop between the given points
* sampler.setLoopPoints(0.2, 0.3);
* sampler.loop = true;
*/
setLoopPoints(loopStart: Time, loopEnd: Time): this {
this.loopStart = loopStart;
this.loopEnd = loopEnd;
return this;
}

/**
* If loop is true, the loop will start at this position.
*/
get loopStart(): Time {
return this._loopStart;
}
set loopStart(loopStart) {
this._loopStart = loopStart;
this._providedMidiNotes.forEach((midiNote) => {
const buffer = this._buffers.get(midiNote);
if (buffer.loaded) {
assertRange(this.toSeconds(loopStart), 0, buffer.duration);
}
});
// get the current sources
this._activeSources.forEach((sourceList) => {
sourceList.forEach((source) => {
source.loopStart = loopStart;
});
});
}

/**
* If loop is true, the loop will end at this position.
*/
get loopEnd(): Time {
return this._loopEnd;
}
set loopEnd(loopEnd) {
this._loopEnd = loopEnd;
this._providedMidiNotes.forEach((midiNote) => {
const buffer = this._buffers.get(midiNote);
if (buffer.loaded) {
assertRange(this.toSeconds(loopEnd), 0, buffer.duration);
}
});
// get the current sources
this._activeSources.forEach((sourceList) => {
sourceList.forEach((source) => {
source.loopEnd = loopEnd;
});
});
}


/**
* If the buffers should loop once they are over.
* @example
* const sampler = new Tone.Sampler({
* urls: {
* A4: "https://tonejs.github.io/audio/berklee/femalevoice_aa_A4.mp3",
* },
* }).toDestination();
* sampler.loop = true;
*/
get loop(): boolean {
return this._loop;
}
set loop(loop) {
// if no change, do nothing
if (this._loop === loop) {
return;
}
this._loop = loop;
// set the loop of all of the sources
this._activeSources.forEach((sourceList) => {
sourceList.forEach((source) => {
source.loop = loop;
});
});
}

/**
* Clean up
*/
Expand Down