@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.
271 lines (232 loc) • 8.05 kB
JavaScript
import { expect } from '@bundled-es-modules/chai';
import sinon from 'sinon';
import TrackScheduler from '../../../src/track/TrackScheduler.js';
import Track from '../../../src/track/Track.js';
import Stack from '../../../src/track/Stack.js';
import Controller from '../../../src/core/AudioController.js';
import Timeframe from '../../../src/core/Timeframe.js';
describe('TrackScheduler', () => {
let track;
let stack;
let scheduler;
let controller;
let timeframe;
beforeEach(() => {
controller = new Controller();
track = new Track({ controller });
stack = new Stack();
scheduler = new TrackScheduler(track, stack);
timeframe = new Timeframe({
anchor: 0,
realEnd: 100,
currentTime: 0,
playDuration: 100,
offset: 0,
});
});
afterEach(() => {
scheduler.reset();
});
describe('#reset', () => {
it('disconnects stack and clears state', () => {
const disconnectSpy = sinon.spy(stack, 'disconnectAll');
scheduler.reset();
expect(disconnectSpy.calledOnce).to.be.true;
});
});
describe('#getNextSegments', () => {
it('returns segments within the lookahead window', () => {
const segment1 = { start: 0, duration: 4, end: 4, $inTransit: false, isReady: false };
const segment2 = {
start: 4,
duration: 4,
end: 8,
$inTransit: false,
isReady: false,
prev: segment1,
};
const segment3 = {
start: 8,
duration: 4,
end: 12,
$inTransit: false,
isReady: false,
prev: segment2,
};
segment1.next = segment2;
segment2.next = segment3;
const segments = scheduler.getNextSegments(timeframe, segment1);
// Should include segment1, segment2, segment3 since they fit in 10s lookahead
expect(segments).to.include(segment1);
expect(segments).to.include(segment2);
expect(segments).to.include(segment3);
expect(segments.length).to.equal(3);
});
it('ignores segments outside of timeframe window', () => {
timeframe = new Timeframe({
anchor: 0,
realEnd: 2,
currentTime: 0,
playDuration: 2,
offset: 0,
});
const segment1 = { start: 0, duration: 5, end: 5, $inTransit: false, isReady: false };
const segment2 = {
start: 5,
duration: 5,
end: 10,
$inTransit: false,
isReady: false,
prev: segment1,
};
segment1.next = segment2;
const segments = scheduler.getNextSegments(timeframe, segment1);
// segment2 is outside the timeframe end (2)
expect(segments).to.include(segment1);
expect(segments).to.not.include(segment2);
});
it('ignores in-transit or ready segments', () => {
const segment1 = { start: 0, duration: 4, end: 4, $inTransit: true, isReady: false };
const segment2 = {
start: 4,
duration: 4,
end: 8,
$inTransit: false,
isReady: true,
prev: segment1,
};
const segment3 = {
start: 8,
duration: 4,
end: 12,
$inTransit: false,
isReady: false,
prev: segment2,
};
segment1.next = segment2;
segment2.next = segment3;
const segments = scheduler.getNextSegments(timeframe, segment1);
expect(segments).to.not.include(segment1); // in transit
expect(segments).to.not.include(segment2); // is ready
expect(segments).to.include(segment3);
expect(segments.length).to.equal(1);
});
});
describe('#evictOldCaches', () => {
it('unloads caches perfectly out of immediate playback scope', () => {
const segmentMock = (i) => ({
id: i,
isLoaded: true,
isReady: false,
unloadCache: sinon.spy(),
});
const sPrev5 = segmentMock(-5);
const sPrev4 = segmentMock(-4);
const sPrev3 = segmentMock(-3);
const sPrev2 = segmentMock(-2);
const sPrev1 = segmentMock(-1);
const sCurr = segmentMock(0);
const sNext1 = segmentMock(1);
const sNext2 = segmentMock(2);
const sNext3 = segmentMock(3);
const sNext4 = segmentMock(4);
const sNext5 = segmentMock(5);
sPrev5.next = sPrev4;
sPrev4.prev = sPrev5;
sPrev4.next = sPrev3;
sPrev3.prev = sPrev4;
sPrev3.next = sPrev2;
sPrev2.prev = sPrev3;
sPrev2.next = sPrev1;
sPrev1.prev = sPrev2;
sPrev1.next = sCurr;
sCurr.prev = sPrev1;
sCurr.next = sNext1;
sNext1.prev = sCurr;
sNext1.next = sNext2;
sNext2.prev = sNext1;
sNext2.next = sNext3;
sNext3.prev = sNext2;
sNext3.next = sNext4;
sNext4.prev = sNext3;
sNext4.next = sNext5;
sNext5.prev = sNext4;
scheduler.evictOldCaches(sCurr);
expect(sPrev4.unloadCache.calledOnce).to.be.true; // prev 4
expect(sNext4.unloadCache.calledOnce).to.be.true; // next 4
// Ensure others weren't touched
[sPrev5, sPrev3, sPrev2, sPrev1, sCurr, sNext1, sNext2, sNext3, sNext5].forEach((s) => {
expect(s.unloadCache.called).to.be.false;
});
});
});
describe('#scheduleAt', () => {
it('loads and connects segments, updates timings', async () => {
const loadPromise = Promise.resolve();
const segment = {
isLoaded: false,
duration: 10,
end: 15,
load: sinon.stub().returns({ promise: loadPromise }),
connect: sinon.stub().resolves(),
};
const calcOffsetSpy = sinon.stub(timeframe, 'calculateOffset').returns(2);
const calcStartSpy = sinon.stub(timeframe, 'calculateRealStart').returns(5);
const emitSpy = sinon.spy(track.controller, 'notify');
const recalcSpy = sinon.spy(stack, 'recalculateStartTimes');
await scheduler.scheduleAt(timeframe, segment);
expect(segment.$inTransit).to.be.false;
expect(segment.load.calledOnce).to.be.true;
expect(
segment.connect.calledWith({
ac: track.controller.ac,
destination: track.gainNode,
start: 5,
offset: 2,
stop: 100, // realEnd
}),
).to.be.true;
expect(recalcSpy.calledWith(segment)).to.be.true;
expect(emitSpy.calledWith('loading-start', track)).to.be.true;
expect(emitSpy.calledWith('loading-end', track)).to.be.true;
});
it('catches errors gracefully and fires error events', async () => {
const error = new Error('Connection failed');
const segment = {
isLoaded: true,
duration: 10,
end: 15,
connect: sinon.stub().rejects(error),
};
const emitSpy = sinon.spy(track.controller, 'notify');
await scheduler.scheduleAt(timeframe, segment);
expect(segment.$inTransit).to.be.false;
expect(emitSpy.calledWith('error', error)).to.be.true;
});
it('ignores AbortError errors silently', async () => {
const error = new Error('Aborted');
error.name = 'AbortError';
const segment = {
isLoaded: true,
duration: 10,
end: 15,
connect: sinon.stub().rejects(error),
};
const emitSpy = sinon.spy(track.controller, 'notify');
await scheduler.scheduleAt(timeframe, segment);
expect(segment.$inTransit).to.be.false;
expect(emitSpy.calledWith('error', error)).to.be.false;
});
});
describe('#runSchedulePass', () => {
it('sets $inTransit to true on next segments and calls scheduleAt', async () => {
const segment1 = { start: 0, duration: 5, end: 5, $inTransit: false, isReady: false };
sinon.stub(stack, 'getAt').returns(segment1);
sinon.stub(scheduler, 'getNextSegments').returns([segment1]);
const scheduleAtStub = sinon.stub(scheduler, 'scheduleAt').resolves();
await scheduler.runSchedulePass(timeframe);
expect(segment1.$inTransit).to.be.true;
expect(scheduleAtStub.calledOnceWith(timeframe, segment1)).to.be.true;
});
});
});