Tone.js/Tone/source/buffer/GrainPlayer.ts
2020-07-19 10:16:30 -07:00

322 lines
7.8 KiB
TypeScript

import { Source, SourceOptions } from "../Source";
import { noOp } from "../../core/util/Interface";
import { ToneAudioBuffer } from "../../core/context/ToneAudioBuffer";
import { defaultArg, optionsFromArguments } from "../../core/util/Defaults";
import { Clock } from "../../core/clock/Clock";
import { Cents, Positive, Seconds, Time } from "../../core/type/Units";
import { ToneBufferSource } from "./ToneBufferSource";
import { intervalToFrequencyRatio } from "../../core/type/Conversions";
import { assertRange } from "../../core/util/Debug";
interface GrainPlayerOptions extends SourceOptions {
onload: () => void;
onerror: (error: Error) => void;
reverse: boolean;
url?: ToneAudioBuffer | string | AudioBuffer;
overlap: Seconds;
grainSize: Seconds;
playbackRate: Positive;
detune: Cents;
loop: boolean;
loopStart: Time;
loopEnd: Time;
}
/**
* GrainPlayer implements [granular synthesis](https://en.wikipedia.org/wiki/Granular_synthesis).
* Granular Synthesis enables you to adjust pitch and playback rate independently. The grainSize is the
* amount of time each small chunk of audio is played for and the overlap is the
* amount of crossfading transition time between successive grains.
* @category Source
*/
export class GrainPlayer extends Source<GrainPlayerOptions> {
readonly name: string = "GrainPlayer";
/**
* The audio buffer belonging to the player.
*/
buffer: ToneAudioBuffer;
/**
* Create a repeating tick to schedule the grains.
*/
private _clock: Clock;
/**
* Internal loopStart value
*/
private _loopStart = 0;
/**
* Internal loopStart value
*/
private _loopEnd = 0;
/**
* All of the currently playing BufferSources
*/
private _activeSources: ToneBufferSource[] = [];
/**
* Internal reference to the playback rate
*/
private _playbackRate: Positive;
/**
* Internal grain size reference;
*/
private _grainSize: Seconds;
/**
* Internal overlap reference;
*/
private _overlap: Seconds;
/**
* Adjust the pitch independently of the playbackRate.
*/
detune: Cents;
/**
* If the buffer should loop back to the loopStart when completed
*/
loop: boolean;
/**
* @param url Either the AudioBuffer or the url from which to load the AudioBuffer
* @param onload The function to invoke when the buffer is loaded.
*/
constructor(url?: string | AudioBuffer | ToneAudioBuffer, onload?: () => void);
constructor(options?: Partial<GrainPlayerOptions>);
constructor() {
super(optionsFromArguments(GrainPlayer.getDefaults(), arguments, ["url", "onload"]));
const options = optionsFromArguments(GrainPlayer.getDefaults(), arguments, ["url", "onload"]);
this.buffer = new ToneAudioBuffer({
onload: options.onload,
onerror: options.onerror,
reverse: options.reverse,
url: options.url,
});
this._clock = new Clock({
context: this.context,
callback: this._tick.bind(this),
frequency: 1 / options.grainSize
});
this._playbackRate = options.playbackRate;
this._grainSize = options.grainSize;
this._overlap = options.overlap;
this.detune = options.detune;
// setup
this.overlap = options.overlap;
this.loop = options.loop;
this.playbackRate = options.playbackRate;
this.grainSize = options.grainSize;
this.loopStart = options.loopStart;
this.loopEnd = options.loopEnd;
this.reverse = options.reverse;
this._clock.on("stop", this._onstop.bind(this));
}
static getDefaults(): GrainPlayerOptions {
return Object.assign(Source.getDefaults(), {
onload: noOp,
onerror: noOp,
overlap: 0.1,
grainSize: 0.2,
playbackRate: 1,
detune: 0,
loop: false,
loopStart: 0,
loopEnd: 0,
reverse: false
});
}
/**
* Internal start method
*/
protected _start(time?: Time, offset?: Time, duration?: Time): void {
offset = defaultArg(offset, 0);
offset = this.toSeconds(offset);
time = this.toSeconds(time);
const grainSize = 1 / this._clock.frequency.getValueAtTime(time);
this._clock.start(time, offset / grainSize);
if (duration) {
this.stop(time + this.toSeconds(duration));
}
}
/**
* Stop and then restart the player from the beginning (or offset)
* @param time When the player should start.
* @param offset The offset from the beginning of the sample to start at.
* @param duration How long the sample should play. If no duration is given,
* it will default to the full length of the sample (minus any offset)
*/
restart(time?: Seconds, offset?: Time, duration?: Time): this {
super.restart(time, offset, duration);
return this;
}
protected _restart(time?: Seconds, offset?: Time, duration?: Time): void {
this._stop(time);
this._start(time, offset, duration);
}
/**
* Internal stop method
*/
protected _stop(time?: Time): void {
this._clock.stop(time);
}
/**
* Invoked when the clock is stopped
*/
private _onstop(time: Seconds): void {
// stop the players
this._activeSources.forEach((source) => {
source.fadeOut = 0;
source.stop(time);
});
this.onstop(this);
}
/**
* Invoked on each clock tick. scheduled a new grain at this time.
*/
private _tick(time: Seconds): void {
// check if it should stop looping
const ticks = this._clock.getTicksAtTime(time);
const offset = ticks * this._grainSize;
this.log("offset", offset);
if (!this.loop && offset > this.buffer.duration) {
this.stop(time);
return;
}
// at the beginning of the file, the fade in should be 0
const fadeIn = offset < this._overlap ? 0 : this._overlap;
// create a buffer source
const source = new ToneBufferSource({
context: this.context,
url: this.buffer,
fadeIn: fadeIn,
fadeOut: this._overlap,
loop: this.loop,
loopStart: this._loopStart,
loopEnd: this._loopEnd,
// compute the playbackRate based on the detune
playbackRate: intervalToFrequencyRatio(this.detune / 100)
}).connect(this.output);
source.start(time, this._grainSize * ticks);
source.stop(time + this._grainSize / this.playbackRate);
// add it to the active sources
this._activeSources.push(source);
// remove it when it's done
source.onended = () => {
const index = this._activeSources.indexOf(source);
if (index !== -1) {
this._activeSources.splice(index, 1);
}
};
}
/**
* The playback rate of the sample
*/
get playbackRate(): Positive {
return this._playbackRate;
}
set playbackRate(rate) {
assertRange(rate, 0.001);
this._playbackRate = rate;
this.grainSize = this._grainSize;
}
/**
* The loop start time.
*/
get loopStart(): Time {
return this._loopStart;
}
set loopStart(time) {
if (this.buffer.loaded) {
assertRange(this.toSeconds(time), 0, this.buffer.duration);
}
this._loopStart = this.toSeconds(time);
}
/**
* The loop end time.
*/
get loopEnd(): Time {
return this._loopEnd;
}
set loopEnd(time) {
if (this.buffer.loaded) {
assertRange(this.toSeconds(time), 0, this.buffer.duration);
}
this._loopEnd = this.toSeconds(time);
}
/**
* The direction the buffer should play in
*/
get reverse() {
return this.buffer.reverse;
}
set reverse(rev) {
this.buffer.reverse = rev;
}
/**
* The size of each chunk of audio that the
* buffer is chopped into and played back at.
*/
get grainSize(): Time {
return this._grainSize;
}
set grainSize(size) {
this._grainSize = this.toSeconds(size);
this._clock.frequency.setValueAtTime(this._playbackRate / this._grainSize, this.now());
}
/**
* The duration of the cross-fade between successive grains.
*/
get overlap(): Time {
return this._overlap;
}
set overlap(time) {
const computedTime = this.toSeconds(time);
assertRange(computedTime, 0);
this._overlap = computedTime;
}
/**
* If all the buffer is loaded
*/
get loaded(): boolean {
return this.buffer.loaded;
}
dispose(): this {
super.dispose();
this.buffer.dispose();
this._clock.dispose();
this._activeSources.forEach((source) => source.dispose());
return this;
}
}