mirror of
				https://github.com/owncast/owncast.git
				synced 2025-10-31 18:18:06 +08:00 
			
		
		
		
	![renovate[bot]](/assets/img/avatar_default.png) f6a1b1b638
			
		
	
	f6a1b1b638
	
	
	
		
			
			* chore(deps): update typescript-eslint monorepo to v8 * chore(js): fix linter errors --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Gabe Kangas <gabek@real-ity.com>
		
			
				
	
	
		
			490 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			490 lines
		
	
	
		
			16 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.
 | |
| 
 | |
| How does latency occur?
 | |
| Two pieces are at play. The first being the server. The larger each segment is
 | |
| that is being generated by Owncast, the larger gap you are going to be from
 | |
| live when you begin playback.
 | |
| 
 | |
| Second is your media player.
 | |
| The player tries to play every segment as it comes in.
 | |
| However, your computer is not always 100% in playing things in real time, and
 | |
| there are natural stutters in playback. So if one frame is delayed in playback
 | |
| you may not see it visually, but now you're one frame behind. Eventually this
 | |
| can compound and you can be many seconds behind.
 | |
| 
 | |
| How to help with this? The Owncast Latency Compensator will:
 | |
|   - Determine the start (max) and end (min) latency values.
 | |
|   - Keep an eye on download speed and stop compensating if it drops too low.
 | |
|   - Limit the playback speedup rate so it doesn't sound weird by jumping speeds.
 | |
|   - Force a large jump to into the future once compensation begins.
 | |
|   - 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 = 4; // Max number of buffering events before we stop compensating for latency.
 | |
| const MIN_BUFFER_DURATION = 200; // Min duration a buffer event must last to be counted.
 | |
| const MAX_SPEEDUP_RATE = 1.08; // The playback rate when compensating for latency.
 | |
| const MAX_SPEEDUP_RAMP = 0.02; // The max amount we will increase the playback rate at once.
 | |
| const TIMEOUT_DURATION = 30 * 1000; // The amount of time we stop handling latency after certain events.
 | |
| const CHECK_TIMER_INTERVAL = 3 * 1000; // 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 = 1.8; // The player:bitrate ratio required to enable compensating for latency.
 | |
| const HIGHEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER = 2.6; // Segment length * this value is when we start compensating.
 | |
| const LOWEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER = 1.8; // Segment length * this value is when we stop compensating.
 | |
| const MIN_LATENCY = 4 * 1000; // The absolute lowest we'll continue compensation to be running at.
 | |
| const MAX_LATENCY = 15 * 1000; // The absolute highest we'll allow a target latency to be before we start compensating.
 | |
| const MAX_JUMP_LATENCY = 5 * 1000; // How much behind the max latency we need to be behind before we allow a jump.
 | |
| const MAX_JUMP_FREQUENCY = 20 * 1000; // How often we'll allow a time jump.
 | |
| const MAX_ACTIONABLE_LATENCY = 80 * 1000; // If latency is seen to be greater than this then something is wrong.
 | |
| const STARTUP_WAIT_TIME = 10 * 1000; // The amount of time after we start up that we'll allow monitoring to occur.
 | |
| 
 | |
| function getCurrentlyPlayingSegment(tech) {
 | |
|   const targetMedia = tech.vhs.playlists.media();
 | |
|   const snapshotTime = tech.currentTime();
 | |
|   let segment;
 | |
| 
 | |
|   // Iterate trough available segments and get first within which snapshot_time is
 | |
|   // eslint-disable-next-line no-plusplus
 | |
|   for (let i = 0, l = targetMedia.segments.length; i < l; i++) {
 | |
|     // Note: segment.end may be undefined or is not properly set
 | |
|     if (snapshotTime < targetMedia.segments[i].end) {
 | |
|       segment = targetMedia.segments[i];
 | |
|       break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   if (!segment) {
 | |
|     [segment] = targetMedia.segments;
 | |
|   }
 | |
| 
 | |
|   return segment;
 | |
| }
 | |
| 
 | |
| class LatencyCompensator {
 | |
|   constructor(player) {
 | |
|     this.player = player;
 | |
|     this.playing = false;
 | |
|     this.enabled = false;
 | |
|     this.running = false;
 | |
|     this.inTimeout = false;
 | |
|     this.jumpingToLiveIgnoreBuffer = false;
 | |
|     this.timeoutTimer = 0;
 | |
|     this.checkTimer = 0;
 | |
|     this.bufferingCounter = 0;
 | |
|     this.bufferingTimer = 0;
 | |
|     this.playbackRate = 1.0;
 | |
|     this.lastJumpOccurred = null;
 | |
|     this.startupTime = new Date();
 | |
|     this.clockSkewMs = 0;
 | |
|     this.currentLatency = null;
 | |
| 
 | |
|     // Keep track of all the latencies we encountered buffering events
 | |
|     // in order to determine a new minimum latency.
 | |
|     this.bufferedAtLatency = [];
 | |
| 
 | |
|     this.player.on('playing', this.handlePlaying.bind(this));
 | |
|     this.player.on('pause', this.handlePause.bind(this));
 | |
|     this.player.on('error', this.handleError.bind(this));
 | |
|     this.player.on('waiting', this.handleBuffering.bind(this));
 | |
|     this.player.on('stalled', 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.check = this.check.bind(this);
 | |
|     this.start = this.start.bind(this);
 | |
|     this.enable = this.enable.bind(this);
 | |
|     this.countBufferingEvent = this.countBufferingEvent.bind(this);
 | |
|   }
 | |
| 
 | |
|   // To keep our client clock in sync with the server clock to determine
 | |
|   // accurate latency the clock skew should be set here to be used in
 | |
|   // the calculation. Otherwise if somebody's client clock is significantly
 | |
|   // off it will have a very incorrect latency determination and make bad
 | |
|   // decisions.
 | |
|   setClockSkew(skewMs) {
 | |
|     this.clockSkewMs = skewMs;
 | |
|   }
 | |
| 
 | |
|   // This is run on a timer to check if we should be compensating for latency.
 | |
|   check() {
 | |
|     // We have an arbitrary delay at startup to allow the player to run
 | |
|     // normally and hopefully get a bit of a buffer of segments before we
 | |
|     // start messing with it.
 | |
|     if (new Date().getTime() - this.startupTime.getTime() < STARTUP_WAIT_TIME) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // If we're paused then do nothing.
 | |
|     if (this.player.paused()) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (this.player.seeking()) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (this.inTimeout) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (!this.enabled) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
 | |
| 
 | |
|     // We need access to the internal tech of VHS to move forward.
 | |
|     // If running under an Apple browser that uses CoreMedia (Safari)
 | |
|     // we do not have access to this as the tech is internal to the OS.
 | |
|     if (!tech || !tech.vhs) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // Network state 2 means we're actively using the network.
 | |
|     // We only want to attempt latency compensation if we're continuing to
 | |
|     // download new segments.
 | |
|     const networkState = this.player.networkState();
 | |
|     if (networkState !== 2) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     let totalBuffered = 0;
 | |
| 
 | |
|     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();
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       tech.vhs.stats.buffered.forEach(buffer => {
 | |
|         totalBuffered += buffer.end - buffer.start;
 | |
|       });
 | |
|     } catch (e) {
 | |
|       console.error(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;
 | |
| 
 | |
|     try {
 | |
|       const segment = getCurrentlyPlayingSegment(tech);
 | |
|       if (!segment) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // If we're downloading media fast enough or we feel like we have a large
 | |
|       // enough buffer then continue. Otherwise timeout for a bit.
 | |
|       if (bandwidthRatio < REQUIRED_BANDWIDTH_RATIO && totalBuffered < segment.duration * 6) {
 | |
|         this.timeout();
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // How far away from live edge do we stop the compensator.
 | |
|       const computedMinLatencyThreshold = Math.max(
 | |
|         MIN_LATENCY,
 | |
|         segment.duration * 1000 * LOWEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER,
 | |
|       );
 | |
| 
 | |
|       // Create an array of all the buffering events in the past along with
 | |
|       // the computed min latency above.
 | |
|       const targetLatencies = this.bufferedAtLatency.concat([computedMinLatencyThreshold]);
 | |
| 
 | |
|       // Determine if we need to reduce the minimum latency we computed
 | |
|       // above based on buffering events that have taken place in the past by
 | |
|       // creating an array of all the buffering events and the above computed
 | |
|       // minimum latency target and averaging all those values.
 | |
|       const minLatencyThreshold =
 | |
|         targetLatencies.reduce((sum, current) => sum + current, 0) / targetLatencies.length;
 | |
| 
 | |
|       // How far away from live edge do we start the compensator.
 | |
|       let maxLatencyThreshold = Math.max(
 | |
|         minLatencyThreshold * 1.4,
 | |
|         Math.min(segment.duration * 1000 * HIGHEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER, MAX_LATENCY),
 | |
|       );
 | |
| 
 | |
|       // If this newly adjusted minimum latency ends up being greater than
 | |
|       // the previously computed maximum latency then reset the maximum
 | |
|       // value using the minimum + an offset.
 | |
|       if (minLatencyThreshold >= maxLatencyThreshold) {
 | |
|         maxLatencyThreshold = minLatencyThreshold + 3000;
 | |
|       }
 | |
| 
 | |
|       const segmentTime = segment.dateTimeObject.getTime();
 | |
|       const now = new Date().getTime() + this.clockSkewMs;
 | |
|       const latency = now - segmentTime;
 | |
|       this.currentLatency = latency;
 | |
| 
 | |
|       // Since the calculation of latency is based on clock times, it's possible
 | |
|       // things can be reported incorrectly. So we use a sanity check here to
 | |
|       // simply bail if the latency is reported to so high we think the whole
 | |
|       // thing is wrong. We can't make decisions based on bad data, so give up.
 | |
|       // This can also occur if somebody pauses for a long time and hits play
 | |
|       // again but it's not really possible to know the difference between
 | |
|       // the two scenarios.
 | |
|       if (Math.abs(latency) > MAX_ACTIONABLE_LATENCY) {
 | |
|         this.timeout();
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if (latency > maxLatencyThreshold) {
 | |
|         // If the current latency exceeds the max jump amount then
 | |
|         // force jump into the future, skipping all the video in between.
 | |
|         if (this.shouldJumpToLive() && latency > maxLatencyThreshold + MAX_JUMP_LATENCY) {
 | |
|           const jumpAmount = latency / 1000 - segment.duration * 3;
 | |
|           const seekPosition = this.player.currentTime() + jumpAmount;
 | |
|           console.info(
 | |
|             'latency',
 | |
|             latency / 1000,
 | |
|             'jumping',
 | |
|             jumpAmount,
 | |
|             'to live from ',
 | |
|             this.player.currentTime(),
 | |
|             ' to ',
 | |
|             seekPosition,
 | |
|           );
 | |
| 
 | |
|           // Verify we have the seek position buffered before jumping.
 | |
|           const availableBufferedTimeEnd = tech.vhs.stats.buffered[0].end;
 | |
|           const availableBufferedTimeStart = tech.vhs.stats.buffered[0].start;
 | |
|           if (seekPosition > availableBufferedTimeStart < availableBufferedTimeEnd) {
 | |
|             this.jump(seekPosition);
 | |
| 
 | |
|             return;
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         // Using our bandwidth ratio determine a wide guess at how fast we can play.
 | |
|         let proposedPlaybackRate = bandwidthRatio * 0.33;
 | |
| 
 | |
|         // But limit the playback rate to a max value.
 | |
|         proposedPlaybackRate = Math.max(Math.min(proposedPlaybackRate, MAX_SPEEDUP_RATE), 1.0);
 | |
| 
 | |
|         if (proposedPlaybackRate > this.playbackRate + MAX_SPEEDUP_RAMP) {
 | |
|           // If this proposed speed is substantially faster than the current rate,
 | |
|           // then allow us to ramp up by using a slower value for now.
 | |
|           proposedPlaybackRate = this.playbackRate + MAX_SPEEDUP_RAMP;
 | |
|         }
 | |
| 
 | |
|         // Limit to 3 decimal places of precision.
 | |
|         proposedPlaybackRate = Math.round(proposedPlaybackRate * 10 ** 3) / 10 ** 3;
 | |
| 
 | |
|         // Otherwise start the playback rate adjustment.
 | |
|         this.start(proposedPlaybackRate);
 | |
|       } else if (latency <= minLatencyThreshold) {
 | |
|         this.stop();
 | |
|       }
 | |
| 
 | |
|       console.info(
 | |
|         'latency',
 | |
|         latency / 1000,
 | |
|         'min',
 | |
|         minLatencyThreshold / 1000,
 | |
|         'max',
 | |
|         maxLatencyThreshold / 1000,
 | |
|         'playback rate',
 | |
|         this.playbackRate,
 | |
|         'enabled:',
 | |
|         this.enabled,
 | |
|         'running: ',
 | |
|         this.running,
 | |
|         'skew: ',
 | |
|         this.clockSkewMs,
 | |
|         'rebuffer events: ',
 | |
|         this.bufferingCounter,
 | |
|       );
 | |
|     } catch {
 | |
|       // console.error(err);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   shouldJumpToLive() {
 | |
|     // If we've been rebuffering some recently then don't make it worse by
 | |
|     // jumping more into the future.
 | |
|     if (this.bufferingCounter > 1) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     const now = new Date().getTime();
 | |
|     const delta = now - this.lastJumpOccurred;
 | |
|     return delta > MAX_JUMP_FREQUENCY;
 | |
|   }
 | |
| 
 | |
|   jump(seekPosition) {
 | |
|     this.jumpingToLiveIgnoreBuffer = true;
 | |
|     this.performedInitialLiveJump = true;
 | |
| 
 | |
|     this.lastJumpOccurred = new Date();
 | |
| 
 | |
|     console.info('current time', this.player.currentTime(), 'seeking to', seekPosition);
 | |
|     this.player.currentTime(seekPosition);
 | |
| 
 | |
|     setTimeout(() => {
 | |
|       this.jumpingToLiveIgnoreBuffer = false;
 | |
|     }, 5000);
 | |
|   }
 | |
| 
 | |
|   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() {
 | |
|     if (this.running) {
 | |
|       console.log('stopping latency compensator...');
 | |
|     }
 | |
|     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() {
 | |
|     if (this.jumpingToLiveIgnoreBuffer) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.inTimeout = true;
 | |
|     this.stop();
 | |
| 
 | |
|     clearTimeout(this.timeoutTimer);
 | |
|     this.timeoutTimer = setTimeout(() => {
 | |
|       this.endTimeout();
 | |
|     }, TIMEOUT_DURATION);
 | |
|   }
 | |
| 
 | |
|   endTimeout() {
 | |
|     clearTimeout(this.timeoutTimer);
 | |
|     this.inTimeout = false;
 | |
|   }
 | |
| 
 | |
|   handlePlaying() {
 | |
|     const wasPreviouslyPlaying = this.playing;
 | |
|     this.playing = true;
 | |
| 
 | |
|     clearTimeout(this.bufferingTimer);
 | |
|     if (!this.enabled) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (!this.shouldJumpToLive()) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // If we were not previously playing (was paused, or this is a cold start)
 | |
|     // seek to live immediately on starting playback to handle any long-pause
 | |
|     // scenarios or somebody starting far back from the live edge.
 | |
|     // If we were playing previously then that means we're probably coming back
 | |
|     // from a rebuffering event, meaning we should not be adding more seeking
 | |
|     // to the mix, just let it play.
 | |
|     if (!wasPreviouslyPlaying) {
 | |
|       this.jumpingToLiveIgnoreBuffer = true;
 | |
|       this.player.liveTracker.seekToLiveEdge();
 | |
|       this.lastJumpOccurred = new Date();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   handlePause() {
 | |
|     this.playing = false;
 | |
|   }
 | |
| 
 | |
|   handleEnded() {
 | |
|     if (!this.enabled) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.disable();
 | |
|   }
 | |
| 
 | |
|   handleError() {
 | |
|     if (!this.enabled) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.timeout();
 | |
|   }
 | |
| 
 | |
|   countBufferingEvent() {
 | |
|     this.bufferingCounter += 1;
 | |
| 
 | |
|     if (this.bufferingCounter > REBUFFER_EVENT_LIMIT) {
 | |
|       this.disable();
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.bufferedAtLatency.push(this.currentLatency);
 | |
| 
 | |
|     console.log(
 | |
|       'latency compensation timeout due to buffering:',
 | |
|       this.bufferingCounter,
 | |
|       'buffering events of',
 | |
|       REBUFFER_EVENT_LIMIT,
 | |
|     );
 | |
| 
 | |
|     // Allow us to forget about old buffering events if enough time goes by.
 | |
|     setTimeout(() => {
 | |
|       if (this.bufferingCounter > 0) {
 | |
|         this.bufferingCounter -= 1;
 | |
|       }
 | |
|     }, BUFFERING_AMNESTY_DURATION);
 | |
|   }
 | |
| 
 | |
|   handleBuffering() {
 | |
|     if (!this.enabled || this.inTimeout) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (this.jumpingToLiveIgnoreBuffer) {
 | |
|       this.jumpingToLiveIgnoreBuffer = false;
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.timeout();
 | |
| 
 | |
|     clearTimeout(this.bufferingTimer);
 | |
|     this.bufferingTimer = setTimeout(() => {
 | |
|       this.countBufferingEvent();
 | |
|     }, MIN_BUFFER_DURATION);
 | |
|   }
 | |
| }
 | |
| 
 | |
| export default LatencyCompensator;
 |