Simple, bandwidth-only adaptive streaming
Use the calculated bandwidth after every segment download to select the appropriate bitrate playlist. Allow playlist selection logic to be configurable at runtime. Add tests to verify adaptive behavior. Currently, switching is pretty choppy but testing with a higher goalBufferLength (for instance, 30 seconds) looked pretty good.
Showing
2 changed files
with
203 additions
and
7 deletions
... | @@ -14,6 +14,24 @@ var | ... | @@ -14,6 +14,24 @@ var |
14 | // the desired length of video to maintain in the buffer, in seconds | 14 | // the desired length of video to maintain in the buffer, in seconds |
15 | goalBufferLength = 5, | 15 | goalBufferLength = 5, |
16 | 16 | ||
17 | // a fudge factor to apply to advertised playlist bitrates to account for | ||
18 | // temporary flucations in client bandwidth | ||
19 | bandwidthVariance = 1.1, | ||
20 | |||
21 | playlistBandwidth = function(left, right) { | ||
22 | var leftBandwidth, rightBandwidth; | ||
23 | if (left.attributes && left.attributes.BANDWIDTH) { | ||
24 | leftBandwidth = left.attributes.BANDWIDTH; | ||
25 | } | ||
26 | leftBandwidth = leftBandwidth || window.Number.MAX_VALUE; | ||
27 | if (right.attributes && right.attributes.BANDWIDTH) { | ||
28 | rightBandwidth = right.attributes.BANDWIDTH; | ||
29 | } | ||
30 | rightBandwidth = rightBandwidth || window.Number.MAX_VALUE; | ||
31 | |||
32 | return leftBandwidth - rightBandwidth; | ||
33 | }, | ||
34 | |||
17 | /** | 35 | /** |
18 | * Constructs a new URI by interpreting a path relative to another | 36 | * Constructs a new URI by interpreting a path relative to another |
19 | * URI. | 37 | * URI. |
... | @@ -92,14 +110,49 @@ var | ... | @@ -92,14 +110,49 @@ var |
92 | return 1; // HAVE_METADATA | 110 | return 1; // HAVE_METADATA |
93 | }; | 111 | }; |
94 | 112 | ||
95 | |||
96 | /** | 113 | /** |
97 | * Chooses the appropriate media playlist based on the current | 114 | * Chooses the appropriate media playlist based on the current |
98 | * bandwidth estimate and the player size. | 115 | * bandwidth estimate and the player size. |
116 | * @return the highest bitrate playlist less than the currently detected | ||
117 | * bandwidth, accounting for some amount of bandwidth variance | ||
99 | */ | 118 | */ |
100 | player.hls.selectPlaylist = function() { | 119 | player.hls.selectPlaylist = function() { |
101 | player.hls.media = player.hls.master.playlists[0]; | 120 | var |
102 | player.hls.mediaIndex = 0; | 121 | bestVariant, |
122 | effectiveBitrate, | ||
123 | sortedPlaylists = player.hls.master.playlists.slice(), | ||
124 | i = sortedPlaylists.length, | ||
125 | variant; | ||
126 | |||
127 | sortedPlaylists.sort(playlistBandwidth); | ||
128 | |||
129 | while (i--) { | ||
130 | variant = sortedPlaylists[i]; | ||
131 | |||
132 | // ignore playlists without bandwidth information | ||
133 | if (!variant.attributes || !variant.attributes.BANDWIDTH) { | ||
134 | continue; | ||
135 | } | ||
136 | |||
137 | effectiveBitrate = variant.attributes.BANDWIDTH * bandwidthVariance; | ||
138 | |||
139 | // since the playlists are sorted in ascending order by bandwidth, the | ||
140 | // current variant is the best as long as its effective bitrate is | ||
141 | // below the current bandwidth estimate | ||
142 | if (effectiveBitrate < player.hls.bandwidth) { | ||
143 | bestVariant = variant; | ||
144 | break; | ||
145 | } | ||
146 | } | ||
147 | |||
148 | // console.log('bandwidth:', | ||
149 | // player.hls.bandwidth, | ||
150 | // 'variant:', | ||
151 | // (bestVariant || sortedPlaylists[0]).attributes.BANDWIDTH); | ||
152 | |||
153 | // if no acceptable variant was found, fall back on the lowest | ||
154 | // bitrate playlist | ||
155 | return bestVariant || sortedPlaylists[0]; | ||
103 | }; | 156 | }; |
104 | 157 | ||
105 | onDurationUpdate = function(value) { | 158 | onDurationUpdate = function(value) { |
... | @@ -111,7 +164,7 @@ var | ... | @@ -111,7 +164,7 @@ var |
111 | * URL is a master playlist, the default variant will be downloaded and | 164 | * URL is a master playlist, the default variant will be downloaded and |
112 | * parsed as well. Triggers `loadedmanifest` once for each playlist that is | 165 | * parsed as well. Triggers `loadedmanifest` once for each playlist that is |
113 | * downloaded and `loadedmetadata` after at least one media playlist has | 166 | * downloaded and `loadedmetadata` after at least one media playlist has |
114 | * been parsed. Whether multiple playlists were downloaded or not, after | 167 | * been parsed. Whether multiple playlists were downloaded or not, when |
115 | * `loadedmetadata` fires a parsed or inferred master playlist object will | 168 | * `loadedmetadata` fires a parsed or inferred master playlist object will |
116 | * be available as `player.hls.master`. | 169 | * be available as `player.hls.master`. |
117 | * | 170 | * |
... | @@ -141,6 +194,7 @@ var | ... | @@ -141,6 +194,7 @@ var |
141 | if (player.hls.master) { | 194 | if (player.hls.master) { |
142 | // merge this playlist into the master | 195 | // merge this playlist into the master |
143 | i = player.hls.master.playlists.length; | 196 | i = player.hls.master.playlists.length; |
197 | |||
144 | while (i--) { | 198 | while (i--) { |
145 | playlist = player.hls.master.playlists[i]; | 199 | playlist = player.hls.master.playlists[i]; |
146 | playlistUri = resolveUrl(srcUrl, playlist.uri); | 200 | playlistUri = resolveUrl(srcUrl, playlist.uri); |
... | @@ -156,9 +210,22 @@ var | ... | @@ -156,9 +210,22 @@ var |
156 | }; | 210 | }; |
157 | } | 211 | } |
158 | 212 | ||
159 | player.hls.selectPlaylist(); | 213 | // always start playback with the default rendition |
214 | if (!player.hls.media) { | ||
215 | player.hls.media = player.hls.master.playlists[0]; | ||
160 | player.trigger('loadedmanifest'); | 216 | player.trigger('loadedmanifest'); |
161 | player.trigger('loadedmetadata'); | 217 | player.trigger('loadedmetadata'); |
218 | return; | ||
219 | } | ||
220 | |||
221 | // select a playlist and download its metadata if necessary | ||
222 | playlist = player.hls.selectPlaylist(); | ||
223 | if (!playlist.segments) { | ||
224 | downloadPlaylist(resolveUrl(srcUrl, playlist.uri)); | ||
225 | } else { | ||
226 | player.hls.media = playlist; | ||
227 | } | ||
228 | player.trigger('loadedmanifest'); | ||
162 | } | 229 | } |
163 | }; | 230 | }; |
164 | xhr.send(null); | 231 | xhr.send(null); |
... | @@ -204,10 +271,12 @@ var | ... | @@ -204,10 +271,12 @@ var |
204 | segmentXhr.open('GET', segmentUri); | 271 | segmentXhr.open('GET', segmentUri); |
205 | segmentXhr.responseType = 'arraybuffer'; | 272 | segmentXhr.responseType = 'arraybuffer'; |
206 | segmentXhr.onreadystatechange = function() { | 273 | segmentXhr.onreadystatechange = function() { |
274 | var playlist; | ||
275 | |||
207 | if (segmentXhr.readyState === 4) { | 276 | if (segmentXhr.readyState === 4) { |
208 | // calculate the download bandwidth | 277 | // calculate the download bandwidth |
209 | player.hls.segmentXhrTime = (+new Date()) - startTime; | 278 | player.hls.segmentXhrTime = (+new Date()) - startTime; |
210 | player.hls.bandwidth = segmentXhr.response.byteLength / player.hls.segmentXhrTime; | 279 | player.hls.bandwidth = (segmentXhr.response.byteLength / player.hls.segmentXhrTime) * 8 * 1000; |
211 | 280 | ||
212 | // transmux the segment data from MP2T to FLV | 281 | // transmux the segment data from MP2T to FLV |
213 | segmentParser.parseSegmentBinaryData(new Uint8Array(segmentXhr.response)); | 282 | segmentParser.parseSegmentBinaryData(new Uint8Array(segmentXhr.response)); |
... | @@ -218,6 +287,15 @@ var | ... | @@ -218,6 +287,15 @@ var |
218 | 287 | ||
219 | segmentXhr = null; | 288 | segmentXhr = null; |
220 | player.hls.mediaIndex++; | 289 | player.hls.mediaIndex++; |
290 | |||
291 | // figure out what stream the next segment should be downloaded from | ||
292 | // with the updated bandwidth information | ||
293 | playlist = player.hls.selectPlaylist(); | ||
294 | if (!playlist.segments) { | ||
295 | downloadPlaylist(resolveUrl(srcUrl, playlist.uri)); | ||
296 | } else { | ||
297 | player.hls.media = playlist; | ||
298 | } | ||
221 | } | 299 | } |
222 | }; | 300 | }; |
223 | startTime = +new Date(); | 301 | startTime = +new Date(); |
... | @@ -234,6 +312,7 @@ var | ... | @@ -234,6 +312,7 @@ var |
234 | player.on('loadedmetadata', fillBuffer); | 312 | player.on('loadedmetadata', fillBuffer); |
235 | player.on('timeupdate', fillBuffer); | 313 | player.on('timeupdate', fillBuffer); |
236 | 314 | ||
315 | player.hls.mediaIndex = 0; | ||
237 | downloadPlaylist(srcUrl); | 316 | downloadPlaylist(srcUrl); |
238 | }); | 317 | }); |
239 | player.src({ | 318 | player.src({ | ... | ... |
... | @@ -162,7 +162,6 @@ test('downloads media playlists after loading the master', function() { | ... | @@ -162,7 +162,6 @@ test('downloads media playlists after loading the master', function() { |
162 | type: 'sourceopen' | 162 | type: 'sourceopen' |
163 | }); | 163 | }); |
164 | 164 | ||
165 | strictEqual(xhrUrls.length, 3, 'three requests were made'); | ||
166 | strictEqual(xhrUrls[0], 'manifest/master.m3u8', 'master playlist requested'); | 165 | strictEqual(xhrUrls[0], 'manifest/master.m3u8', 'master playlist requested'); |
167 | strictEqual(xhrUrls[1], | 166 | strictEqual(xhrUrls[1], |
168 | window.location.origin + | 167 | window.location.origin + |
... | @@ -189,6 +188,124 @@ test('calculates the bandwidth after downloading a segment', function() { | ... | @@ -189,6 +188,124 @@ test('calculates the bandwidth after downloading a segment', function() { |
189 | 'saves segment request time: ' + player.hls.segmentXhrTime + 's'); | 188 | 'saves segment request time: ' + player.hls.segmentXhrTime + 's'); |
190 | }); | 189 | }); |
191 | 190 | ||
191 | test('selects a playlist after segment downloads', function() { | ||
192 | var calls = 0; | ||
193 | player.hls('manifest/master.m3u8'); | ||
194 | player.hls.selectPlaylist = function() { | ||
195 | calls++; | ||
196 | return player.hls.master.playlists[0]; | ||
197 | }; | ||
198 | videojs.mediaSources[player.currentSrc()].trigger({ | ||
199 | type: 'sourceopen' | ||
200 | }); | ||
201 | |||
202 | strictEqual(calls, 1, 'selects after the initial segment'); | ||
203 | player.currentTime = function() { | ||
204 | return 1; | ||
205 | }; | ||
206 | player.buffered = function() { | ||
207 | return videojs.createTimeRange(0, 2); | ||
208 | }; | ||
209 | player.trigger('timeupdate'); | ||
210 | strictEqual(calls, 2, 'selects after additional segments'); | ||
211 | }); | ||
212 | |||
213 | test('downloads additional playlists if required', function() { | ||
214 | var | ||
215 | called = false, | ||
216 | playlist = { | ||
217 | uri: 'media3.m3u8' | ||
218 | }; | ||
219 | player.hls('manifest/master.m3u8'); | ||
220 | videojs.mediaSources[player.currentSrc()].trigger({ | ||
221 | type: 'sourceopen' | ||
222 | }); | ||
223 | |||
224 | // before an m3u8 is downloaded, no segments are available | ||
225 | player.hls.selectPlaylist = function() { | ||
226 | if (!called) { | ||
227 | called = true; | ||
228 | return playlist; | ||
229 | } | ||
230 | playlist.segments = []; | ||
231 | return playlist; | ||
232 | }; | ||
233 | xhrUrls = []; | ||
234 | |||
235 | // the playlist selection is revisited after a new segment is downloaded | ||
236 | player.currentTime = function() { | ||
237 | return 1; | ||
238 | }; | ||
239 | player.trigger('timeupdate'); | ||
240 | |||
241 | strictEqual(2, xhrUrls.length, 'requests were made'); | ||
242 | strictEqual(xhrUrls[1], | ||
243 | window.location.origin + | ||
244 | window.location.pathname.split('/').slice(0, -1).join('/') + | ||
245 | '/manifest/' + | ||
246 | playlist.uri, | ||
247 | 'made playlist request'); | ||
248 | strictEqual(playlist, player.hls.media, 'a new playlists was selected'); | ||
249 | ok(player.hls.media.segments, 'segments are now available'); | ||
250 | }); | ||
251 | |||
252 | test('selects a playlist below the current bandwidth', function() { | ||
253 | var playlist; | ||
254 | player.hls('manifest/master.m3u8'); | ||
255 | videojs.mediaSources[player.currentSrc()].trigger({ | ||
256 | type: 'sourceopen' | ||
257 | }); | ||
258 | |||
259 | // the default playlist has a really high bitrate | ||
260 | player.hls.master.playlists[0].attributes.BANDWIDTH = 9e10; | ||
261 | // playlist 1 has a very low bitrate | ||
262 | player.hls.master.playlists[1].attributes.BANDWIDTH = 1; | ||
263 | // but the detected client bandwidth is really low | ||
264 | player.hls.bandwidth = 10; | ||
265 | |||
266 | playlist = player.hls.selectPlaylist(); | ||
267 | strictEqual(playlist, | ||
268 | player.hls.master.playlists[1], | ||
269 | 'the low bitrate stream is selected'); | ||
270 | }); | ||
271 | |||
272 | test('raises the minimum bitrate for a stream proportionially', function() { | ||
273 | var playlist; | ||
274 | player.hls('manifest/master.m3u8'); | ||
275 | videojs.mediaSources[player.currentSrc()].trigger({ | ||
276 | type: 'sourceopen' | ||
277 | }); | ||
278 | |||
279 | // the default playlist's bandwidth + 10% is equal to the current bandwidth | ||
280 | player.hls.master.playlists[0].attributes.BANDWIDTH = 10; | ||
281 | player.hls.bandwidth = 11; | ||
282 | |||
283 | // 9.9 * 1.1 < 11 | ||
284 | player.hls.master.playlists[1].attributes.BANDWIDTH = 9.9; | ||
285 | playlist = player.hls.selectPlaylist(); | ||
286 | |||
287 | strictEqual(playlist, | ||
288 | player.hls.master.playlists[1], | ||
289 | 'a lower bitrate stream is selected'); | ||
290 | }); | ||
291 | |||
292 | test('uses the lowest bitrate if no other is suitable', function() { | ||
293 | var playlist; | ||
294 | player.hls('manifest/master.m3u8'); | ||
295 | videojs.mediaSources[player.currentSrc()].trigger({ | ||
296 | type: 'sourceopen' | ||
297 | }); | ||
298 | |||
299 | // the lowest bitrate playlist is much greater than 1b/s | ||
300 | player.hls.bandwidth = 1; | ||
301 | playlist = player.hls.selectPlaylist(); | ||
302 | |||
303 | // playlist 1 has the lowest advertised bitrate | ||
304 | strictEqual(playlist, | ||
305 | player.hls.master.playlists[1], | ||
306 | 'the lowest bitrate stream is selected'); | ||
307 | }); | ||
308 | |||
192 | test('does not download the next segment if the buffer is full', function() { | 309 | test('does not download the next segment if the buffer is full', function() { |
193 | player.hls('manifest/media.m3u8'); | 310 | player.hls('manifest/media.m3u8'); |
194 | player.currentTime = function() { | 311 | player.currentTime = function() { | ... | ... |
-
Please register or sign in to post a comment