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 1 commit
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
@@ -1,4 +1,4 @@
import { expect } from "chai";

Check failure on line 1 in Tone/instrument/Sampler.test.ts

View workflow job for this annotation

GitHub Actions / Linting and environment checks

Run autofix to sort these imports!

import { BasicTests } from "../../test/helper/Basic.js";
import { CompareToFile } from "../../test/helper/CompareToFile.js";
Expand All @@ -6,6 +6,7 @@
import { atTime, Offline } from "../../test/helper/Offline.js";
import { ToneAudioBuffer } from "../core/context/ToneAudioBuffer.js";
import { Sampler } from "./Sampler.js";
import { getContext } from "../core/Global.js";

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

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
126 changes: 123 additions & 3 deletions Tone/instrument/Sampler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
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 @@
baseUrl: string;
curve: ToneBufferSourceCurve;
urls: SamplesMap;
loop: boolean;
loopEnd: number;
loopStart: number;
}

/**
Expand Down Expand Up @@ -70,6 +73,26 @@
*/
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

Check warning on line 82 in Tone/instrument/Sampler.ts

View workflow job for this annotation

GitHub Actions / Linting and environment checks

Expected JSDoc block to be aligned
*/
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 @@
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 @@
onerror: noOp,
release: 0.1,
urls: {},
loop: false,
loopEnd: 0,
loopStart: 0,
});
}

Expand Down Expand Up @@ -197,6 +226,7 @@
if (!Array.isArray(notes)) {
notes = [notes];
}
const offset = defaultArg(0, this._loopStart);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you have this reversed, should be

const offset = defaultArg(this._loopStart, 0);

notes.forEach((note) => {
const midiFloat = ftomf(
new FrequencyClass(this.context, note).toFrequency()
Expand All @@ -210,16 +240,22 @@
const playbackRate = intervalToFrequencyRatio(
difference + remainder
);
let duration = this._loop

Check failure on line 243 in Tone/instrument/Sampler.ts

View workflow job for this annotation

GitHub Actions / Linting and environment checks

'duration' is never reassigned. Use 'const' instead
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if duration isn't redefined, could be const instead of let

? 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,90 @@
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("https://tonejs.github.io/audio/berklee/guitar_chord4.mp3").toDestination();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this @example isn't a valid use of the API. i think it'd need to look like:

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;

* // 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.loopStart = loopEnd;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like you accidentally set loopStart instead of loopEnd here.

});
});
}


/**
* If the buffers should loop once they are over.
* @example
* const sampler = new Tone.Sampler("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
Loading