From a9d1fb3e2da0361d246f00c738d3132e9d12210d Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Tue, 11 Oct 2022 17:01:54 -0700 Subject: [PATCH 1/4] Schedule live reload time base on start of last update request Use last fragment duration rather than target duration when playhead is less than two target durations from the live edge --- src/controller/base-playlist-controller.ts | 8 +++++- src/controller/level-helper.ts | 25 ++++++++++++++--- tests/unit/controller/level-helper.ts | 32 +++++++++++++++++++--- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/controller/base-playlist-controller.ts b/src/controller/base-playlist-controller.ts index e14d4db04f2..59c77a4df06 100644 --- a/src/controller/base-playlist-controller.ts +++ b/src/controller/base-playlist-controller.ts @@ -204,7 +204,13 @@ export default class BasePlaylistController implements NetworkComponentAPI { part ); } - let reloadInterval = computeReloadInterval(details, stats); + const position = this.hls.mainForwardBufferInfo?.start || 0; + const distanceToLiveEdgeMs = (details.edge - position) * 1000; + let reloadInterval = computeReloadInterval( + details, + stats, + distanceToLiveEdgeMs + ); if (msn !== undefined && details.canBlockReload) { reloadInterval -= details.partTarget || 1; } diff --git a/src/controller/level-helper.ts b/src/controller/level-helper.ts index 6b07004f21b..2a7e9f8829d 100644 --- a/src/controller/level-helper.ts +++ b/src/controller/level-helper.ts @@ -434,10 +434,20 @@ export function addSliding(details: LevelDetails, start: number) { export function computeReloadInterval( newDetails: LevelDetails, - stats: LoaderStats + stats: LoaderStats, + distanceToLiveEdgeMs: number = Infinity ): number { - const reloadInterval = 1000 * newDetails.targetduration; - const roundTrip = stats.loading.end - stats.loading.start; + let reloadInterval = 1000 * newDetails.targetduration; + + // Use last segment duration when shorter than target duration and near live edge + const fragments = newDetails.fragments; + if (fragments.length && reloadInterval * 2 > distanceToLiveEdgeMs) { + const lastSegmentDuration = fragments[fragments.length - 1].duration * 1000; + if (lastSegmentDuration < reloadInterval) { + const now = performance.now(); + reloadInterval = stats.loading.start + lastSegmentDuration - now; + } + } let estimatedTimeUntilUpdate; if (!newDetails.updated) { @@ -447,7 +457,14 @@ export function computeReloadInterval( // duration before retrying. estimatedTimeUntilUpdate = reloadInterval / 2; } else { - estimatedTimeUntilUpdate = reloadInterval - roundTrip; + const roundTrip = stats.loading.end - stats.loading.start; + const now = performance.now(); + const estimatedRefreshFromLastRequest = + stats.loading.start + reloadInterval - now; + estimatedTimeUntilUpdate = Math.min( + reloadInterval - roundTrip, + estimatedRefreshFromLastRequest + ); } // console.log(`[computeReloadInterval] live reload ${newDetails.updated ? 'REFRESHED' : 'MISSED'}`, diff --git a/tests/unit/controller/level-helper.ts b/tests/unit/controller/level-helper.ts index ada8a230015..106d0bf5a0b 100644 --- a/tests/unit/controller/level-helper.ts +++ b/tests/unit/controller/level-helper.ts @@ -17,7 +17,7 @@ import { AttrList } from '../../../src/utils/attr-list'; chai.use(sinonChai); const expect = chai.expect; -const generatePlaylist = (sequenceNumbers, offset = 0) => { +const generatePlaylist = (sequenceNumbers, offset = 0, duration = 5) => { const playlist = new LevelDetails(''); playlist.startSN = sequenceNumbers[0]; playlist.endSN = sequenceNumbers[sequenceNumbers.length - 1]; @@ -25,7 +25,7 @@ const generatePlaylist = (sequenceNumbers, offset = 0) => { const frag = new Fragment(PlaylistLevelType.MAIN, ''); frag.sn = n; frag.start = i * 5 + offset; - frag.duration = 5; + frag.duration = duration; return frag; }); return playlist; @@ -265,8 +265,18 @@ expect: ${JSON.stringify(merged.fragments[i])}` }); describe('computeReloadInterval', function () { + let sandbox; + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(performance, 'now').returns(0); + }); + + afterEach(function () { + sandbox.restore(); + }); + it('returns the targetduration of the new level if available', function () { - const newPlaylist = generatePlaylist([3, 4]); + const newPlaylist = generatePlaylist([3, 4], 0, 6); newPlaylist.targetduration = 5; newPlaylist.updated = true; const actual = computeReloadInterval(newPlaylist, new LoadStats()); @@ -282,7 +292,7 @@ expect: ${JSON.stringify(merged.fragments[i])}` }); it('rounds the reload interval', function () { - const newPlaylist = generatePlaylist([3, 4]); + const newPlaylist = generatePlaylist([3, 4], 0, 10); newPlaylist.targetduration = 5.9999; newPlaylist.updated = true; const actual = computeReloadInterval(newPlaylist, new LoadStats()); @@ -310,5 +320,19 @@ expect: ${JSON.stringify(merged.fragments[i])}` const actual = computeReloadInterval(newPlaylist, stats); expect(actual).to.equal(2500); }); + + it('returns the last fragment duration when distance to live edge is less than two target durations', function () { + const newPlaylist = generatePlaylist([3, 4], 0, 2); + newPlaylist.targetduration = 5; + newPlaylist.updated = true; + const actual = computeReloadInterval(newPlaylist, new LoadStats(), 11000); + expect(actual).to.equal(5000); + const actualLow = computeReloadInterval( + newPlaylist, + new LoadStats(), + 9000 + ); + expect(actualLow).to.equal(2000); + }); }); }); From 4859ed1d5e07477822fa00815200cf597ea9b880 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Tue, 18 Oct 2022 18:43:29 -0700 Subject: [PATCH 2/4] Eliminate JavaScript execution and setTimeout latency from playlist reload interval --- src/controller/audio-track-controller.ts | 1 + src/controller/base-playlist-controller.ts | 57 +++++++++++++++++---- src/controller/level-controller.ts | 1 + src/controller/level-helper.ts | 41 +++++---------- src/controller/subtitle-track-controller.ts | 1 + tests/unit/controller/level-helper.ts | 32 +++--------- 6 files changed, 70 insertions(+), 63 deletions(-) diff --git a/src/controller/audio-track-controller.ts b/src/controller/audio-track-controller.ts index a6690567ab5..993ea572a0f 100644 --- a/src/controller/audio-track-controller.ts +++ b/src/controller/audio-track-controller.ts @@ -240,6 +240,7 @@ class AudioTrackController extends BasePlaylistController { } protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void { + super.loadPlaylist(); const audioTrack = this.tracksInGroup[this.trackId]; if (this.shouldLoadTrack(audioTrack)) { const id = audioTrack.id; diff --git a/src/controller/base-playlist-controller.ts b/src/controller/base-playlist-controller.ts index 59c77a4df06..af9e538232f 100644 --- a/src/controller/base-playlist-controller.ts +++ b/src/controller/base-playlist-controller.ts @@ -17,6 +17,7 @@ import { ErrorTypes } from '../errors'; export default class BasePlaylistController implements NetworkComponentAPI { protected hls: Hls; protected timer: number = -1; + protected requestScheduled: number = -1; protected canLoad: boolean = false; protected retryCount: number = 0; protected log: (msg: any) => void; @@ -48,6 +49,7 @@ export default class BasePlaylistController implements NetworkComponentAPI { public startLoad(): void { this.canLoad = true; this.retryCount = 0; + this.requestScheduled = -1; this.loadPlaylist(); } @@ -89,7 +91,11 @@ export default class BasePlaylistController implements NetworkComponentAPI { } } - protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {} + protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void { + if (this.requestScheduled === -1) { + this.requestScheduled = self.performance.now(); + } + } protected shouldLoadTrack(track: MediaPlaylist): boolean { return ( @@ -108,8 +114,9 @@ export default class BasePlaylistController implements NetworkComponentAPI { const { details, stats } = data; // Set last updated date-time - const elapsed = stats.loading.end - ? Math.max(0, self.performance.now() - stats.loading.end) + const now = self.performance.now(); + const elapsed = stats.loading.first + ? Math.max(0, now - stats.loading.first) : 0; details.advancedDateTime = Date.now() - elapsed; @@ -204,22 +211,53 @@ export default class BasePlaylistController implements NetworkComponentAPI { part ); } - const position = this.hls.mainForwardBufferInfo?.start || 0; + const bufferInfo = this.hls.mainForwardBufferInfo; + const position = bufferInfo ? bufferInfo.end - bufferInfo.len : 0; const distanceToLiveEdgeMs = (details.edge - position) * 1000; - let reloadInterval = computeReloadInterval( + const reloadInterval = computeReloadInterval( details, - stats, distanceToLiveEdgeMs ); + if (!details.updated) { + this.requestScheduled = -1; + } else if (now > this.requestScheduled + reloadInterval) { + this.requestScheduled = stats.loading.start; + } + if (msn !== undefined && details.canBlockReload) { - reloadInterval -= details.partTarget || 1; + this.requestScheduled = + stats.loading.first + + reloadInterval - + (details.partTarget * 1000 || 1000); + } else { + this.requestScheduled = + (this.requestScheduled === -1 ? now : this.requestScheduled) + + reloadInterval; } + let estimatedTimeUntilUpdate = this.requestScheduled - now; + estimatedTimeUntilUpdate = Math.max(0, estimatedTimeUntilUpdate); this.log( - `reload live playlist ${index} in ${Math.round(reloadInterval)} ms` + `reload live playlist ${index} in ${Math.round( + estimatedTimeUntilUpdate + )} ms` ); + this.log( + `live reload ${details.updated ? 'REFRESHED' : 'MISSED'} + reload in ${estimatedTimeUntilUpdate / 1000} + round trip ${(stats.loading.end - stats.loading.start) / 1000} + diff ${ + (reloadInterval - + (estimatedTimeUntilUpdate + stats.loading.end - stats.loading.start)) / + 1000 + } + reload interval ${reloadInterval / 1000} + target duration ${details.targetduration} + distance to edge ${distanceToLiveEdgeMs / 1000}` + ); + this.timer = self.setTimeout( () => this.loadPlaylist(deliveryDirectives), - reloadInterval + estimatedTimeUntilUpdate ); } else { this.clearTimer(); @@ -245,6 +283,7 @@ export default class BasePlaylistController implements NetworkComponentAPI { const { config } = this.hls; const retry = this.retryCount < config.levelLoadingMaxRetry; if (retry) { + this.requestScheduled = -1; this.retryCount++; if ( errorEvent.details.indexOf('LoadTimeOut') > -1 && diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index fe0e67192dd..f392696f443 100644 --- a/src/controller/level-controller.ts +++ b/src/controller/level-controller.ts @@ -518,6 +518,7 @@ export default class LevelController extends BasePlaylistController { } protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters) { + super.loadPlaylist(); const level = this.currentLevelIndex; const currentLevel = this._levels[level]; diff --git a/src/controller/level-helper.ts b/src/controller/level-helper.ts index 2a7e9f8829d..d8fd96952e2 100644 --- a/src/controller/level-helper.ts +++ b/src/controller/level-helper.ts @@ -434,46 +434,29 @@ export function addSliding(details: LevelDetails, start: number) { export function computeReloadInterval( newDetails: LevelDetails, - stats: LoaderStats, distanceToLiveEdgeMs: number = Infinity ): number { let reloadInterval = 1000 * newDetails.targetduration; - // Use last segment duration when shorter than target duration and near live edge - const fragments = newDetails.fragments; - if (fragments.length && reloadInterval * 2 > distanceToLiveEdgeMs) { - const lastSegmentDuration = fragments[fragments.length - 1].duration * 1000; - if (lastSegmentDuration < reloadInterval) { - const now = performance.now(); - reloadInterval = stats.loading.start + lastSegmentDuration - now; + if (newDetails.updated) { + // Use last segment duration when shorter than target duration and near live edge + const fragments = newDetails.fragments; + if (fragments.length && reloadInterval * 3 > distanceToLiveEdgeMs) { + const lastSegmentDuration = + fragments[fragments.length - 1].duration * 1000; + if (lastSegmentDuration < reloadInterval) { + reloadInterval = lastSegmentDuration; + } } - } - - let estimatedTimeUntilUpdate; - if (!newDetails.updated) { + } else { // estimate = 'miss half average'; // follow HLS Spec, If the client reloads a Playlist file and finds that it has not // changed then it MUST wait for a period of one-half the target // duration before retrying. - estimatedTimeUntilUpdate = reloadInterval / 2; - } else { - const roundTrip = stats.loading.end - stats.loading.start; - const now = performance.now(); - const estimatedRefreshFromLastRequest = - stats.loading.start + reloadInterval - now; - estimatedTimeUntilUpdate = Math.min( - reloadInterval - roundTrip, - estimatedRefreshFromLastRequest - ); + reloadInterval /= 2; } - // console.log(`[computeReloadInterval] live reload ${newDetails.updated ? 'REFRESHED' : 'MISSED'}`, - // '\n method', estimate, - // '\n estimated time until update =>', estimatedTimeUntilUpdate, - // '\n average target duration', reloadInterval, - // '\n time round trip', roundTrip); - - return Math.round(estimatedTimeUntilUpdate); + return Math.round(reloadInterval); } export function getFragmentWithSN( diff --git a/src/controller/subtitle-track-controller.ts b/src/controller/subtitle-track-controller.ts index c16622f032d..30b273defc5 100644 --- a/src/controller/subtitle-track-controller.ts +++ b/src/controller/subtitle-track-controller.ts @@ -275,6 +275,7 @@ class SubtitleTrackController extends BasePlaylistController { } protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void { + super.loadPlaylist(); const currentTrack = this.tracksInGroup[this.trackId]; if (this.shouldLoadTrack(currentTrack)) { const id = currentTrack.id; diff --git a/tests/unit/controller/level-helper.ts b/tests/unit/controller/level-helper.ts index 106d0bf5a0b..d548cd6f8b8 100644 --- a/tests/unit/controller/level-helper.ts +++ b/tests/unit/controller/level-helper.ts @@ -279,7 +279,7 @@ expect: ${JSON.stringify(merged.fragments[i])}` const newPlaylist = generatePlaylist([3, 4], 0, 6); newPlaylist.targetduration = 5; newPlaylist.updated = true; - const actual = computeReloadInterval(newPlaylist, new LoadStats()); + const actual = computeReloadInterval(newPlaylist); expect(actual).to.equal(5000); }); @@ -287,7 +287,7 @@ expect: ${JSON.stringify(merged.fragments[i])}` const newPlaylist = generatePlaylist([1, 2]); newPlaylist.updated = false; newPlaylist.targetduration = 5; - const actual = computeReloadInterval(newPlaylist, new LoadStats()); + const actual = computeReloadInterval(newPlaylist); expect(actual).to.equal(2500); }); @@ -295,43 +295,25 @@ expect: ${JSON.stringify(merged.fragments[i])}` const newPlaylist = generatePlaylist([3, 4], 0, 10); newPlaylist.targetduration = 5.9999; newPlaylist.updated = true; - const actual = computeReloadInterval(newPlaylist, new LoadStats()); + const actual = computeReloadInterval(newPlaylist); expect(actual).to.equal(6000); }); - it('subtracts the request time of the last level load from the reload interval', function () { - const newPlaylist = generatePlaylist([3, 4]); - newPlaylist.targetduration = 5; - newPlaylist.updated = true; - const stats = new LoadStats(); - stats.loading.start = 0; - stats.loading.end = 1000; - const actual = computeReloadInterval(newPlaylist, stats); - expect(actual).to.equal(4000); - }); - it('returns a minimum of half the target duration', function () { const newPlaylist = generatePlaylist([3, 4]); newPlaylist.targetduration = 5; newPlaylist.updated = false; - const stats = new LoadStats(); - stats.loading.start = 0; - stats.loading.end = 1000; - const actual = computeReloadInterval(newPlaylist, stats); + const actual = computeReloadInterval(newPlaylist); expect(actual).to.equal(2500); }); - it('returns the last fragment duration when distance to live edge is less than two target durations', function () { + it('returns the last fragment duration when distance to live edge is less than three target durations', function () { const newPlaylist = generatePlaylist([3, 4], 0, 2); newPlaylist.targetduration = 5; newPlaylist.updated = true; - const actual = computeReloadInterval(newPlaylist, new LoadStats(), 11000); + const actual = computeReloadInterval(newPlaylist, 15000); expect(actual).to.equal(5000); - const actualLow = computeReloadInterval( - newPlaylist, - new LoadStats(), - 9000 - ); + const actualLow = computeReloadInterval(newPlaylist, 14000); expect(actualLow).to.equal(2000); }); }); From 496e51d76d0b2e7a5eea58b0a3994101ceb09e38 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Tue, 18 Oct 2022 18:53:41 -0700 Subject: [PATCH 3/4] Comment out debug logs --- src/controller/base-playlist-controller.ts | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/controller/base-playlist-controller.ts b/src/controller/base-playlist-controller.ts index af9e538232f..00c09e443c3 100644 --- a/src/controller/base-playlist-controller.ts +++ b/src/controller/base-playlist-controller.ts @@ -241,19 +241,19 @@ export default class BasePlaylistController implements NetworkComponentAPI { estimatedTimeUntilUpdate )} ms` ); - this.log( - `live reload ${details.updated ? 'REFRESHED' : 'MISSED'} - reload in ${estimatedTimeUntilUpdate / 1000} - round trip ${(stats.loading.end - stats.loading.start) / 1000} - diff ${ - (reloadInterval - - (estimatedTimeUntilUpdate + stats.loading.end - stats.loading.start)) / - 1000 - } - reload interval ${reloadInterval / 1000} - target duration ${details.targetduration} - distance to edge ${distanceToLiveEdgeMs / 1000}` - ); + // this.log( + // `live reload ${details.updated ? 'REFRESHED' : 'MISSED'} + // reload in ${estimatedTimeUntilUpdate / 1000} + // round trip ${(stats.loading.end - stats.loading.start) / 1000} + // diff ${ + // (reloadInterval - + // (estimatedTimeUntilUpdate + stats.loading.end - stats.loading.start)) / + // 1000 + // } + // reload interval ${reloadInterval / 1000} + // target duration ${details.targetduration} + // distance to edge ${distanceToLiveEdgeMs / 1000}` + // ); this.timer = self.setTimeout( () => this.loadPlaylist(deliveryDirectives), From e2f2f240a30530257a674f32017dee066ca42fa9 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Mon, 24 Oct 2022 10:27:26 -0700 Subject: [PATCH 4/4] Reload live stream using last segment length when distance to playlist end is less than or equal to four target durations (Reload is scheduled on update. With a default live sync of 3 target durations, without interruption, a distance of 3x-4x is most likely.) --- src/controller/level-helper.ts | 7 +++++-- tests/unit/controller/level-helper.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/controller/level-helper.ts b/src/controller/level-helper.ts index d8fd96952e2..18ce16264a1 100644 --- a/src/controller/level-helper.ts +++ b/src/controller/level-helper.ts @@ -7,7 +7,6 @@ import { logger } from '../utils/logger'; import { Fragment, Part } from '../loader/fragment'; import { LevelDetails } from '../loader/level-details'; import type { Level } from '../types/level'; -import type { LoaderStats } from '../types/loader'; import type { MediaPlaylist } from '../types/media-playlist'; import { DateRange } from '../loader/date-range'; @@ -441,7 +440,11 @@ export function computeReloadInterval( if (newDetails.updated) { // Use last segment duration when shorter than target duration and near live edge const fragments = newDetails.fragments; - if (fragments.length && reloadInterval * 3 > distanceToLiveEdgeMs) { + const liveEdgeMaxTargetDurations = 4; + if ( + fragments.length && + reloadInterval * liveEdgeMaxTargetDurations > distanceToLiveEdgeMs + ) { const lastSegmentDuration = fragments[fragments.length - 1].duration * 1000; if (lastSegmentDuration < reloadInterval) { diff --git a/tests/unit/controller/level-helper.ts b/tests/unit/controller/level-helper.ts index d548cd6f8b8..5c70a739b30 100644 --- a/tests/unit/controller/level-helper.ts +++ b/tests/unit/controller/level-helper.ts @@ -307,11 +307,11 @@ expect: ${JSON.stringify(merged.fragments[i])}` expect(actual).to.equal(2500); }); - it('returns the last fragment duration when distance to live edge is less than three target durations', function () { + it('returns the last fragment duration when distance to live edge is less than or equal to four target durations', function () { const newPlaylist = generatePlaylist([3, 4], 0, 2); newPlaylist.targetduration = 5; newPlaylist.updated = true; - const actual = computeReloadInterval(newPlaylist, 15000); + const actual = computeReloadInterval(newPlaylist, 20000); expect(actual).to.equal(5000); const actualLow = computeReloadInterval(newPlaylist, 14000); expect(actualLow).to.equal(2000);