diff --git a/Tone/instrument/Sampler.test.ts b/Tone/instrument/Sampler.test.ts index 9f6bd0ea..58818ce7 100644 --- a/Tone/instrument/Sampler.test.ts +++ b/Tone/instrument/Sampler.test.ts @@ -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", () => { @@ -56,6 +57,7 @@ describe("Sampler", () => { { attack: 0.2, release: 0.3, + loop: true } ); expect(sampler.attack).to.equal(0.2); @@ -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(() => { diff --git a/Tone/instrument/Sampler.ts b/Tone/instrument/Sampler.ts index bee81e93..5a625079 100644 --- a/Tone/instrument/Sampler.ts +++ b/Tone/instrument/Sampler.ts @@ -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"; @@ -34,6 +34,9 @@ export interface SamplerOptions extends InstrumentOptions { baseUrl: string; curve: ToneBufferSourceCurve; urls: SamplesMap; + loop: boolean; + loopEnd: number; + loopStart: number; } /** @@ -70,6 +73,26 @@ export class Sampler extends Instrument { */ private _activeSources: Map = 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 @@ -144,6 +167,9 @@ export class Sampler extends Instrument { 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) { @@ -161,6 +187,9 @@ export class Sampler extends Instrument { onerror: noOp, release: 0.1, urls: {}, + loop: false, + loopEnd: 0, + loopStart: 0, }); } @@ -197,6 +226,7 @@ export class Sampler extends Instrument { if (!Array.isArray(notes)) { notes = [notes]; } + const offset = defaultArg(this._loopStart, 0); notes.forEach((note) => { const midiFloat = ftomf( new FrequencyClass(this.context, note).toFrequency() @@ -210,6 +240,9 @@ export class Sampler extends Instrument { const playbackRate = intervalToFrequencyRatio( difference + remainder ); + const duration = this._loop + ? undefined + : buffer.duration / playbackRate; // play that note const source = new ToneBufferSource({ url: buffer, @@ -217,9 +250,12 @@ export class Sampler extends Instrument { 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, []); @@ -357,6 +393,98 @@ export class Sampler extends Instrument { 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 */