UNPKG

@firstcoders/hls-web-audio

Version:

`@firstcoders/hls-web-audio` plays multiple streamed audio tracks in sync using the Web Audio API. It is designed for stem-like playback where each track can be independently loaded, buffered, scheduled, and mixed.

231 lines (194 loc) 7.5 kB
import { expect } from '@bundled-es-modules/chai'; import sinon from 'sinon'; import PlaybackTimeline from '../../../src/core/PlaybackTimeline.js'; import Timeframe from '../../../src/core/Timeframe.js'; describe('PlaybackTimeline', () => { let controllerMock; let timeline; beforeEach(() => { controllerMock = { notifyUpdated: sinon.spy(), fireEvent: sinon.spy(), ac: { currentTime: 10, suspend: sinon.stub().resolves(), resume: sinon.stub().resolves(), }, engine: { isBuffering: false, }, trackGroup: { audioDuration: 100, }, desiredState: 'resumed', }; timeline = new PlaybackTimeline(controllerMock); }); describe('#constructor', () => { it('initializes with default offset and timeframe', () => { expect(timeline.offset).to.equal(0); expect(timeline.timeframe).to.be.instanceOf(Timeframe); }); }); describe('#anchor', () => { it('getting returns timeframe anchor', () => { timeline.timeframe.anchor = 50; expect(timeline.anchor).to.equal(50); }); it('setting updates timeframe anchor', () => { timeline.anchor = 20; expect(timeline.timeframe.anchor).to.equal(20); }); }); describe('#playDuration', () => { it('sets and notifies playDuration', () => { timeline.playDuration = 60; expect(timeline.playDuration).to.equal(60); expect(controllerMock.notifyUpdated.calledWith('playDuration', 60)).to.be.true; }); it('throws error if setting invalid playDuration', () => { expect(() => { timeline.playDuration = 'invalid'; }).to.throw('The property "playDuration" must be of type number'); }); it('returns audioDuration if playDuration is not set', () => { expect(timeline.playDuration).to.equal(100); }); }); describe('#offset', () => { it('sets and notifies offset', () => { timeline.offset = 25; expect(timeline.offset).to.equal(25); expect(controllerMock.notifyUpdated.calledWith('offset', 25)).to.be.true; }); it('throws error if setting invalid offset', () => { expect(() => { timeline.offset = 'invalid'; }).to.throw('The property "offset" must be of type number'); }); }); describe('#setRegion', () => { it('updates both offset and playDuration and notifies if they changed', () => { timeline.setRegion(10, 50); expect(timeline.offset).to.equal(10); expect(timeline.playDuration).to.equal(50); expect(controllerMock.notifyUpdated.calledWith('offset', 10)).to.be.true; expect(controllerMock.notifyUpdated.calledWith('playDuration', 50)).to.be.true; }); it('throws on invalid offset or playDuration types', () => { expect(() => timeline.setRegion('invalid', 50)).to.throw( 'The property "offset" must be of type number', ); expect(() => timeline.setRegion(10, 'invalid')).to.throw( 'The property "playDuration" must be of type number', ); }); it('does not notify if values are unchanged', () => { timeline.setRegion(0, 100); // 0 is default offset, 100 is default (playDuration based on audioDuration initially undefined) controllerMock.notifyUpdated.resetHistory(); timeline.setRegion(0, 100); expect(controllerMock.notifyUpdated.called).to.be.false; }); }); describe('#currentTime & rawCurrentTime', () => { it('returns undefined if anchor is undefined', () => { expect(timeline.rawCurrentTime).to.be.undefined; expect(timeline.currentTime).to.be.undefined; }); it('calculates based on ac.currentTime and anchor', () => { timeline.anchor = 5; // ac.currentTime is 10 expect(timeline.rawCurrentTime).to.equal(5); expect(timeline.currentTime).to.equal(5); }); }); describe('#setCurrentTime', () => { beforeEach(() => { timeline.playDuration = 50; // offset = 0, playDuration = 50, duration = 100 }); it('throws if t is out of bounds', () => { expect(() => timeline.setCurrentTime(-5)).to.throw(); expect(() => timeline.setCurrentTime(150)).to.throw(); }); it('constrains seeking outside the play region to the offset', () => { const fixStub = sinon.stub(timeline, 'fixAnchor'); timeline.setCurrentTime(60); // outside 0 to 50 expect(fixStub.calledWith(0)).to.be.true; }); it('fixes adjusted start within the play region', () => { const fixStub = sinon.stub(timeline, 'fixAnchor'); timeline.setCurrentTime(20); // within 0 to 50 bounds expect(fixStub.calledWith(20)).to.be.true; }); it('suspends and conditionally resumes the audio context', async () => { timeline.setCurrentTime(20); expect(controllerMock.ac.suspend.calledOnce).to.be.true; // simulate promise resolution await Promise.resolve(); expect(controllerMock.ac.resume.calledOnce).to.be.true; }); it('does not resume if buffered or state not resumed', async () => { controllerMock.desiredState = 'suspended'; // Not 'resumed' timeline.setCurrentTime(20); await Promise.resolve(); expect(controllerMock.ac.resume.called).to.be.false; }); }); describe('#fixAnchor', () => { it('adjusts start relative to context time and fires seek event', () => { controllerMock.ac.currentTime = 50; timeline.fixAnchor(10); // target track time expect(timeline.anchor).to.equal(40); expect( controllerMock.fireEvent.calledWith('seek', { t: 10, pct: 10 / 100, // duration 100 remaining: 90, }), ).to.be.true; }); }); describe('#pct & #remaining', () => { beforeEach(() => { timeline.anchor = 0; // currentTime becomes 10 (10 - 0) }); it('calculates the current pct based on current time and audioDuration', () => { expect(timeline.pct).to.equal(0.1); // 10 / 100 }); it('calculates the remaining time based on current time and audioDuration', () => { expect(timeline.remaining).to.equal(90); // 100 - 10 }); it('sets the current time as a fraction of audio duration', () => { const setCurrentTimeStub = sinon.stub(timeline, 'setCurrentTime'); timeline.pct = 0.5; expect(setCurrentTimeStub.calledWith(50)).to.be.true; }); it('constrains pct setting between 0 and 1', () => { const setCurrentTimeStub = sinon.stub(timeline, 'setCurrentTime'); timeline.pct = 1.5; expect(setCurrentTimeStub.calledWith(100)).to.be.true; timeline.pct = -0.5; expect(setCurrentTimeStub.calledWith(0)).to.be.true; }); }); describe('#realEnd', () => { it('returns the calculated end position based on bounds', () => { timeline.anchor = 10; timeline.offset = 5; timeline.playDuration = 40; expect(timeline.realEnd).to.equal(55); // 10 + 5 + 40 }); }); describe('#currentTimeframe', () => { it('updates and returns the internal timeframe', () => { timeline.anchor = 10; timeline.playDuration = 40; const frame = timeline.currentTimeframe; expect(frame).to.be.instanceOf(Timeframe); expect(frame.anchor).to.equal(10); expect(frame.offset).to.equal(0); expect(frame.playDuration).to.equal(40); expect(frame.currentTime).to.equal(0); // ac is 10, start is 10 expect(frame.realEnd).to.equal(50); }); }); });