diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index eaf02a67735..29ba5fa54a7 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -14,6 +14,7 @@ import { alignMediaPlaylistByPDT, } from '../utils/discontinuities'; import { mediaAttributesIdentical } from '../utils/media-option-attributes'; +import { useAlternateAudio } from '../utils/rendition-helper'; import type { FragmentTracker } from './fragment-tracker'; import type Hls from '../hls'; import type { Fragment, MediaFragment, Part } from '../loader/fragment'; @@ -519,7 +520,7 @@ class AudioStreamController const cachedTrackLoadedData = this.cachedTrackLoadedData; if (cachedTrackLoadedData) { this.cachedTrackLoadedData = null; - this.hls.trigger(Events.AUDIO_TRACK_LOADED, cachedTrackLoadedData); + this.onAudioTrackLoaded(Events.AUDIO_TRACK_LOADED, cachedTrackLoadedData); } } @@ -528,12 +529,18 @@ class AudioStreamController data: TrackLoadedData, ) { const { levels } = this; - const { details: newDetails, id: trackId } = data; + const { details: newDetails, id: trackId, groupId, track } = data; + if (!levels) { + this.warn( + `Audio tracks reset while loading track ${trackId} "${track.name}" of "${groupId}"`, + ); + return; + } const mainDetails = this.mainDetails; if ( !mainDetails || - mainDetails.expired || - newDetails.endCC > mainDetails.endCC + newDetails.endCC > mainDetails.endCC || + mainDetails.expired ) { this.cachedTrackLoadedData = data; if (this.state !== State.STOPPED) { @@ -541,12 +548,9 @@ class AudioStreamController } return; } - if (!levels) { - this.warn(`Audio tracks were reset while loading level ${trackId}`); - return; - } + this.cachedTrackLoadedData = null; this.log( - `Audio track ${trackId} loaded [${newDetails.startSN},${ + `Audio track ${trackId} "${track.name}" of "${groupId}" loaded [${newDetails.startSN},${ newDetails.endSN }]${ newDetails.lastPartSn @@ -555,18 +559,18 @@ class AudioStreamController },duration:${newDetails.totalduration}`, ); - const track = levels[trackId]; + const trackLevel = levels[trackId]; let sliding = 0; - if (newDetails.live || track.details?.live) { + if (newDetails.live || trackLevel.details?.live) { this.checkLiveUpdate(newDetails); if (newDetails.deltaUpdateFailed) { return; } - if (track.details) { + if (trackLevel.details) { sliding = this.alignPlaylists( newDetails, - track.details, + trackLevel.details, this.levelLastLoaded?.details, ); } @@ -580,8 +584,8 @@ class AudioStreamController sliding = newDetails.fragmentStart; } } - track.details = newDetails; - this.levelLastLoaded = track; + trackLevel.details = newDetails; + this.levelLastLoaded = trackLevel; // compute start position if we are aligned with the main playlist if (!this.startFragRequested) { @@ -1026,9 +1030,14 @@ class AudioStreamController bufferedTrack.name !== switchingTrack.name || bufferedTrack.lang !== switchingTrack.lang) ) { - this.log('Switching audio track : flushing all audio'); - super.flushMainBuffer(0, Number.POSITIVE_INFINITY, 'audio'); - this.bufferedTrack = null; + if (useAlternateAudio(switchingTrack.url, this.hls)) { + this.log('Switching audio track : flushing all audio'); + super.flushMainBuffer(0, Number.POSITIVE_INFINITY, 'audio'); + this.bufferedTrack = null; + } else { + // Main is being buffered. Set bufferedTrack so that it is flushed when switching back to alt-audio + this.bufferedTrack = switchingTrack; + } } } diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 5157b15b098..1d64dd450e7 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -1308,7 +1308,9 @@ export default class BaseStreamController const mainStart = this.hls.startPosition; const liveSyncPosition = this.hls.liveSyncPosition; const startPosition = frag - ? (mainStart !== -1 ? mainStart : liveSyncPosition) || frag.start + ? (mainStart !== -1 && mainStart >= start + ? mainStart + : liveSyncPosition) || frag.start : pos; this.log( `Setting startPosition to ${startPosition} to match initial live edge. mainStart: ${mainStart} liveSyncPosition: ${liveSyncPosition} frag.start: ${frag?.start}`, diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 74cda3ba0b7..1d4af6453ee 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -882,12 +882,16 @@ export default class StreamController } // If switching from alt to main audio, flush all audio and trigger track switched if (fromAltAudio) { + this.fragmentTracker.removeAllFragments(); + hls.once(Events.BUFFER_FLUSHED, () => { + this.hls?.trigger(Events.AUDIO_TRACK_SWITCHED, data); + }); hls.trigger(Events.BUFFER_FLUSHING, { startOffset: 0, endOffset: Number.POSITIVE_INFINITY, type: null, }); - this.fragmentTracker.removeAllFragments(); + return; } hls.trigger(Events.AUDIO_TRACK_SWITCHED, data); } else { diff --git a/src/loader/level-details.ts b/src/loader/level-details.ts index 08781615ee6..ac4a4136570 100644 --- a/src/loader/level-details.ts +++ b/src/loader/level-details.ts @@ -179,7 +179,7 @@ export class LevelDetails { } get expired(): boolean { - if (this.live && this.age) { + if (this.live && this.age && this.misses < 3) { const playlistWindowDuration = this.partEnd - this.fragmentStart; return ( this.age > diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index 8a1ef95d1cf..2979bd54fc3 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -184,8 +184,9 @@ class PassThroughRemuxer implements Remuxer { const startDTS = getStartDTS(initData, data); const decodeTime = startDTS === null ? timeOffset : startDTS; if ( - isInvalidInitPts(initPTS, decodeTime, timeOffset, duration) || - (initSegment.timescale !== initPTS.timescale && accurateTimeOffset) + (accurateTimeOffset || !initPTS) && + (isInvalidInitPts(initPTS, decodeTime, timeOffset, duration) || + initSegment.timescale !== initPTS.timescale) ) { initSegment.initPTS = decodeTime - timeOffset; if (initPTS && initPTS.timescale === 1) { diff --git a/src/utils/level-helper.ts b/src/utils/level-helper.ts index 8dab67253c7..58801689b01 100644 --- a/src/utils/level-helper.ts +++ b/src/utils/level-helper.ts @@ -136,7 +136,10 @@ export function updateFragPTSDTS( export function mergeDetails( oldDetails: LevelDetails, newDetails: LevelDetails, -): void { +) { + if (oldDetails === newDetails) { + return; + } // Track the last initSegment processed. Initialize it to the last one on the timeline. let currentInitSegment: Fragment | null = null; const oldFragments = oldDetails.fragments; diff --git a/src/utils/rendition-helper.ts b/src/utils/rendition-helper.ts index ea3ec2ecebe..a58d8ffc4c1 100644 --- a/src/utils/rendition-helper.ts +++ b/src/utils/rendition-helper.ts @@ -502,6 +502,9 @@ function searchDownAndUpList( return -1; } -export function useAlternateAudio(audioTrackUrl: string, hls: Hls): boolean { +export function useAlternateAudio( + audioTrackUrl: string | undefined, + hls: Hls, +): boolean { return !!audioTrackUrl && audioTrackUrl !== hls.levels[hls.loadLevel]?.uri; } diff --git a/tests/unit/controller/audio-stream-controller.ts b/tests/unit/controller/audio-stream-controller.ts index 69ae711c803..7dd91954a18 100644 --- a/tests/unit/controller/audio-stream-controller.ts +++ b/tests/unit/controller/audio-stream-controller.ts @@ -1,4 +1,5 @@ import chai from 'chai'; +import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { hlsDefaultConfig } from '../../../src/config'; import AudioStreamController from '../../../src/controller/audio-stream-controller'; @@ -10,11 +11,13 @@ import KeyLoader from '../../../src/loader/key-loader'; import { LoadStats } from '../../../src/loader/load-stats'; import { Level } from '../../../src/types/level'; import { AttrList } from '../../../src/utils/attr-list'; +import { adjustSlidingStart } from '../../../src/utils/discontinuities'; import type { Fragment } from '../../../src/loader/fragment'; import type { LevelDetails } from '../../../src/loader/level-details'; import type { AudioTrackLoadedData, AudioTrackSwitchingData, + LevelLoadedData, TrackLoadedData, } from '../../../src/types/events'; import type { @@ -27,8 +30,14 @@ const expect = chai.expect; type AudioStreamControllerTestable = Omit< AudioStreamController, - 'levels' | 'mainDetails' | 'onAudioTrackSwitching' | 'onAudioTrackLoaded' + | 'hls' + | 'levels' + | 'mainDetails' + | 'onAudioTrackSwitching' + | 'onAudioTrackLoaded' + | 'onLevelLoaded' > & { + hls: Hls; levels: Level[] | null; mainDetails: LevelDetails; onAudioTrackSwitching: ( @@ -39,6 +48,7 @@ type AudioStreamControllerTestable = Omit< event: Events.AUDIO_TRACK_LOADED, data: TrackLoadedData, ) => void; + onLevelLoaded: (event: Events.LEVEL_LOADED, data: LevelLoadedData) => void; }; describe('AudioStreamController', function () { @@ -116,17 +126,16 @@ describe('AudioStreamController', function () { name: 'C', }, ]; - const tracks: Level[] = audioTracks.map((parsedLevel) => { - const level = new Level(parsedLevel); - return level; - }); + let sandbox: sinon.SinonSandbox; let hls: Hls; let fragmentTracker: FragmentTracker; let keyLoader: KeyLoader; let audioStreamController: AudioStreamControllerTestable; + let tracks: Level[]; beforeEach(function () { + sandbox = sinon.createSandbox(); hls = new Hls(); fragmentTracker = new FragmentTracker(hls); keyLoader = new KeyLoader(hlsDefaultConfig); @@ -135,9 +144,11 @@ describe('AudioStreamController', function () { fragmentTracker, keyLoader, ) as unknown as AudioStreamControllerTestable; + tracks = audioTracks.map((mediaPlaylist) => new Level(mediaPlaylist)); }); afterEach(function () { + sandbox.restore(); hls.destroy(); }); @@ -164,30 +175,84 @@ describe('AudioStreamController', function () { }); describe('onAudioTrackLoaded', function () { - it('should update the level details from the event data', function () { - const trackLoadedData: AudioTrackLoadedData = { - id: 0, - groupId: 'audio', - stats: new LoadStats(), - deliveryDirectives: null, - networkDetails: {}, + let mainLoadedData: LevelLoadedData; + let trackLoadedData: AudioTrackLoadedData; + const getPlaylistData = function ( + startSN: number, + endSN: number, + type: 'audio' | 'main', + live: boolean, + ) { + const targetduration = 10; + const fragments: Fragment[] = Array.from(new Array(endSN - startSN)).map( + (u, i) => + ({ + sn: i + startSN, + cc: Math.floor((i + startSN) / 10), + start: i * targetduration, + duration: targetduration, + type, + }) as unknown as Fragment, + ); + return { details: { - live: false, - get fragments() { - const frags: Fragment[] = []; - for (let i = 0; i < this.endSN; i++) { - frags.push({ sn: i, type: 'main' } as unknown as Fragment); - } - return frags; + live, + advanced: true, + updated: true, + fragments, + get endCC(): number { + return fragments[fragments.length - 1].cc; + }, + get startCC(): number { + return fragments[0].cc; }, - targetduration: 100, + targetduration, + startSN, + endSN, } as unknown as LevelDetails, - track: {} as any, + id: 0, + networkDetails: {}, + stats: new LoadStats(), + deliveryDirectives: null, + }; + }; + const getLevelLoadedData = function ( + startSN: number, + endSN: number, + live: boolean = false, + ): LevelLoadedData { + const data = getPlaylistData(startSN, endSN, 'main', live); + const levelData: LevelLoadedData = { + ...data, + level: 0, + levelInfo: new Level({ ...audioTracks[0] }), }; + return levelData; + }; + const getTrackLoadedData = function ( + startSN: number, + endSN: number, + live: boolean = false, + ): AudioTrackLoadedData { + const data = getPlaylistData(startSN, endSN, 'audio', live); + const audioTrackData: AudioTrackLoadedData = { + ...data, + groupId: 'audio', + track: { ...audioTracks[0] }, + }; + return audioTrackData; + }; + + beforeEach(function () { + sandbox.stub(audioStreamController, 'tick'); + sandbox.stub(audioStreamController.hls, 'trigger'); + mainLoadedData = getLevelLoadedData(0, 5); + trackLoadedData = getTrackLoadedData(0, 5); + }); + it('should update the audio track LevelDetails from the track loaded data', function () { audioStreamController.levels = tracks; - audioStreamController.mainDetails = trackLoadedData.details; - audioStreamController.tick = () => {}; + audioStreamController.mainDetails = mainLoadedData.details; audioStreamController.onAudioTrackLoaded( Events.AUDIO_TRACK_LOADED, @@ -197,6 +262,169 @@ describe('AudioStreamController', function () { expect(audioStreamController.levels[0].details).to.equal( trackLoadedData.details, ); + expect(audioStreamController.hls.trigger).to.have.been.calledWith( + Events.AUDIO_TRACK_UPDATED, + { + details: trackLoadedData.details, + id: 0, + groupId: 'audio', + }, + ); + expect(audioStreamController.tick).to.have.been.calledOnce; + }); + + it('waits for main level details before emitting track updated', function () { + audioStreamController.levels = tracks; + + audioStreamController.onAudioTrackLoaded( + Events.AUDIO_TRACK_LOADED, + trackLoadedData, + ); + + expect(audioStreamController.hls.trigger).to.have.not.been.called; + expect(audioStreamController.tick).to.have.not.been.called; + expect(audioStreamController.levels[0].details).to.be.undefined; + + audioStreamController.onLevelLoaded(Events.LEVEL_LOADED, mainLoadedData); + + expect(audioStreamController.levels[0].details).to.equal( + trackLoadedData.details, + ); + expect(audioStreamController.hls.trigger).to.have.been.calledWith( + Events.AUDIO_TRACK_UPDATED, + { + details: trackLoadedData.details, + id: 0, + groupId: 'audio', + }, + ); + expect(audioStreamController.tick).to.have.been.calledOnce; + }); + + it('waits for main level details discontinuity domain before emitting track updated', function () { + audioStreamController.levels = tracks; + // Audio track ends on DISCONTINUITY-SEQUENCE 1 (main ends at 0) + trackLoadedData = getTrackLoadedData(7, 12, true); + mainLoadedData = getLevelLoadedData(1, 6, true); + audioStreamController.mainDetails = { + ...mainLoadedData.details, + } as unknown as LevelDetails; + + expect(trackLoadedData.details.endCC).to.equal(1); + expect(audioStreamController.mainDetails.endCC).to.equal(0); + + audioStreamController.onAudioTrackLoaded( + Events.AUDIO_TRACK_LOADED, + trackLoadedData, + ); + + expect(audioStreamController.hls.trigger).to.have.not.been.called; + expect(audioStreamController.tick).to.have.not.been.called; + expect(audioStreamController.levels[0].details).to.be.undefined; + + // Main update ending on DISCONTINUITY-SEQUENCE 1 + mainLoadedData = getLevelLoadedData(6, 11, true); + + audioStreamController.onLevelLoaded(Events.LEVEL_LOADED, mainLoadedData); + + expect(audioStreamController.mainDetails.endCC).to.equal(1); + expect(audioStreamController.levels[0].details).to.equal( + trackLoadedData.details, + ); + expect(audioStreamController.hls.trigger).to.have.been.calledWith( + Events.AUDIO_TRACK_UPDATED, + { + details: trackLoadedData.details, + id: 0, + groupId: 'audio', + }, + ); + expect(audioStreamController.tick).to.have.been.calledOnce; + }); + + it('waits for recent live main level details before emitting track updated', function () { + audioStreamController.levels = tracks; + trackLoadedData.details.live = mainLoadedData.details.live = true; + trackLoadedData.details.updated = mainLoadedData.details.updated = true; + // Main live details are present but expired (see LevelDetails `get expired()` and `get age()`) + audioStreamController.mainDetails = { + ...mainLoadedData.details, + expired: true, + } as unknown as LevelDetails; + + audioStreamController.onAudioTrackLoaded( + Events.AUDIO_TRACK_LOADED, + trackLoadedData, + ); + + expect(audioStreamController.hls.trigger).to.have.not.been.called; + expect(audioStreamController.tick).to.have.not.been.called; + expect(audioStreamController.levels[0].details).to.be.undefined; + + // Main update - no longer expired + audioStreamController.onLevelLoaded(Events.LEVEL_LOADED, mainLoadedData); + + expect(audioStreamController.levels[0].details).to.equal( + trackLoadedData.details, + ); + expect(audioStreamController.hls.trigger).to.have.been.calledWith( + Events.AUDIO_TRACK_UPDATED, + { + details: trackLoadedData.details, + id: 0, + groupId: 'audio', + }, + ); + expect(audioStreamController.tick).to.have.been.calledOnce; + }); + + it('aligns track with main level details before emitting track updated', function () { + audioStreamController.levels = tracks; + // Audio track ends on DISCONTINUITY-SEQUENCE 1 (main ends at 0) + trackLoadedData = getTrackLoadedData(7, 12, true); + mainLoadedData = getLevelLoadedData(1, 6, true); + + audioStreamController.mainDetails = { + ...mainLoadedData.details, + } as unknown as LevelDetails; + + expect(trackLoadedData.details.endCC).to.equal(1); + expect(audioStreamController.mainDetails.endCC).to.equal(0); + + audioStreamController.onAudioTrackLoaded( + Events.AUDIO_TRACK_LOADED, + trackLoadedData, + ); + + // Main update - no longer expired, ending on DISCONTINUITY-SEQUENCE 1 + mainLoadedData = getLevelLoadedData(6, 11, true); + adjustSlidingStart(60, mainLoadedData.details); + + expect( + mainLoadedData.details.fragments[0].start, + 'main start before sync', + ).to.equal(60); + expect( + trackLoadedData.details.fragments[0].start, + 'audio start before sync', + ).to.equal(0); + + audioStreamController.onLevelLoaded(Events.LEVEL_LOADED, mainLoadedData); + + expect( + audioStreamController.levels[0].details?.fragments[0].start, + 'audio start after sync', + ).to.equal(70); + + expect(audioStreamController.hls.trigger).to.have.been.calledWith( + Events.AUDIO_TRACK_UPDATED, + { + details: trackLoadedData.details, + id: 0, + groupId: 'audio', + }, + ); + expect(audioStreamController.tick).to.have.been.calledOnce; }); }); }); diff --git a/tests/unit/controller/level-helper.ts b/tests/unit/controller/level-helper.ts index 78b0fb435f5..5e90a1ecb2a 100644 --- a/tests/unit/controller/level-helper.ts +++ b/tests/unit/controller/level-helper.ts @@ -12,6 +12,7 @@ import { Level } from '../../../src/types/level'; import { PlaylistLevelType } from '../../../src/types/loader'; import { AttrList } from '../../../src/utils/attr-list'; import { + addSliding, adjustSliding, computeReloadInterval, mapFragmentIntersection, @@ -553,6 +554,29 @@ fileSequence18.ts`; expect(detailsUpdated.dateRanges.d2.startTime).to.equal(2.94); expect(detailsUpdated.dateRanges.d3.startTime).to.equal(3.94); }); + + it('does not add more sliding when LevelDetails arguments are the same object', function () { + const playlist = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:10 +#EXT-X-MEDIA-SEQUENCE:3 +#EXTINF:6, +fileSequence5.ts +#EXTINF:6, +fileSequence6.ts`; + const details = M3U8Parser.parseLevelPlaylist( + playlist, + 'http://dummy.url.com/playlist.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + addSliding(details, 10); + expect(details.fragmentStart).to.equal(10); + mergeDetails(details, details); + expect(details.fragmentStart).to.equal(10); + }); }); describe('computeReloadInterval', function () {