diff --git a/Tone/component/filter/BiquadFilter.test.ts b/Tone/component/filter/BiquadFilter.test.ts new file mode 100644 index 000000000..d793e66fd --- /dev/null +++ b/Tone/component/filter/BiquadFilter.test.ts @@ -0,0 +1,94 @@ +import { expect } from "chai"; +import { BasicTests } from "test/helper/Basic"; +import { Offline } from "test/helper/Offline"; +import { PassAudio } from "test/helper/PassAudio"; +import { Oscillator } from "../../source/oscillator/Oscillator"; +import { BiquadFilter } from "./BiquadFilter"; + +describe("BiquadFilter", () => { + + BasicTests(BiquadFilter); + + context("BiquadFiltering", () => { + + it("can be constructed with a arguments", () => { + const filter = new BiquadFilter(200, "highpass"); + expect(filter.frequency.value).to.be.closeTo(200, 0.001); + expect(filter.type).to.equal("highpass"); + filter.dispose(); + }); + + it("can be constructed with an object", () => { + const filter = new BiquadFilter({ + frequency: 340, + type: "bandpass", + }); + expect(filter.frequency.value).to.be.closeTo(340, 0.001); + expect(filter.type).to.equal("bandpass"); + filter.dispose(); + }); + + it("can set/get values as an Object", () => { + const filter = new BiquadFilter(); + const values = { + Q: 2, + frequency: 440, + gain: -6, + type: "lowshelf" as const + }; + filter.set(values); + expect(filter.get()).to.include.keys(["type", "frequency", "Q", "gain"]); + expect(filter.type).to.equal(values.type); + expect(filter.frequency.value).to.equal(values.frequency); + expect(filter.Q.value).to.equal(values.Q); + expect(filter.gain.value).to.be.closeTo(values.gain, 0.04); + filter.dispose(); + }); + + it("can get the frequency response curve", () => { + const filter = new BiquadFilter(); + const curve = filter.getFrequencyResponse(32); + expect(curve.length).to.equal(32); + expect(curve[0]).be.closeTo(1, 0.01); + expect(curve[5]).be.closeTo(0.5, 0.1); + expect(curve[15]).be.closeTo(0, 0.01); + expect(curve[31]).be.closeTo(0, 0.01); + filter.dispose(); + }); + + it("passes the incoming signal through", () => { + return PassAudio(input => { + const filter = new BiquadFilter().toDestination(); + input.connect(filter); + }); + }); + + it("can set the basic filter types", () => { + const filter = new BiquadFilter(); + const types: BiquadFilterType[] = ["lowpass", "highpass", + "bandpass", "lowshelf", "highshelf", "notch", "allpass", "peaking"]; + for (const type of types) { + filter.type = type; + expect(filter.type).to.equal(type); + } + expect(() => { + // @ts-ignore + filter.type = "nontype"; + }).to.throw(Error); + filter.dispose(); + }); + + it("attenuates the incoming signal", () => { + return Offline(() => { + const filter = new BiquadFilter(700, "lowpass").toDestination(); + filter.Q.value = 0; + const osc = new Oscillator(880).connect(filter); + osc.start(0); + }, 0.2).then((buffer) => { + expect(buffer.getRmsAtTime(0.05)).to.be.within(0.37, 0.53); + expect(buffer.getRmsAtTime(0.1)).to.be.within(0.37, 0.53); + }); + }); + + }); +}); diff --git a/Tone/component/filter/BiquadFilter.ts b/Tone/component/filter/BiquadFilter.ts new file mode 100644 index 000000000..f13c707fd --- /dev/null +++ b/Tone/component/filter/BiquadFilter.ts @@ -0,0 +1,157 @@ +import { ToneAudioNode, ToneAudioNodeOptions } from "../../core/context/ToneAudioNode"; +import { Cents, Frequency, GainFactor, Positive } from "../../core/type/Units"; +import { optionsFromArguments } from "../../core/util/Defaults"; +import { Param } from "../../core/context/Param"; +import { assert } from "../../core/util/Debug"; + +export interface BiquadFilterOptions extends ToneAudioNodeOptions { + frequency: Frequency; + detune: Cents; + Q: number; + type: BiquadFilterType; + gain: GainFactor; +} + +/** + * Thin wrapper around the native Web Audio [BiquadFilterNode](https://webaudio.github.io/web-audio-api/#biquadfilternode). + * BiquadFilter is similar to [[Filter]] but doesn't have the option to set the "rolloff" value. + * @category Component + */ +export class BiquadFilter extends ToneAudioNode { + readonly name: string = "BiquadFilter"; + + readonly input: BiquadFilterNode; + readonly output: BiquadFilterNode; + + /** + * The frequency of the filter + */ + readonly frequency: Param<"frequency">; + + /** + * A detune value, in cents, for the frequency. + */ + readonly detune: Param<"cents">; + + /** + * The Q factor of the filter. + * For lowpass and highpass filters the Q value is interpreted to be in dB. + * For these filters the nominal range is [−𝑄𝑙𝑖𝑚,𝑄𝑙𝑖𝑚] where 𝑄𝑙𝑖𝑚 is the largest value for which 10𝑄/20 does not overflow. This is approximately 770.63678. + * For the bandpass, notch, allpass, and peaking filters, this value is a linear value. + * The value is related to the bandwidth of the filter and hence should be a positive value. The nominal range is + * [0,3.4028235𝑒38], the upper limit being the most-positive-single-float. + * This is not used for the lowshelf and highshelf filters. + */ + readonly Q: Param<"number">; + + /** + * The gain of the filter. Its value is in dB units. The gain is only used for lowshelf, highshelf, and peaking filters. + */ + readonly gain: Param<"gain">; + + private readonly _filter: BiquadFilterNode; + + /** + * @param frequency The cutoff frequency of the filter. + * @param type The type of filter. + */ + constructor(frequency?: Frequency, type?: BiquadFilterType); + constructor(options?: Partial); + constructor() { + super(optionsFromArguments(BiquadFilter.getDefaults(), arguments, ["frequency", "type"])); + const options = optionsFromArguments(BiquadFilter.getDefaults(), arguments, ["frequency", "type"]); + + this._filter = this.context.createBiquadFilter(); + this.input = this.output = this._filter; + + this.Q = new Param({ + context: this.context, + units: "number", + value: options.Q, + param: this._filter.Q, + }); + + this.frequency = new Param({ + context: this.context, + units: "frequency", + value: options.frequency, + param: this._filter.frequency, + }); + + this.detune = new Param({ + context: this.context, + units: "cents", + value: options.detune, + param: this._filter.detune, + }); + + this.gain = new Param({ + context: this.context, + units: "gain", + value: options.gain, + param: this._filter.gain, + }); + + this.type = options.type; + } + + static getDefaults(): BiquadFilterOptions { + return Object.assign(ToneAudioNode.getDefaults(), { + Q: 1, + type: "lowpass" as const, + frequency: 350, + detune: 0, + gain: 0, + }); + } + + /** + * The type of this BiquadFilterNode. For a complete list of types and their attributes, see the + * [Web Audio API](https://webaudio.github.io/web-audio-api/#dom-biquadfiltertype-lowpass) + */ + get type(): BiquadFilterType { + return this._filter.type; + } + set type(type) { + const types: BiquadFilterType[] = ["lowpass", "highpass", "bandpass", + "lowshelf", "highshelf", "notch", "allpass", "peaking"]; + assert(types.indexOf(type) !== -1, `Invalid filter type: ${type}`); + this._filter.type = type; + } + + /** + * Get the frequency response curve. This curve represents how the filter + * responses to frequencies between 20hz-20khz. + * @param len The number of values to return + * @return The frequency response curve between 20-20kHz + */ + getFrequencyResponse(len = 128): Float32Array { + // start with all 1s + const freqValues = new Float32Array(len); + for (let i = 0; i < len; i++) { + const norm = Math.pow(i / len, 2); + const freq = norm * (20000 - 20) + 20; + freqValues[i] = freq; + } + const magValues = new Float32Array(len); + const phaseValues = new Float32Array(len); + // clone the filter to remove any connections which may be changing the value + const filterClone = this.context.createBiquadFilter(); + filterClone.type = this.type; + filterClone.Q.value = this.Q.value; + filterClone.frequency.value = this.frequency.value as number; + filterClone.gain.value = this.gain.value as number; + filterClone.getFrequencyResponse(freqValues, magValues, phaseValues); + return magValues; + } + + dispose(): this { + super.dispose(); + this._filter.disconnect(); + this.Q.dispose(); + this.frequency.dispose(); + this.gain.dispose(); + this.detune.dispose(); + return this; + } +} diff --git a/Tone/component/filter/Filter.ts b/Tone/component/filter/Filter.ts index 97863cade..2d18b37f7 100644 --- a/Tone/component/filter/Filter.ts +++ b/Tone/component/filter/Filter.ts @@ -1,21 +1,17 @@ import { Gain } from "../../core/context/Gain"; -import { connectSeries, ToneAudioNode, ToneAudioNodeOptions } from "../../core/context/ToneAudioNode"; -import { Cents, Frequency, GainFactor, Positive } from "../../core/type/Units"; +import { connectSeries, ToneAudioNode } from "../../core/context/ToneAudioNode"; +import { Frequency } from "../../core/type/Units"; import { optionsFromArguments } from "../../core/util/Defaults"; import { readOnly, writable } from "../../core/util/Interface"; import { isNumber } from "../../core/util/TypeCheck"; import { Signal } from "../../signal/Signal"; import { assert } from "../../core/util/Debug"; +import { BiquadFilter, BiquadFilterOptions } from "./BiquadFilter"; export type FilterRollOff = -12 | -24 | -48 | -96; -export interface FilterOptions extends ToneAudioNodeOptions { - type: BiquadFilterType; - frequency: Frequency; +export type FilterOptions = BiquadFilterOptions & { rolloff: FilterRollOff; - Q: Positive; - detune: Cents; - gain: GainFactor; } /** @@ -35,7 +31,7 @@ export class Filter extends ToneAudioNode { readonly input = new Gain({ context: this.context }); readonly output = new Gain({ context: this.context }); - private _filters: BiquadFilterNode[] = []; + private _filters: BiquadFilter[] = []; /** * the rolloff value of the filter @@ -149,7 +145,9 @@ export class Filter extends ToneAudioNode { this._filters = new Array(cascadingCount); for (let count = 0; count < cascadingCount; count++) { - const filter = this.context.createBiquadFilter(); + const filter = new BiquadFilter({ + context: this.context, + }); filter.type = this._type; this.frequency.connect(filter.frequency); this.detune.connect(filter.detune); @@ -168,27 +166,20 @@ export class Filter extends ToneAudioNode { * @return The frequency response curve between 20-20kHz */ getFrequencyResponse(len = 128): Float32Array { + const filterClone = new BiquadFilter({ + frequency: this.frequency.value, + gain: this.gain.value, + Q: this.Q.value, + type: this._type, + detune: this.detune.value, + }); // start with all 1s const totalResponse = new Float32Array(len).map(() => 1); - const freqValues = new Float32Array(len); - for (let i = 0; i < len; i++) { - const norm = Math.pow(i / len, 2); - const freq = norm * (20000 - 20) + 20; - freqValues[i] = freq; - } - const magValues = new Float32Array(len); - const phaseValues = new Float32Array(len); this._filters.forEach(() => { - const filterClone = this.context.createBiquadFilter(); - filterClone.type = this._type; - filterClone.Q.value = this.Q.value; - filterClone.frequency.value = this.frequency.value as number; - filterClone.gain.value = this.gain.value as number; - filterClone.getFrequencyResponse(freqValues, magValues, phaseValues); - magValues.forEach((val, i) => { - totalResponse[i] *= val; - }); + const response = filterClone.getFrequencyResponse(len); + response.forEach((val, i) => totalResponse[i] *= val); }); + filterClone.dispose(); return totalResponse; } @@ -198,7 +189,7 @@ export class Filter extends ToneAudioNode { dispose(): this { super.dispose(); this._filters.forEach(filter => { - filter.disconnect(); + filter.dispose(); }); writable(this, ["detune", "frequency", "gain", "Q"]); this.frequency.dispose();