UNPKG

@twilio/audioplayer

Version:

An HTMLAudioElement-like implementation that uses AudioContext to circumvent browser limitations.

566 lines (465 loc) 16.6 kB
/* tslint:disable:max-classes-per-file no-empty */ import * as assert from 'assert'; import * as sinon from 'sinon'; import { SinonSpy } from 'sinon'; import AudioPlayer, { IAudioPlayerOptions } from '../src/AudioPlayer'; import EventTarget from '../src/EventTarget'; // tslint:disable-next-line:only-arrow-functions describe('AudioPlayer', function() { let audioContext: MockContext; let audioPlayer: AudioPlayer; beforeEach(() => { XMLHttpRequestFactory.clear(); audioContext = new MockContext(); }); describe('without a SRC', () => { beforeEach(() => { AudioFactory.clear(); audioPlayer = new AudioPlayer(audioContext, { AudioFactory, XMLHttpRequestFactory, }); }); describe('.play()', () => { it('should set .paused to false synchronously', () => { audioPlayer.play(); assert.equal(audioPlayer.paused, false); }); it('should return a pending Promise for each play call until a src is set', () => { let isNextTick = false; const promise = Promise.all([ audioPlayer.play().then(() => { assert(isNextTick); }), audioPlayer.play().then(() => { assert(isNextTick); }), audioPlayer.play().then(() => { assert(isNextTick); }), ]); setTimeout(() => { isNextTick = true; audioPlayer.src = 'foo'; }); return promise; }); }); describe('.pause()', () => { it('should set .paused to true synchronously', () => { audioPlayer.pause(); assert.equal(audioPlayer.paused, true); }); it('should cause pending .play Promises to reject', (done) => { audioPlayer.play().catch(() => { done(); }); audioPlayer.pause(); }); context('when already paused', () => { it('should silently do nothing', () => { audioPlayer.pause(); assert.equal(audioPlayer.pause(), undefined); }); }); }); describe('.load()', () => { it('should not affect .src', () => { audioPlayer.src = 'test'; audioPlayer.load(); assert.equal(audioPlayer.src, 'test'); }); it('should not resolve pending play Promises', () => { const error = new Error('Promises resolved unexpectedly'); const promise = Promise.race([ audioPlayer.play().then(() => { throw error; }), audioPlayer.play().then(() => { throw error; }), audioPlayer.play().then(() => { throw error; }), new Promise(resolve => { setTimeout(resolve); }), ]); audioPlayer.load(); return promise; }); }); describe('setting .src', () => { it('should update .src synchronously', () => { audioPlayer.src = 'test'; assert.equal(audioPlayer.src, 'test'); }); it('should resolve any pending play Promises', () => { const promise = Promise.all([ audioPlayer.play(), audioPlayer.play(), audioPlayer.play(), ]); audioPlayer.src = 'foo'; return promise; }); }); describe('setting .loop', () => { it('should update .loop synchronously', () => { audioPlayer.loop = true; assert.equal(audioPlayer.loop, true); audioPlayer.loop = false; assert.equal(audioPlayer.loop, false); }); }); }); describe('with a SRC', () => { beforeEach(() => { AudioFactory.clear(); audioPlayer = new AudioPlayer(audioContext, 'foo', { AudioFactory, XMLHttpRequestFactory, }); audioContext.clear(); }); describe('.play()', () => { it('should set .paused to false synchronously', () => { audioPlayer.play(); assert.equal(audioPlayer.paused, false); }); it('should create a new audio node', () => { audioPlayer.play(); assert.equal(audioContext.audioNodes.length, 1); }); it('should set .loop on the new audio node', () => { audioPlayer.loop = true; audioPlayer.play(); assert.equal(audioContext.audioNodes[0].loop, true); }); it('should start the audio node', () => { return audioPlayer.play().then(() => { sinon.assert.calledOnce(audioContext.audioNodes[0].start as SinonSpy); }); }); it('should play the audio element if a sinkId is set', () => { audioPlayer.setSinkId('foo'); return audioPlayer.play().then(() => { assert.equal(AudioFactory.instances.length, 1); sinon.assert.calledOnce(AudioFactory.instances[0].play as SinonSpy); }); }); context('when already playing', () => { it('should not create another audio node', () => { audioPlayer.play(); return audioPlayer.play() .then(() => audioPlayer.play()) .then(() => { assert.equal(audioContext.audioNodes.length, 1); }); }); }); }); describe('.pause()', () => { it('should set .paused to true synchronously', () => { return audioPlayer.play().then(() => { assert.equal(audioPlayer.paused, false); audioPlayer.pause(); assert.equal(audioPlayer.paused, true); }); }); it('should cause pending .play Promises to reject', (done) => { audioPlayer.play().catch(() => { done(); }); audioPlayer.pause(); }); it('should stop the audioNode', () => { return audioPlayer.play().then(() => { audioPlayer.pause(); sinon.assert.calledOnce(audioContext.audioNodes[0].stop as SinonSpy); }); }); it('should pause the audio element if a sinkId is set', () => { return audioPlayer.play().then(() => { audioPlayer.pause(); sinon.assert.calledOnce(AudioFactory.instances[0].pause as SinonSpy); }); }); context('when already paused', () => { it('should silently do nothing', () => { audioPlayer.pause(); assert.equal(audioPlayer.pause(), undefined); }); }); }); describe('.load()', () => { it('should not change .src', () => { audioPlayer.src = 'bar'; audioPlayer.load(); assert.equal(audioPlayer.src, 'bar'); }); it('should not reject any pending play Promises', () => { const error = new Error('Promises rejected unexpectedly'); const promise = Promise.race([ audioPlayer.play().catch(() => { throw error; }), audioPlayer.play().catch(() => { throw error; }), audioPlayer.play().catch(() => { throw error; }), new Promise(resolve => { setTimeout(resolve); }), ]); audioPlayer.load(); return promise; }); it('should call .pause() if the sound is currently playing', () => { audioPlayer.pause = sinon.spy(audioPlayer.pause); audioPlayer.play().then(() => { audioPlayer.load(); sinon.assert.calledOnce(audioPlayer.pause as SinonSpy); }); }); }); describe('setting .src', () => { it('should update .src synchronously', () => { audioPlayer.src = 'bar'; assert.equal(audioPlayer.src, 'bar'); }); it('should reject any pending play Promises', (done) => { const error = new Error('Expected a reject, but got a resolve'); Promise.all([ audioPlayer.play().then(() => { throw error; }, () => { }), audioPlayer.play().then(() => { throw error; }, () => { }), audioPlayer.play().then(() => { throw error; }, () => { }), ]).then(() => done(), done); audioPlayer.src = 'bar'; }); it('should call .pause() if the sound is currently playing', () => { audioPlayer.pause = sinon.spy(audioPlayer.pause); audioPlayer.play().then(() => { audioPlayer.src = 'bar'; sinon.assert.calledOnce(audioPlayer.pause as SinonSpy); }); }); }); describe('setting .loop', () => { it('should update .loop synchronously', () => { audioPlayer.loop = true; assert.equal(audioPlayer.loop, true); audioPlayer.loop = false; assert.equal(audioPlayer.loop, false); }); context('when a sound is looping and loop is set to false', () => { it('should set a listener on the audio node that calls .pause() in response to the ended event', () => { audioPlayer.pause = sinon.spy(audioPlayer.pause); audioPlayer.loop = true; return audioPlayer.play().then(() => { audioPlayer.loop = false; sinon.assert.notCalled(audioPlayer.pause as SinonSpy); audioContext.audioNodes[0].dispatchEvent('ended'); sinon.assert.calledOnce(audioPlayer.pause as SinonSpy); }); }); it('should only pause after the initial ended event', () => { audioPlayer.pause = sinon.spy(audioPlayer.pause); audioPlayer.loop = true; return audioPlayer.play().then(() => { audioPlayer.loop = false; sinon.assert.notCalled(audioPlayer.pause as SinonSpy); audioContext.audioNodes[0].dispatchEvent('ended'); sinon.assert.calledOnce(audioPlayer.pause as SinonSpy); audioContext.audioNodes[0].dispatchEvent('ended'); sinon.assert.calledOnce(audioPlayer.pause as SinonSpy); audioContext.audioNodes[0].dispatchEvent('ended'); sinon.assert.calledOnce(audioPlayer.pause as SinonSpy); }); }); }); }); }); describe('.setSinkId()', () => { beforeEach(() => { AudioFactory.clear(); audioPlayer = new AudioPlayer(audioContext, 'foo', { AudioFactory, XMLHttpRequestFactory, }); audioContext.clear(); }); context('when setSinkId is not supported', () => { it('should return a rejected Promise', (done) => { audioPlayer = new AudioPlayer(audioContext, { AudioFactory: LegacyAudioFactory, XMLHttpRequestFactory, }); audioPlayer.setSinkId('foo').catch(() => done()); }); }); context('when .sinkId is default', () => { context('and setting to default', () => { it('should return a resolved Promise', () => { return audioPlayer.setSinkId('default'); }); }); context('and setting to a sinkId', () => { it('should call .setSinkId of the audio element', () => { return audioPlayer.setSinkId('foo').then(() => { sinon.assert.calledOnce((AudioFactory.instances[0] as AudioFactory).setSinkId as SinonSpy); }); }); it('should create a new MediaStreamDestination', () => { return audioPlayer.setSinkId('foo').then(() => { sinon.assert.calledOnce(audioContext.createMediaStreamDestination as SinonSpy); }); }); it('should set .destination to the created destination', () => { return audioPlayer.setSinkId('foo').then(() => { assert.equal(audioPlayer.destination, audioContext.createdDestination); }); }); it('should connect the gain node to the new destination if already playing', () => { return audioPlayer.play().then(() => { sinon.assert.calledOnce(audioContext.audioNodes[0].connect as SinonSpy); sinon.assert.calledOnce(audioContext.gainNodes[0].connect as SinonSpy); return audioPlayer.setSinkId('foo'); }).then(() => { sinon.assert.calledOnce(audioContext.audioNodes[0].connect as SinonSpy); sinon.assert.calledTwice(audioContext.gainNodes[0].connect as SinonSpy); }); }); it('should connect the gain node to the new destination if not already playing', () => { return audioPlayer.setSinkId('foo').then(() => { sinon.assert.calledTwice(audioContext.gainNodes[0].connect as SinonSpy); }); }); }); }); context('when .sinkId is not default', () => { beforeEach(() => { return audioPlayer.setSinkId('foo'); }); context('and setting to default', () => { it('should return a resolved Promise', () => { return audioPlayer.setSinkId('default'); }); it('should set .destination to context.destination', () => { return audioPlayer.setSinkId('default').then(() => { assert.equal(audioPlayer.destination, audioContext.destination); }); }); }); context('and setting to the same sinkId', () => { it('should not create a new MediaStreamDestination', () => { sinon.assert.calledOnce(audioContext.createMediaStreamDestination as SinonSpy); return audioPlayer.setSinkId('foo').then(() => { sinon.assert.calledOnce(audioContext.createMediaStreamDestination as SinonSpy); }); }); }); context('and setting to a new sinkId', () => { it('should call .setSinkId of the audio element', () => { sinon.assert.calledOnce((AudioFactory.instances[0] as AudioFactory).setSinkId as SinonSpy); return audioPlayer.setSinkId('bar').then(() => { sinon.assert.calledTwice((AudioFactory.instances[0] as AudioFactory).setSinkId as SinonSpy); }); }); it('should not create a new MediaStreamDestination', () => { sinon.assert.calledOnce(audioContext.createMediaStreamDestination as SinonSpy); return audioPlayer.setSinkId('foo').then(() => { sinon.assert.calledOnce(audioContext.createMediaStreamDestination as SinonSpy); }); }); }); }); }); describe('.muted', () => { beforeEach(() => { AudioFactory.clear(); audioPlayer = new AudioPlayer(audioContext, { AudioFactory, XMLHttpRequestFactory, }); }); it('should set gain to 1 when not muted', () => { audioPlayer.muted = false; assert.equal(audioContext.gainNodes[0].gain.value, 1); }); it('should set gain to 0 when muted', () => { audioPlayer.muted = true; assert.equal(audioContext.gainNodes[0].gain.value, 0); }); }); }); class MockAudioNode extends EventTarget { loop: boolean = false; srcObject?: any; constructor() { super(); this.connect = sinon.spy(this.connect); this.start = sinon.spy(this.start); this.stop = sinon.spy(this.stop); } connect() { } disconnect() { } start() { } stop() { } } class MockGainNode { gain: any = { value: 1, }; constructor() { this.connect = sinon.spy(this.connect); } connect() { } disconnect() { } } class MockContext { audioNodes: MockAudioNode[] = []; gainNodes: MockGainNode[] = []; destination: any = { stream: 'foo', }; createdDestination: any = { stream: 'bar', }; constructor() { this.createMediaStreamDestination = sinon.spy(this.createMediaStreamDestination); } clear() { this.audioNodes.splice(0, this.audioNodes.length); } createBufferSource() { const node = new MockAudioNode(); this.audioNodes.push(node); return node; } createGain() { const node = new MockGainNode(); this.gainNodes.push(node); return node; } createMediaStreamDestination() { return this.createdDestination; } decodeAudioData() { } } class LegacyAudioFactory { static instances: LegacyAudioFactory[] = []; static clear() { this.instances.splice(0, this.instances.length); } srcObject: any; constructor() { this.play = sinon.spy(this.play); this.pause = sinon.spy(this.pause); AudioFactory.instances.push(this); } pause() { } play() { } } class AudioFactory extends LegacyAudioFactory { constructor() { super(); this.setSinkId = sinon.spy(this.setSinkId); } setSinkId() { return Promise.resolve(); } } class XMLHttpRequestFactory extends EventTarget { static instances: XMLHttpRequestFactory[] = []; static clear() { this.instances.splice(0, this.instances.length); } responseType?: string; constructor() { super(); XMLHttpRequestFactory.instances.push(this); } open(method: string, url: string, async?: boolean): void { } send(): void { this.dispatchEvent('load', { target: { response: 'foo' }, }); } }