diff --git a/src/audio-contexts/audio-context.ts b/src/audio-contexts/audio-context.ts index 12f50ee7c..a8fbdf7cf 100644 --- a/src/audio-contexts/audio-context.ts +++ b/src/audio-contexts/audio-context.ts @@ -4,10 +4,9 @@ import { ChannelMergerNode } from '../audio-nodes/channel-merger-node'; import { ChannelSplitterNode } from '../audio-nodes/channel-splitter-node'; import { MediaElementAudioSourceNode } from '../audio-nodes/media-element-audio-source-node'; import { MediaStreamAudioSourceNode } from '../audio-nodes/media-stream-audio-source-node'; -import { OscillatorNode } from '../audio-nodes/oscillator-node'; import { INVALID_STATE_ERROR_FACTORY_PROVIDER, InvalidStateErrorFactory } from '../factories/invalid-state-error'; import { isValidLatencyHint } from '../helpers/is-valid-latency-hint'; -import { IAnalyserNode, IAudioContext, IAudioContextOptions, IOscillatorNode } from '../interfaces'; +import { IAnalyserNode, IAudioContext, IAudioContextOptions } from '../interfaces'; import { UNPATCHED_AUDIO_CONTEXT_CONSTRUCTOR_PROVIDER, unpatchedAudioContextConstructor as nptchdDCntxtCnstrctr @@ -96,10 +95,6 @@ export class AudioContext extends BaseAudioContext implements IAudioContext { return new MediaStreamAudioSourceNode(this, { mediaStream }); } - public createOscillator (): IOscillatorNode { - return new OscillatorNode(this); - } - public close () { // Bug #35: Firefox does not throw an error if the AudioContext was closed before. if (this.state === 'closed') { diff --git a/src/audio-contexts/base-audio-context.ts b/src/audio-contexts/base-audio-context.ts index 1d08e9a1c..d22d4a27c 100644 --- a/src/audio-contexts/base-audio-context.ts +++ b/src/audio-contexts/base-audio-context.ts @@ -5,6 +5,7 @@ import { BiquadFilterNode } from '../audio-nodes/biquad-filter-node'; import { ConstantSourceNode } from '../audio-nodes/constant-source-node'; import { GainNode } from '../audio-nodes/gain-node'; import { IIRFilterNode } from '../audio-nodes/iir-filter-node'; +import { OscillatorNode } from '../audio-nodes/oscillator-node'; import { decodeAudioData } from '../decode-audio-data'; import { IAudioBuffer, @@ -14,6 +15,7 @@ import { IBiquadFilterNode, IGainNode, IIIRFilterNode, + IOscillatorNode, IWorkletOptions } from '../interfaces'; import { @@ -65,6 +67,10 @@ export class BaseAudioContext extends MinimalBaseAudioContext implements IBaseAu return new IIRFilterNode(this, { feedback, feedforward }); } + public createOscillator (): IOscillatorNode { + return new OscillatorNode(this); + } + public decodeAudioData ( audioData: ArrayBuffer, successCallback?: TDecodeSuccessCallback, errorCallback?: TDecodeErrorCallback ): Promise { diff --git a/src/audio-nodes/oscillator-node.ts b/src/audio-nodes/oscillator-node.ts index 713102856..89a24d329 100644 --- a/src/audio-nodes/oscillator-node.ts +++ b/src/audio-nodes/oscillator-node.ts @@ -1,25 +1,23 @@ import { Injector } from '@angular/core'; import { INVALID_STATE_ERROR_FACTORY_PROVIDER, InvalidStateErrorFactory } from '../factories/invalid-state-error'; +import { AUDIO_NODE_RENDERER_STORE } from '../globals'; +import { createNativeOscillatorNode } from '../helpers/create-native-oscillator-node'; import { getNativeContext } from '../helpers/get-native-context'; import { isOfflineAudioContext } from '../helpers/is-offline-audio-context'; import { IAudioParam, IMinimalBaseAudioContext, IOscillatorNode, IOscillatorOptions } from '../interfaces'; -import { - TChannelCountMode, - TChannelInterpretation, - TEndedEventHandler, - TNativeOscillatorNode, - TOscillatorType, - TUnpatchedAudioContext, - TUnpatchedOfflineAudioContext -} from '../types'; +import { OscillatorNodeRenderer } from '../renderers/oscillator-node'; +import { TChannelCountMode, TChannelInterpretation, TEndedEventHandler, TNativeOscillatorNode, TOscillatorType } from '../types'; +import { AUDIO_PARAM_WRAPPER_PROVIDER, AudioParamWrapper } from '../wrappers/audio-param'; import { NoneAudioDestinationNode } from './none-audio-destination-node'; const injector = Injector.create({ providers: [ + AUDIO_PARAM_WRAPPER_PROVIDER, INVALID_STATE_ERROR_FACTORY_PROVIDER ] }); +const audioParamWrapper = injector.get(AudioParamWrapper); const invalidStateErrorFactory = injector.get(InvalidStateErrorFactory); // The DEFAULT_OPTIONS are only of type Partial because there is no default value for periodicWave. @@ -32,22 +30,23 @@ const DEFAULT_OPTIONS: Partial = { type: 'sine' }; -const createNativeNode = (nativeContext: TUnpatchedAudioContext | TUnpatchedOfflineAudioContext) => { - if (isOfflineAudioContext(nativeContext)) { - throw new Error('This is not yet supported.'); - } - - return nativeContext.createOscillator(); -}; - export class OscillatorNode extends NoneAudioDestinationNode implements IOscillatorNode { constructor (context: IMinimalBaseAudioContext, options: Partial = DEFAULT_OPTIONS) { const nativeContext = getNativeContext(context); - const { channelCount } = { ...DEFAULT_OPTIONS, ...options }; - const nativeNode = createNativeNode(nativeContext); + const mergedOptions = { ...DEFAULT_OPTIONS, ...options }; + const nativeNode = createNativeOscillatorNode(nativeContext, mergedOptions); - super(context, nativeNode, channelCount); + super(context, nativeNode, mergedOptions.channelCount); + + if (isOfflineAudioContext(nativeContext)) { + const oscillatorNodeRenderer = new OscillatorNodeRenderer(this); + + AUDIO_NODE_RENDERER_STORE.set(this, oscillatorNodeRenderer); + + audioParamWrapper.wrap(nativeNode, context, 'detune'); + audioParamWrapper.wrap(nativeNode, context, 'frequency'); + } } public get detune () { diff --git a/src/helpers/create-native-oscillator-node.ts b/src/helpers/create-native-oscillator-node.ts new file mode 100644 index 000000000..5eb1082ce --- /dev/null +++ b/src/helpers/create-native-oscillator-node.ts @@ -0,0 +1,96 @@ +import { Injector } from '@angular/core'; +import { assignNativeAudioNodeOptions } from '../helpers/assign-native-audio-node-options'; +import { cacheTestResult } from '../helpers/cache-test-result'; +import { IOscillatorOptions } from '../interfaces'; +import { + AUDIO_SCHEDULED_SOURCE_NODE_START_METHOD_NEGATIVE_PARAMETERS_SUPPORT_TESTER_PROVIDER, + AudioScheduledSourceNodeStartMethodNegativeParametersSupportTester +} from '../support-testers/audio-scheduled-source-node-start-methods-negative-parameters'; +import { + AUDIO_SCHEDULED_SOURCE_NODE_STOP_METHOD_CONSECUTIVE_CALLS_SUPPORT_TESTER_PROVIDER, + AudioScheduledSourceNodeStopMethodConsecutiveCallsSupportTester +} from '../support-testers/audio-scheduled-source-node-stop-methods-consecutive-calls'; +import { + AUDIO_SCHEDULED_SOURCE_NODE_STOP_METHOD_NEGATIVE_PARAMETERS_SUPPORT_TESTER_PROVIDER, + AudioScheduledSourceNodeStopMethodNegativeParametersSupportTester +} from '../support-testers/audio-scheduled-source-node-stop-methods-negative-parameters'; +import { TNativeOscillatorNode, TUnpatchedAudioContext, TUnpatchedOfflineAudioContext } from '../types'; +import { + AUDIO_SCHEDULED_SOURCE_NODE_START_METHOD_NEGATIVE_PARAMETERS_WRAPPER_PROVIDER, + AudioScheduledSourceNodeStartMethodNegativeParametersWrapper +} from '../wrappers/audio-scheduled-source-node-start-method-negative-parameters'; +import { + AUDIO_SCHEDULED_SOURCE_NODE_STOP_METHOD_CONSECUTIVE_CALLS_WRAPPER_PROVIDER, + AudioScheduledSourceNodeStopMethodConsecutiveCallsWrapper +} from '../wrappers/audio-scheduled-source-node-stop-method-consecutive-calls'; +import { + AUDIO_SCHEDULED_SOURCE_NODE_STOP_METHOD_NEGATIVE_PARAMETERS_WRAPPER_PROVIDER, + AudioScheduledSourceNodeStopMethodNegativeParametersWrapper +} from '../wrappers/audio-scheduled-source-node-stop-method-negative-parameters'; + +const injector = Injector.create({ + providers: [ + AUDIO_SCHEDULED_SOURCE_NODE_START_METHOD_NEGATIVE_PARAMETERS_SUPPORT_TESTER_PROVIDER, + AUDIO_SCHEDULED_SOURCE_NODE_START_METHOD_NEGATIVE_PARAMETERS_WRAPPER_PROVIDER, + AUDIO_SCHEDULED_SOURCE_NODE_STOP_METHOD_CONSECUTIVE_CALLS_SUPPORT_TESTER_PROVIDER, + AUDIO_SCHEDULED_SOURCE_NODE_STOP_METHOD_CONSECUTIVE_CALLS_WRAPPER_PROVIDER, + AUDIO_SCHEDULED_SOURCE_NODE_STOP_METHOD_NEGATIVE_PARAMETERS_SUPPORT_TESTER_PROVIDER, + AUDIO_SCHEDULED_SOURCE_NODE_STOP_METHOD_NEGATIVE_PARAMETERS_WRAPPER_PROVIDER + ] +}); + +const startMethodNegativeParametersSupportTester = injector.get(AudioScheduledSourceNodeStartMethodNegativeParametersSupportTester); +const startMethodNegativeParametersWrapper = injector.get(AudioScheduledSourceNodeStartMethodNegativeParametersWrapper); +const stopMethodConsecutiveCallsSupportTester = injector.get(AudioScheduledSourceNodeStopMethodConsecutiveCallsSupportTester); +const stopMethodConsecutiveCallsWrapper = injector.get(AudioScheduledSourceNodeStopMethodConsecutiveCallsWrapper); +const stopMethodNegativeParametersSupportTester = injector.get(AudioScheduledSourceNodeStopMethodNegativeParametersSupportTester); +const stopMethodNegativeParametersWrapper = injector.get(AudioScheduledSourceNodeStopMethodNegativeParametersWrapper); + +export const createNativeOscillatorNode = ( + nativeContext: TUnpatchedAudioContext | TUnpatchedOfflineAudioContext, + options: Partial = { } +): TNativeOscillatorNode => { + const nativeNode = nativeContext.createOscillator(); + + assignNativeAudioNodeOptions(nativeNode, options); + + if (options.detune !== undefined) { + nativeNode.detune.value = options.detune; + } + + if (options.frequency !== undefined) { + nativeNode.frequency.value = options.frequency; + } + + // @todo periodicWave + + if (options.type !== undefined) { + nativeNode.type = options.type; + } + + // Bug #44: Only Chrome & Opera throw a RangeError yet. + if (!cacheTestResult( + AudioScheduledSourceNodeStartMethodNegativeParametersSupportTester, + () => startMethodNegativeParametersSupportTester.test(nativeContext) + )) { + startMethodNegativeParametersWrapper.wrap(nativeNode); + } + + // Bug #19: Safari does not ignore calls to stop() of an already stopped AudioBufferSourceNode. + if (!cacheTestResult( + AudioScheduledSourceNodeStopMethodConsecutiveCallsSupportTester, + () => stopMethodConsecutiveCallsSupportTester.test(nativeContext) + )) { + stopMethodConsecutiveCallsWrapper.wrap(nativeNode, nativeContext); + } + + // Bug #44: No browser does throw a RangeError yet. + if (!cacheTestResult( + AudioScheduledSourceNodeStopMethodNegativeParametersSupportTester, + () => stopMethodNegativeParametersSupportTester.test(nativeContext) + )) { + stopMethodNegativeParametersWrapper.wrap(nativeNode); + } + + return nativeNode; +}; diff --git a/src/interfaces/audio-context.ts b/src/interfaces/audio-context.ts index ce96adb48..16c323290 100644 --- a/src/interfaces/audio-context.ts +++ b/src/interfaces/audio-context.ts @@ -4,7 +4,6 @@ import { IBaseAudioContext } from './base-audio-context'; import { IMediaElementAudioSourceNode } from './media-element-audio-source-node'; import { IMediaStreamAudioSourceNode } from './media-stream-audio-source-node'; import { IMinimalAudioContext } from './minimal-audio-context'; -import { IOscillatorNode } from './oscillator-node'; export interface IAudioContext extends IBaseAudioContext, IMinimalAudioContext { @@ -21,7 +20,4 @@ export interface IAudioContext extends IBaseAudioContext, IMinimalAudioContext { createMediaStreamSource (mediaStream: MediaStream): IMediaStreamAudioSourceNode; - // @todo This should move into the IBaseAudioContext interface. - createOscillator (): IOscillatorNode; - } diff --git a/src/interfaces/base-audio-context.ts b/src/interfaces/base-audio-context.ts index d2c836076..aad0554bc 100644 --- a/src/interfaces/base-audio-context.ts +++ b/src/interfaces/base-audio-context.ts @@ -6,6 +6,7 @@ import { IConstantSourceNode } from './constant-source-node'; import { IGainNode } from './gain-node'; import { IIIRFilterNode } from './iir-filter-node'; import { IMinimalBaseAudioContext } from './minimal-base-audio-context'; +import { IOscillatorNode } from './oscillator-node'; export interface IBaseAudioContext extends IMinimalBaseAudioContext { @@ -25,6 +26,8 @@ export interface IBaseAudioContext extends IMinimalBaseAudioContext { createIIRFilter (feedforward: number[], feedback: number[]): IIIRFilterNode; + createOscillator (): IOscillatorNode; + decodeAudioData ( audioData: ArrayBuffer, successCallback?: TDecodeSuccessCallback, diff --git a/src/renderers/oscillator-node.ts b/src/renderers/oscillator-node.ts new file mode 100644 index 000000000..86d176a72 --- /dev/null +++ b/src/renderers/oscillator-node.ts @@ -0,0 +1,53 @@ +import { AUDIO_PARAM_STORE } from '../globals'; +import { connectAudioParam } from '../helpers/connect-audio-param'; +import { createNativeOscillatorNode } from '../helpers/create-native-oscillator-node'; +import { getNativeNode } from '../helpers/get-native-node'; +import { isOwnedByContext } from '../helpers/is-owned-by-context'; +import { renderAutomation } from '../helpers/render-automation'; +import { IAudioParam, IOscillatorNode } from '../interfaces'; +import { TNativeAudioNode, TNativeAudioParam, TNativeOscillatorNode, TUnpatchedOfflineAudioContext } from '../types'; +import { AudioNodeRenderer } from './audio-node'; + +export class OscillatorNodeRenderer extends AudioNodeRenderer { + + private _nativeNode: null | TNativeOscillatorNode; + + private _proxy: IOscillatorNode; + + constructor (proxy: IOscillatorNode) { + super(); + + this._nativeNode = null; + this._proxy = proxy; + } + + public async render (offlineAudioContext: TUnpatchedOfflineAudioContext): Promise { + if (this._nativeNode !== null) { + return this._nativeNode; + } + + this._nativeNode = getNativeNode(this._proxy); + + // If the initially used nativeNode was not constructed on the same OfflineAudioContext it needs to be created again. + if (!isOwnedByContext(this._nativeNode, offlineAudioContext)) { + const detuneAudioParam = ( this._nativeNode.detune); + const frequencyAudioParam = ( this._nativeNode.frequency); + + this._nativeNode = createNativeOscillatorNode(offlineAudioContext); + + await renderAutomation(offlineAudioContext, detuneAudioParam, this._nativeNode.detune); + await renderAutomation(offlineAudioContext, frequencyAudioParam, this._nativeNode.frequency); + } else { + const detuneNativeAudioParam = AUDIO_PARAM_STORE.get(this._proxy.detune); + const frequencyNativeAudioParam = AUDIO_PARAM_STORE.get(this._proxy.frequency); + + await connectAudioParam(offlineAudioContext, ( this._nativeNode.detune), detuneNativeAudioParam); + await connectAudioParam(offlineAudioContext, ( this._nativeNode.frequency), frequencyNativeAudioParam); + } + + await this._connectSources(offlineAudioContext, this._nativeNode); + + return this._nativeNode; + } + +} diff --git a/test/unit/audio-contexts/audio-context.js b/test/unit/audio-contexts/audio-context.js index 22b46f83d..756db6db6 100644 --- a/test/unit/audio-contexts/audio-context.js +++ b/test/unit/audio-contexts/audio-context.js @@ -823,177 +823,8 @@ describe('AudioContext', () => { describe('createOscillator()', () => { - it('should return an instance of the OscillatorNode interface', () => { - const oscillatorNode = audioContext.createOscillator(); - - expect(oscillatorNode.channelCount).to.equal(2); - expect(oscillatorNode.channelCountMode).to.equal('max'); - expect(oscillatorNode.channelInterpretation).to.equal('speakers'); - - expect(oscillatorNode.detune.cancelScheduledValues).to.be.a('function'); - expect(oscillatorNode.detune.defaultValue).to.equal(0); - expect(oscillatorNode.detune.exponentialRampToValueAtTime).to.be.a('function'); - expect(oscillatorNode.detune.linearRampToValueAtTime).to.be.a('function'); - expect(oscillatorNode.detune.setTargetAtTime).to.be.a('function'); - expect(oscillatorNode.detune.setValueCurveAtTime).to.be.a('function'); - expect(oscillatorNode.detune.value).to.equal(0); - - expect(oscillatorNode.frequency.cancelScheduledValues).to.be.a('function'); - expect(oscillatorNode.frequency.defaultValue).to.equal(440); - expect(oscillatorNode.frequency.exponentialRampToValueAtTime).to.be.a('function'); - expect(oscillatorNode.frequency.linearRampToValueAtTime).to.be.a('function'); - expect(oscillatorNode.frequency.setTargetAtTime).to.be.a('function'); - expect(oscillatorNode.frequency.setValueCurveAtTime).to.be.a('function'); - expect(oscillatorNode.frequency.value).to.equal(440); - - expect(oscillatorNode.numberOfInputs).to.equal(0); - expect(oscillatorNode.numberOfOutputs).to.equal(1); - expect(oscillatorNode.type).to.equal('sine'); - expect(oscillatorNode.setPeriodicWave).to.be.a('function'); - expect(oscillatorNode.start).to.be.a('function'); - expect(oscillatorNode.stop).to.be.a('function'); - }); - - it('should throw an error if the AudioContext is closed', (done) => { - audioContext - .close() - .then(() => { - audioContext.createOscillator(); - }) - .catch((err) => { - expect(err.code).to.equal(11); - expect(err.name).to.equal('InvalidStateError'); - - audioContext = new AudioContext(); - - done(); - }); - }); - - it('should be chainable', () => { - const gainNode = audioContext.createGain(); - const oscillatorNode = audioContext.createOscillator(); - - expect(oscillatorNode.connect(gainNode)).to.equal(gainNode); - }); - - it('should not be connectable to a node of another AudioContext', (done) => { - const anotherAudioContext = new AudioContext(); - const oscillatorNode = audioContext.createOscillator(); - - try { - oscillatorNode.connect(anotherAudioContext.destination); - } catch (err) { - expect(err.code).to.equal(15); - expect(err.name).to.equal('InvalidAccessError'); - - done(); - } finally { - anotherAudioContext.close(); - } - }); - - describe('onended', () => { - - it('should fire an assigned ended event listener', (done) => { - const gainNode = audioContext.createGain(); - const oscillatorNode = audioContext.createOscillator(); - - oscillatorNode.onended = (event) => { - expect(event).to.be.an.instanceOf(Event); - expect(event.type).to.equal('ended'); - - done(); - }; - - gainNode.gain.value = 0; - - oscillatorNode - .connect(gainNode) - .connect(audioContext.destination); - - oscillatorNode.start(); - oscillatorNode.stop(audioContext.currentTime + 0.2); - }); - - }); - - describe('type', () => { - - it("should be assignable to another type but 'custom'", () => { - const oscillatorNode = audioContext.createOscillator(); - - oscillatorNode.type = 'square'; - - expect(oscillatorNode.type).to.equal('square'); - }); - - it("should not be assignable to the type 'custom'", (done) => { - const oscillatorNode = audioContext.createOscillator(); - - try { - oscillatorNode.type = 'custom'; - } catch (err) { - expect(err.code).to.equal(11); - expect(err.name).to.equal('InvalidStateError'); - - done(); - } - }); - - }); - - describe('addEventListener()', () => { - - it('should fire a registered ended event listener', (done) => { - const gainNode = audioContext.createGain(); - const oscillatorNode = audioContext.createOscillator(); - - gainNode.gain.value = 0; - - oscillatorNode.addEventListener('ended', (event) => { - expect(event).to.be.an.instanceOf(Event); - expect(event.type).to.equal('ended'); - - done(); - }); - - oscillatorNode - .connect(gainNode) - .connect(audioContext.destination); - - oscillatorNode.start(); - oscillatorNode.stop(audioContext.currentTime + 0.2); - }); - - }); - - describe('removeEventListener()', () => { - - it('should not fire a removed ended event listener', (done) => { - const gainNode = audioContext.createGain(); - const oscillatorNode = audioContext.createOscillator(); - const listener = spy(); - - gainNode.gain.value = 0; - - oscillatorNode.addEventListener('ended', listener); - oscillatorNode.removeEventListener('ended', listener); - - oscillatorNode - .connect(gainNode) - .connect(audioContext.destination); - - oscillatorNode.start(); - oscillatorNode.stop(audioContext.currentTime + 0.2); - - setTimeout(() => { - expect(listener).to.have.not.been.called; - - done(); - }, 500); - }); - + it('should be a function', () => { + expect(audioContext.createOscillator).to.be.a('function'); }); }); diff --git a/test/unit/audio-contexts/offline-audio-context.js b/test/unit/audio-contexts/offline-audio-context.js index a460459f2..84ca58ff7 100644 --- a/test/unit/audio-contexts/offline-audio-context.js +++ b/test/unit/audio-contexts/offline-audio-context.js @@ -562,6 +562,20 @@ describe('OfflineAudioContext', () => { }); + describe('createOscillator()', () => { + + let offlineAudioContext; + + beforeEach(() => { + offlineAudioContext = new OfflineAudioContext({ length: 1, sampleRate: 44100 }); + }); + + it('should be a function', () => { + expect(offlineAudioContext.createOscillator).to.be.a('function'); + }); + + }); + describe('createGain()', () => { let offlineAudioContext; diff --git a/test/unit/audio-nodes/biquad-filter-node.js b/test/unit/audio-nodes/biquad-filter-node.js index 18438eee6..6a9e8941d 100644 --- a/test/unit/audio-nodes/biquad-filter-node.js +++ b/test/unit/audio-nodes/biquad-filter-node.js @@ -418,7 +418,26 @@ describe('BiquadFilterNode', () => { describe('type', () => { - // @todo + let biquadFilterNode; + + beforeEach(() => { + biquadFilterNode = createBiquadFilterNode(context); + }); + + it('should be assignable to another type', () => { + const type = biquadFilterNode.type = 'allpass'; // eslint-disable-line no-multi-assign + + expect(type).to.equal('allpass'); + expect(biquadFilterNode.type).to.equal('allpass'); + }); + + it('should not be assignable to something else', () => { + const string = 'none of the accepted types'; + const type = biquadFilterNode.type = string; // eslint-disable-line no-multi-assign + + expect(type).to.equal(string); + expect(biquadFilterNode.type).to.equal('lowpass'); + }); }); diff --git a/test/unit/audio-nodes/oscillator-node.js b/test/unit/audio-nodes/oscillator-node.js new file mode 100644 index 000000000..5cb437877 --- /dev/null +++ b/test/unit/audio-nodes/oscillator-node.js @@ -0,0 +1,836 @@ +import { AudioContext } from '../../../src/audio-contexts/audio-context'; +import { GainNode } from '../../../src/audio-nodes/gain-node'; +import { MinimalAudioContext } from '../../../src/audio-contexts/minimal-audio-context'; +import { MinimalOfflineAudioContext } from '../../../src/audio-contexts/minimal-offline-audio-context'; +import { OfflineAudioContext } from '../../../src/audio-contexts/offline-audio-context'; +import { OscillatorNode } from '../../../src/audio-nodes/oscillator-node'; +import { createRenderer } from '../../helper/create-renderer'; +import { spy } from 'sinon'; + +describe('OscillatorNode', () => { + + // @todo leche seems to need a unique string as identifier as first argument. + leche.withData([ + [ + 'constructor with AudioContext', + () => new AudioContext(), + (context, options = null) => { + if (options === null) { + return new OscillatorNode(context); + } + + return new OscillatorNode(context, options); + } + ], [ + 'constructor with MinimalAudioContext', + () => new MinimalAudioContext(), + (context, options = null) => { + if (options === null) { + return new OscillatorNode(context); + } + + return new OscillatorNode(context, options); + } + ], [ + 'constructor with OfflineAudioContext', + () => new OfflineAudioContext({ length: 5, sampleRate: 44100 }), + (context, options = null) => { + if (options === null) { + return new OscillatorNode(context); + } + + return new OscillatorNode(context, options); + } + ], [ + 'constructor with MinimalOfflineAudioContext', + () => new MinimalOfflineAudioContext({ length: 5, sampleRate: 44100 }), + (context, options = null) => { + if (options === null) { + return new OscillatorNode(context); + } + + return new OscillatorNode(context, options); + } + ], [ + 'factory function of AudioContext', + () => new AudioContext(), + (context, options = null) => { + const oscillatorNode = context.createOscillator(); + + if (options !== null && options.channelCount !== undefined) { + oscillatorNode.channelCount = options.channelCount; + } + + if (options !== null && options.channelCountMode !== undefined) { + oscillatorNode.channelCountMode = options.channelCountMode; + } + + if (options !== null && options.channelInterpretation !== undefined) { + oscillatorNode.channelInterpretation = options.channelInterpretation; + } + + if (options !== null && options.detune !== undefined) { + oscillatorNode.detune.value = options.detune; + } + + if (options !== null && options.frequency !== undefined) { + oscillatorNode.frequency.value = options.frequency; + } + + if (options !== null && options.type !== undefined) { + oscillatorNode.type = options.type; + } + + return oscillatorNode; + } + ], [ + 'factory function of OfflineAudioContext', + () => new OfflineAudioContext({ length: 5, sampleRate: 44100 }), + (context, options = null) => { + const oscillatorNode = context.createOscillator(); + + if (options !== null && options.channelCount !== undefined) { + oscillatorNode.channelCount = options.channelCount; + } + + if (options !== null && options.channelCountMode !== undefined) { + oscillatorNode.channelCountMode = options.channelCountMode; + } + + if (options !== null && options.channelInterpretation !== undefined) { + oscillatorNode.channelInterpretation = options.channelInterpretation; + } + + if (options !== null && options.detune !== undefined) { + oscillatorNode.detune.value = options.detune; + } + + if (options !== null && options.frequency !== undefined) { + oscillatorNode.frequency.value = options.frequency; + } + + if (options !== null && options.type !== undefined) { + oscillatorNode.type = options.type; + } + + return oscillatorNode; + } + ] + ], (_, createContext, createOscillatorNode) => { + + let context; + + afterEach(() => { + if (context.close !== undefined) { + return context.close(); + } + }); + + beforeEach(() => context = createContext()); + + describe('constructor()', () => { + + describe('without any options', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should be an instance of the EventTarget interface', () => { + expect(oscillatorNode.addEventListener).to.be.a('function'); + expect(oscillatorNode.dispatchEvent).to.be.a('function'); + expect(oscillatorNode.removeEventListener).to.be.a('function'); + }); + + it('should be an instance of the AudioNode interface', () => { + expect(oscillatorNode.channelCount).to.equal(2); + expect(oscillatorNode.channelCountMode).to.equal('max'); + expect(oscillatorNode.channelInterpretation).to.equal('speakers'); + expect(oscillatorNode.connect).to.be.a('function'); + expect(oscillatorNode.context).to.be.an.instanceOf(context.constructor); + expect(oscillatorNode.disconnect).to.be.a('function'); + expect(oscillatorNode.numberOfInputs).to.equal(0); + expect(oscillatorNode.numberOfOutputs).to.equal(1); + }); + + it('should return an instance of the AudioScheduledSourceNode interface', () => { + expect(oscillatorNode.onended).to.be.null; + expect(oscillatorNode.start).to.be.a('function'); + expect(oscillatorNode.stop).to.be.a('function'); + }); + + it('should return an instance of the ConstantSourceNode interface', () => { + expect(oscillatorNode.detune).not.to.be.undefined; + expect(oscillatorNode.frequency).not.to.be.undefined; + expect(oscillatorNode.setPeriodicWave).to.be.a('function'); + expect(oscillatorNode.type).to.equal('sine'); + }); + + it('should throw an error if the AudioContext is closed', (done) => { + ((context.close === undefined) ? context.startRendering() : context.close()) + .then(() => createOscillatorNode(context)) + .catch((err) => { + expect(err.code).to.equal(11); + expect(err.name).to.equal('InvalidStateError'); + + context.close = undefined; + + done(); + }); + }); + + }); + + describe('with valid options', () => { + + it('should return an instance with the given channelCount', () => { + const channelCount = 4; + const oscillatorNode = createOscillatorNode(context, { channelCount }); + + expect(oscillatorNode.channelCount).to.equal(channelCount); + }); + + it('should return an instance with the given channelCountMode', () => { + const channelCountMode = 'explicit'; + const oscillatorNode = createOscillatorNode(context, { channelCountMode }); + + expect(oscillatorNode.channelCountMode).to.equal(channelCountMode); + }); + + it('should return an instance with the given channelInterpretation', () => { + const channelInterpretation = 'discrete'; + const oscillatorNode = createOscillatorNode(context, { channelInterpretation }); + + expect(oscillatorNode.channelInterpretation).to.equal(channelInterpretation); + }); + + it('should return an instance with the given initial value for detune', () => { + const detune = 0.5; + const oscillatorNode = createOscillatorNode(context, { detune }); + + expect(oscillatorNode.detune.value).to.equal(detune); + }); + + it('should return an instance with the given initial value for frequency', () => { + const frequency = 500; + const oscillatorNode = createOscillatorNode(context, { frequency }); + + expect(oscillatorNode.frequency.value).to.equal(frequency); + }); + + it('should return an instance with the given periodicWave', () => { + + // @todo + + }); + + it('should return an instance with the given type', () => { + const type = 'triangle'; + const oscillatorNode = createOscillatorNode(context, { type }); + + expect(oscillatorNode.type).to.equal(type); + }); + + }); + + }); + + describe('channelCount', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should be assignable to another value', () => { + const channelCount = 4; + + oscillatorNode.channelCount = channelCount; + + expect(oscillatorNode.channelCount).to.equal(channelCount); + }); + + }); + + describe('channelCountMode', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should be assignable to another value', () => { + const channelCountMode = 'explicit'; + + oscillatorNode.channelCountMode = channelCountMode; + + expect(oscillatorNode.channelCountMode).to.equal(channelCountMode); + }); + + }); + + describe('channelInterpretation', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should be assignable to another value', () => { + const channelInterpretation = 'discrete'; + + oscillatorNode.channelInterpretation = channelInterpretation; + + expect(oscillatorNode.channelInterpretation).to.equal(channelInterpretation); + }); + + }); + + describe('detune', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should return an instance of the AudioParam interface', () => { + // @todo cancelAndHoldAtTime + expect(oscillatorNode.detune.cancelScheduledValues).to.be.a('function'); + expect(oscillatorNode.detune.defaultValue).to.equal(0); + expect(oscillatorNode.detune.exponentialRampToValueAtTime).to.be.a('function'); + expect(oscillatorNode.detune.linearRampToValueAtTime).to.be.a('function'); + /* + * @todo maxValue + * @todo minValue + */ + expect(oscillatorNode.detune.setTargetAtTime).to.be.a('function'); + // @todo setValueAtTime + expect(oscillatorNode.detune.setValueCurveAtTime).to.be.a('function'); + expect(oscillatorNode.detune.value).to.equal(0); + }); + + it('should be readonly', () => { + expect(() => { + oscillatorNode.detune = 'anything'; + }).to.throw(TypeError); + }); + + // @todo automation + + }); + + describe('frequency', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should return an instance of the AudioParam interface', () => { + // @todo cancelAndHoldAtTime + expect(oscillatorNode.frequency.cancelScheduledValues).to.be.a('function'); + expect(oscillatorNode.frequency.defaultValue).to.equal(440); + expect(oscillatorNode.frequency.exponentialRampToValueAtTime).to.be.a('function'); + expect(oscillatorNode.frequency.linearRampToValueAtTime).to.be.a('function'); + /* + * @todo maxValue + * @todo minValue + */ + expect(oscillatorNode.frequency.setTargetAtTime).to.be.a('function'); + // @todo setValueAtTime + expect(oscillatorNode.frequency.setValueCurveAtTime).to.be.a('function'); + expect(oscillatorNode.frequency.value).to.equal(440); + }); + + it('should be readonly', () => { + expect(() => { + oscillatorNode.frequency = 'anything'; + }).to.throw(TypeError); + }); + + // @todo automation + + }); + + describe('onended', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should fire an assigned ended event listener', (done) => { + oscillatorNode.onended = (event) => { + expect(event).to.be.an.instanceOf(Event); + expect(event.type).to.equal('ended'); + + done(); + }; + + oscillatorNode.connect(context.destination); + + oscillatorNode.start(); + oscillatorNode.stop(); + + if (context.startRendering !== undefined) { + context.startRendering(); + } + }); + + }); + + describe('type', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it("should be assignable to another type but 'custom'", () => { + const type = oscillatorNode.type = 'square'; // eslint-disable-line no-multi-assign + + expect(type).to.equal('square'); + expect(oscillatorNode.type).to.equal('square'); + }); + + it("should not be assignable to the type 'custom'", (done) => { + try { + oscillatorNode.type = 'custom'; + } catch (err) { + expect(err.code).to.equal(11); + expect(err.name).to.equal('InvalidStateError'); + + done(); + } + }); + + it('should not be assignable to something else', () => { + const string = 'none of the accepted types'; + const type = oscillatorNode.type = string; // eslint-disable-line no-multi-assign + + expect(type).to.equal(string); + expect(oscillatorNode.type).to.equal('sine'); + }); + + }); + + describe('addEventListener()', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should fire a registered ended event listener', (done) => { + oscillatorNode.addEventListener('ended', (event) => { + expect(event).to.be.an.instanceOf(Event); + expect(event.type).to.equal('ended'); + + done(); + }); + + oscillatorNode.connect(context.destination); + + oscillatorNode.start(); + oscillatorNode.stop(); + + if (context.startRendering !== undefined) { + context.startRendering(); + } + }); + + }); + + describe('connect()', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should be chainable', () => { + const gainNode = new GainNode(context); + + expect(oscillatorNode.connect(gainNode)).to.equal(gainNode); + }); + + it('should not be connectable to an AudioNode of another AudioContext', (done) => { + const anotherContext = createContext(); + + try { + oscillatorNode.connect(anotherContext.destination); + } catch (err) { + expect(err.code).to.equal(15); + expect(err.name).to.equal('InvalidAccessError'); + + done(); + } finally { + if (anotherContext.close !== undefined) { + anotherContext.close(); + } + } + }); + + it('should not be connectable to an AudioParam of another AudioContext', (done) => { + const anotherContext = createContext(); + const gainNode = new GainNode(anotherContext); + + try { + oscillatorNode.connect(gainNode.gain); + } catch (err) { + expect(err.code).to.equal(15); + expect(err.name).to.equal('InvalidAccessError'); + + done(); + } finally { + if (anotherContext.close !== undefined) { + anotherContext.close(); + } + } + }); + + it('should throw an IndexSizeError if the output is out-of-bound', (done) => { + const gainNode = new GainNode(context); + + try { + oscillatorNode.connect(gainNode.gain, -1); + } catch (err) { + expect(err.code).to.equal(1); + expect(err.name).to.equal('IndexSizeError'); + + done(); + } + }); + + }); + + describe('disconnect()', () => { + + let renderer; + + beforeEach(() => { + renderer = createRenderer({ + context, + length: (context.length === undefined) ? 5 : undefined, + prepare (destination) { + const firstDummyGainNode = new GainNode(context); + const oscillatorNode = createOscillatorNode(context, { frequency: 11025 }); + const secondDummyGainNode = new GainNode(context); + + oscillatorNode + .connect(firstDummyGainNode) + .connect(destination); + + oscillatorNode.connect(secondDummyGainNode); + + return { firstDummyGainNode, oscillatorNode, secondDummyGainNode }; + } + }); + }); + + it('should be possible to disconnect a destination', function () { + this.timeout(5000); + + return renderer({ + prepare ({ firstDummyGainNode, oscillatorNode }) { + oscillatorNode.disconnect(firstDummyGainNode); + }, + start (startTime, { oscillatorNode }) { + oscillatorNode.start(startTime); + } + }) + .then((channelData) => { + expect(Array.from(channelData)).to.deep.equal([ 0, 0, 0, 0, 0 ]); + }); + }); + + it('should be possible to disconnect another destination in isolation', function () { + this.timeout(5000); + + return renderer({ + prepare ({ oscillatorNode, secondDummyGainNode }) { + oscillatorNode.disconnect(secondDummyGainNode); + }, + start (startTime, { oscillatorNode }) { + oscillatorNode.start(startTime); + } + }) + .then((channelData) => { + expect(channelData[0]).to.equal(0); + expect(channelData[1]).to.equal(1); + expect(channelData[2]).to.be.closeTo(0, 0.000001); + expect(channelData[3]).to.equal(-1); + expect(channelData[4]).to.be.closeTo(0, 0.000001); + }); + }); + + it('should be possible to disconnect all destinations', function () { + this.timeout(5000); + + return renderer({ + prepare ({ oscillatorNode }) { + oscillatorNode.disconnect(); + }, + start (startTime, { oscillatorNode }) { + oscillatorNode.start(startTime); + } + }) + .then((channelData) => { + expect(Array.from(channelData)).to.deep.equal([ 0, 0, 0, 0, 0 ]); + }); + }); + + }); + + describe('removeEventListener()', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should not fire a removed ended event listener', (done) => { + const listener = spy(); + + oscillatorNode.addEventListener('ended', listener); + oscillatorNode.removeEventListener('ended', listener); + + oscillatorNode.connect(context.destination); + + oscillatorNode.start(); + oscillatorNode.stop(); + + setTimeout(() => { + expect(listener).to.have.not.been.called; + + done(); + }, 500); + + if (context.startRendering !== undefined) { + context.startRendering(); + } + }); + + }); + + describe('setPeriodicWave()', () => { + + // @todo + + }); + + describe('start()', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + describe('with a previous call to start()', () => { + + beforeEach(() => { + oscillatorNode.start(); + }); + + it('should throw an InvalidStateError', (done) => { + try { + oscillatorNode.start(); + } catch (err) { + expect(err.code).to.equal(11); + expect(err.name).to.equal('InvalidStateError'); + + done(); + } + }); + + }); + + describe('with a previous call to stop()', () => { + + beforeEach(() => { + oscillatorNode.start(); + oscillatorNode.stop(); + }); + + it('should throw an InvalidStateError', (done) => { + try { + oscillatorNode.start(); + } catch (err) { + expect(err.code).to.equal(11); + expect(err.name).to.equal('InvalidStateError'); + + done(); + } + }); + + }); + + describe('with a negative value as first parameter', () => { + + it('should throw an RangeError', () => { + expect(() => { + oscillatorNode.start(-1); + }).to.throw(RangeError); + }); + + }); + + }); + + describe('stop()', () => { + + describe('without a previous call to start()', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + }); + + it('should throw an InvalidStateError', (done) => { + try { + oscillatorNode.stop(); + } catch (err) { + expect(err.code).to.equal(11); + expect(err.name).to.equal('InvalidStateError'); + + done(); + } + }); + + }); + + describe('with a previous call to stop()', () => { + + let renderer; + + beforeEach(() => { + renderer = createRenderer({ + context, + length: (context.length === undefined) ? 5 : undefined, + prepare (destination) { + const oscillatorNode = createOscillatorNode(context, { frequency: 11025 }); + + oscillatorNode.connect(destination); + + return { oscillatorNode }; + } + }); + }); + + it('should apply the values from the last invocation', function () { + this.timeout(5000); + + return renderer({ + start (startTime, { oscillatorNode }) { + oscillatorNode.start(startTime); + oscillatorNode.stop(startTime + (5 / context.sampleRate)); + oscillatorNode.stop(startTime + (3 / context.sampleRate)); + } + }) + .then((channelData) => { + expect(channelData[0]).to.equal(0); + expect(channelData[1]).to.equal(1); + expect(channelData[2]).to.be.closeTo(0, 0.000001); + expect(channelData[3]).to.equal(0); + expect(channelData[4]).to.equal(0); + }); + }); + + }); + + describe('with a stop time reached prior to the start time', () => { + + let renderer; + + beforeEach(() => { + renderer = createRenderer({ + context, + length: (context.length === undefined) ? 5 : undefined, + prepare (destination) { + const oscillatorNode = createOscillatorNode(context); + + oscillatorNode.connect(destination); + + return { oscillatorNode }; + } + }); + }); + + it('should not play anything', function () { + this.timeout(5000); + + return renderer({ + start (startTime, { oscillatorNode }) { + oscillatorNode.start(startTime + (3 / context.sampleRate)); + oscillatorNode.stop(startTime + (1 / context.sampleRate)); + } + }) + .then((channelData) => { + expect(Array.from(channelData)).to.deep.equal([ 0, 0, 0, 0, 0 ]); + }); + }); + + }); + + describe('with an emitted ended event', () => { + + let oscillatorNode; + + beforeEach((done) => { + oscillatorNode = createOscillatorNode(context); + + oscillatorNode.onended = () => done(); + + oscillatorNode.connect(context.destination); + + oscillatorNode.start(); + oscillatorNode.stop(); + + if (context.startRendering !== undefined) { + context.startRendering(); + } + }); + + it('should ignore calls to stop()', () => { + oscillatorNode.stop(); + }); + + }); + + describe('with a negative value as first parameter', () => { + + let oscillatorNode; + + beforeEach(() => { + oscillatorNode = createOscillatorNode(context); + + oscillatorNode.start(); + }); + + it('should throw an RangeError', () => { + expect(() => { + oscillatorNode.stop(-1); + }).to.throw(RangeError); + }); + + }); + + }); + + }); + +});