mirror of
https://github.com/owncast/owncast.git
synced 2024-11-29 11:39:08 +03:00
280 lines
7.7 KiB
JavaScript
280 lines
7.7 KiB
JavaScript
/*
|
|
The Owncast Latency Compensator.
|
|
It will try to slowly adjust the playback rate to enable the player to get
|
|
further into the future, with the goal of being as close to the live edge as
|
|
possible, without causing any buffering events.
|
|
|
|
It will:
|
|
- Determine the start (max) and end (min) latency values.
|
|
- Keep an eye on download speed and stop compensating if it drops too low.
|
|
- Dynamically calculate the speedup rate based on network speed.
|
|
- Pause the compensation if buffering events occur.
|
|
- Completely give up on all compensation if too many buffering events occur.
|
|
*/
|
|
|
|
const REBUFFER_EVENT_LIMIT = 5; // Max number of buffering events before we stop compensating for latency.
|
|
const MIN_BUFFER_DURATION = 500; // Min duration a buffer event must last to be counted.
|
|
const MAX_SPEEDUP_RATE = 1.08; // The playback rate when compensating for latency.
|
|
const TIMEOUT_DURATION = 30 * 1000; // The amount of time we stop handling latency after certain events.
|
|
const CHECK_TIMER_INTERVAL = 5_000; // How often we check if we should be compensating for latency.
|
|
const BUFFERING_AMNESTY_DURATION = 3 * 1000 * 60; // How often until a buffering event expires.
|
|
const REQUIRED_BANDWIDTH_RATIO = 2.0; // The player:bitrate ratio required to enable compensating for latency.
|
|
const HIGHEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER = 2.4; // Segment length * this value is when we start compensating.
|
|
const LOWEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER = 1.7; // Segment length * this value is when we stop compensating.
|
|
const MIN_LATENCY = 4 * 1000; // The lowest we'll continue compensation to be running at.
|
|
const MAX_LATENCY = 8 * 1000; // The highest we'll allow a target latency to be before we start compensating.
|
|
class LatencyCompensator {
|
|
constructor(player) {
|
|
this.player = player;
|
|
this.enabled = false;
|
|
this.running = false;
|
|
this.inTimeout = false;
|
|
this.timeoutTimer = 0;
|
|
this.checkTimer = 0;
|
|
this.bufferingCounter = 0;
|
|
this.bufferingTimer = 0;
|
|
this.playbackRate = 1.0;
|
|
this.player.on('playing', this.handlePlaying.bind(this));
|
|
this.player.on('error', this.handleError.bind(this));
|
|
this.player.on('waiting', this.handleBuffering.bind(this));
|
|
this.player.on('ended', this.handleEnded.bind(this));
|
|
this.player.on('canplaythrough', this.handlePlaying.bind(this));
|
|
this.player.on('canplay', this.handlePlaying.bind(this));
|
|
}
|
|
|
|
// This is run on a timer to check if we should be compensating for latency.
|
|
check() {
|
|
console.log(
|
|
'playback rate',
|
|
this.playbackRate,
|
|
'enabled:',
|
|
this.enabled,
|
|
'running: ',
|
|
this.running,
|
|
'timeout: ',
|
|
this.inTimeout,
|
|
'buffer count:',
|
|
this.bufferingCounter
|
|
);
|
|
|
|
if (this.inTimeout) {
|
|
return;
|
|
}
|
|
|
|
const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
|
|
|
|
if (!tech || !tech.vhs) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Check the player buffers to make sure there's enough playable content
|
|
// that we can safely play.
|
|
if (tech.vhs.stats.buffered.length === 0) {
|
|
this.timeout();
|
|
}
|
|
|
|
let totalBuffered = 0;
|
|
|
|
tech.vhs.stats.buffered.forEach((buffer) => {
|
|
totalBuffered += buffer.end - buffer.start;
|
|
});
|
|
console.log('buffered', totalBuffered);
|
|
|
|
if (totalBuffered < 18) {
|
|
this.timeout();
|
|
}
|
|
} catch (e) {}
|
|
|
|
// Determine how much of the current playlist's bandwidth requirements
|
|
// we're utilizing. If it's too high then we can't afford to push
|
|
// further into the future because we're downloading too slowly.
|
|
const currentPlaylist = tech.vhs.playlists.media();
|
|
const currentPlaylistBandwidth = currentPlaylist.attributes.BANDWIDTH;
|
|
const playerBandwidth = tech.vhs.systemBandwidth;
|
|
const bandwidthRatio = playerBandwidth / currentPlaylistBandwidth;
|
|
|
|
if (bandwidthRatio < REQUIRED_BANDWIDTH_RATIO) {
|
|
this.timeout();
|
|
return;
|
|
}
|
|
|
|
let proposedPlaybackRate = bandwidthRatio * 0.34;
|
|
console.log('proposed rate', proposedPlaybackRate);
|
|
|
|
proposedPlaybackRate = Math.max(
|
|
Math.min(proposedPlaybackRate, MAX_SPEEDUP_RATE),
|
|
1.0
|
|
);
|
|
|
|
try {
|
|
const segment = getCurrentlyPlayingSegment(tech);
|
|
if (!segment) {
|
|
return;
|
|
}
|
|
|
|
// How far away from live edge do we start the compensator.
|
|
const maxLatencyThreshold = Math.min(
|
|
MAX_LATENCY,
|
|
segment.duration * 1000 * HIGHEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER
|
|
);
|
|
|
|
// How far away from live edge do we stop the compensator.
|
|
const minLatencyThreshold = Math.max(
|
|
MIN_LATENCY,
|
|
segment.duration * 1000 * LOWEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER
|
|
);
|
|
|
|
const segmentTime = segment.dateTimeObject.getTime();
|
|
const now = new Date().getTime();
|
|
const latency = now - segmentTime;
|
|
|
|
console.log(
|
|
'latency',
|
|
latency / 1000,
|
|
'min',
|
|
minLatencyThreshold / 1000,
|
|
'max',
|
|
maxLatencyThreshold / 1000
|
|
);
|
|
|
|
if (latency > maxLatencyThreshold) {
|
|
this.start(proposedPlaybackRate);
|
|
} else if (latency < minLatencyThreshold) {
|
|
this.stop();
|
|
}
|
|
} catch (err) {
|
|
// console.warn(err);
|
|
}
|
|
}
|
|
|
|
setPlaybackRate(rate) {
|
|
this.playbackRate = rate;
|
|
this.player.playbackRate(rate);
|
|
}
|
|
|
|
start(rate = 1.0) {
|
|
if (this.inTimeout || !this.enabled || rate === this.playbackRate) {
|
|
return;
|
|
}
|
|
|
|
this.running = true;
|
|
this.setPlaybackRate(rate);
|
|
}
|
|
|
|
stop() {
|
|
this.running = false;
|
|
this.setPlaybackRate(1.0);
|
|
}
|
|
|
|
enable() {
|
|
this.enabled = true;
|
|
clearInterval(this.checkTimer);
|
|
clearTimeout(this.bufferingTimer);
|
|
|
|
this.checkTimer = setInterval(() => {
|
|
this.check();
|
|
}, CHECK_TIMER_INTERVAL);
|
|
}
|
|
|
|
// Disable means we're done for good and should no longer compensate for latency.
|
|
disable() {
|
|
clearInterval(this.checkTimer);
|
|
clearTimeout(this.timeoutTimer);
|
|
this.stop();
|
|
this.enabled = false;
|
|
}
|
|
|
|
timeout() {
|
|
this.inTimeout = true;
|
|
this.stop();
|
|
|
|
clearTimeout(this.timeoutTimer);
|
|
this.timeoutTimer = setTimeout(() => {
|
|
this.endTimeout();
|
|
}, TIMEOUT_DURATION);
|
|
}
|
|
|
|
endTimeout() {
|
|
clearTimeout(this.timeoutTimer);
|
|
this.inTimeout = false;
|
|
}
|
|
|
|
handlePlaying() {
|
|
clearTimeout(this.bufferingTimer);
|
|
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
handleEnded() {
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
|
|
this.disable();
|
|
}
|
|
|
|
handleError(e) {
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
|
|
console.log('handle error', e);
|
|
this.timeout();
|
|
}
|
|
|
|
countBufferingEvent() {
|
|
this.bufferingCounter++;
|
|
if (this.bufferingCounter > REBUFFER_EVENT_LIMIT) {
|
|
this.disable();
|
|
return;
|
|
}
|
|
|
|
this.timeout();
|
|
|
|
// Allow us to forget about old buffering events if enough time goes by.
|
|
setTimeout(() => {
|
|
if (this.bufferingCounter > 0) {
|
|
this.bufferingCounter--;
|
|
}
|
|
}, BUFFERING_AMNESTY_DURATION);
|
|
}
|
|
|
|
handleBuffering() {
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
|
|
this.timeout();
|
|
|
|
this.bufferingTimer = setTimeout(() => {
|
|
this.countBufferingEvent();
|
|
}, MIN_BUFFER_DURATION);
|
|
}
|
|
}
|
|
|
|
function getCurrentlyPlayingSegment(tech) {
|
|
var target_media = tech.vhs.playlists.media();
|
|
var snapshot_time = tech.currentTime();
|
|
|
|
var segment;
|
|
|
|
// Itinerate trough available segments and get first within which snapshot_time is
|
|
for (var i = 0, l = target_media.segments.length; i < l; i++) {
|
|
// Note: segment.end may be undefined or is not properly set
|
|
if (snapshot_time < target_media.segments[i].end) {
|
|
segment = target_media.segments[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!segment) {
|
|
segment = target_media.segments[0];
|
|
}
|
|
|
|
return segment;
|
|
}
|
|
|
|
export default LatencyCompensator;
|