Skip to content

Commit 471a6bc

Browse files
authored
Merge pull request #530 from chcunningham/audio-video-player-sample
Add audio-video-player sample
2 parents 5f1db1e + ed8b2e5 commit 471a6bc

21 files changed

+1610
-12
lines changed

samples/_headers

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/*
2+
Cross-Origin-Opener-Policy: same-origin
3+
Cross-Origin-Embedder-Policy: require-corp
4+
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { MP4PullDemuxer } from "./mp4_pull_demuxer.js";
2+
import { RingBuffer } from "./third_party/ringbufjs/ringbuf.js";
3+
4+
const DATA_BUFFER_DECODE_TARGET_DURATION = 0.3;
5+
const DATA_BUFFER_DURATION = 0.6;
6+
const DECODER_QUEUE_SIZE_MAX = 5;
7+
const ENABLE_DEBUG_LOGGING = false;
8+
9+
function debugLog(msg) {
10+
if (!ENABLE_DEBUG_LOGGING) {
11+
return;
12+
}
13+
console.debug(msg);
14+
}
15+
16+
export class AudioRenderer {
17+
async initialize(fileUri) {
18+
this.fillInProgress = false;
19+
this.playing = false;
20+
21+
this.demuxer = new MP4PullDemuxer(fileUri);
22+
23+
let trackInfo = await this.demuxer.getAudioTrackInfo();
24+
this.demuxer.selectAudio();
25+
26+
this.decoder = new AudioDecoder({
27+
output: this.bufferAudioData.bind(this),
28+
error: e => console.error(e)
29+
});
30+
const config = {
31+
codec: trackInfo.codec,
32+
sampleRate: trackInfo.sampleRate,
33+
numberOfChannels: trackInfo.numberOfChannels,
34+
description: trackInfo.extradata
35+
};
36+
this.sampleRate = trackInfo.sampleRate;
37+
this.channelCount = trackInfo.numberOfChannels;
38+
39+
debugLog(config);
40+
41+
console.assert(AudioDecoder.isConfigSupported(config));
42+
this.decoder.configure(config);
43+
44+
// Initialize the ring buffer between the decoder and the real-time audio
45+
// rendering thread. The AudioRenderer has buffer space for approximately
46+
// 500ms of decoded audio ahead.
47+
let sampleCountIn500ms =
48+
DATA_BUFFER_DURATION * this.sampleRate * trackInfo.numberOfChannels;
49+
let sab = RingBuffer.getStorageForCapacity(
50+
sampleCountIn500ms,
51+
Float32Array
52+
);
53+
this.ringbuffer = new RingBuffer(sab, Float32Array);
54+
this.interleavingBuffers = [];
55+
56+
this.init_resolver = null;
57+
let promise = new Promise(resolver => (this.init_resolver = resolver));
58+
59+
this.fillDataBuffer();
60+
return promise;
61+
}
62+
63+
play() {
64+
// resolves when audio has effectively started: this can take some time if using
65+
// bluetooth, for example.
66+
debugLog("playback start");
67+
this.playing = true;
68+
this.fillDataBuffer();
69+
}
70+
71+
pause() {
72+
debugLog("playback stop");
73+
this.playing = false;
74+
}
75+
76+
makeChunk(sample) {
77+
const type = sample.is_sync ? "key" : "delta";
78+
const pts_us = (sample.cts * 1000000) / sample.timescale;
79+
const duration_us = (sample.duration * 1000000) / sample.timescale;
80+
return new EncodedAudioChunk({
81+
type: type,
82+
timestamp: pts_us,
83+
duration: duration_us,
84+
data: sample.data
85+
});
86+
}
87+
88+
async fillDataBuffer() {
89+
// This method is called from multiple places to ensure the buffer stays
90+
// healthy. Sometimes these calls may overlap, but at any given point only
91+
// one call is desired.
92+
if (this.fillInProgress)
93+
return;
94+
95+
this.fillInProgress = true;
96+
// This should be this file's ONLY call to the *Internal() variant of this method.
97+
await this.fillDataBufferInternal();
98+
this.fillInProgress = false;
99+
}
100+
101+
async fillDataBufferInternal() {
102+
debugLog(`fillDataBufferInternal()`);
103+
104+
if (this.decoder.decodeQueueSize >= DECODER_QUEUE_SIZE_MAX) {
105+
debugLog('\tdecoder saturated');
106+
// Some audio decoders are known to delay output until the next input.
107+
// Make sure the DECODER_QUEUE_SIZE is big enough to avoid stalling on the
108+
// return below. We're relying on decoder output callback to trigger
109+
// another call to fillDataBuffer().
110+
console.assert(DECODER_QUEUE_SIZE_MAX >= 2);
111+
return;
112+
}
113+
114+
let usedBufferElements = this.ringbuffer.capacity() - this.ringbuffer.available_write();
115+
let usedBufferSecs = usedBufferElements / (this.channelCount * this.sampleRate);
116+
let pcntOfTarget = 100 * usedBufferSecs / DATA_BUFFER_DECODE_TARGET_DURATION;
117+
if (usedBufferSecs >= DATA_BUFFER_DECODE_TARGET_DURATION) {
118+
debugLog(`\taudio buffer full usedBufferSecs: ${usedBufferSecs} pcntOfTarget: ${pcntOfTarget}`);
119+
120+
// When playing, schedule timeout to periodically refill buffer. Don't
121+
// bother scheduling timeout if decoder already saturated. The output
122+
// callback will call us back to keep filling.
123+
if (this.playing)
124+
// Timeout to arrive when buffer is half empty.
125+
setTimeout(this.fillDataBuffer.bind(this), 1000 * usedBufferSecs / 2);
126+
127+
// Initialize() is done when the buffer fills for the first time.
128+
if (this.init_resolver) {
129+
this.init_resolver();
130+
this.init_resolver = null;
131+
}
132+
133+
// Buffer full, so no further work to do now.
134+
return;
135+
}
136+
137+
// Decode up to the buffering target or until decoder is saturated.
138+
while (usedBufferSecs < DATA_BUFFER_DECODE_TARGET_DURATION &&
139+
this.decoder.decodeQueueSize < DECODER_QUEUE_SIZE_MAX) {
140+
debugLog(`\tMore samples. usedBufferSecs:${usedBufferSecs} < target:${DATA_BUFFER_DECODE_TARGET_DURATION}.`);
141+
let sample = await this.demuxer.readSample();
142+
this.decoder.decode(this.makeChunk(sample));
143+
144+
// NOTE: awaiting the demuxer.readSample() above will also give the
145+
// decoder output callbacks a chance to run, so we may see usedBufferSecs
146+
// increase.
147+
usedBufferElements = this.ringbuffer.capacity() - this.ringbuffer.available_write();
148+
usedBufferSecs = usedBufferElements / (this.channelCount * this.sampleRate);
149+
}
150+
151+
if (ENABLE_DEBUG_LOGGING) {
152+
let logPrefix = usedBufferSecs >= DATA_BUFFER_DECODE_TARGET_DURATION ?
153+
'\tbuffered enough' : '\tdecoder saturated';
154+
pcntOfTarget = 100 * usedBufferSecs / DATA_BUFFER_DECODE_TARGET_DURATION;
155+
debugLog(logPrefix + `; bufferedSecs:${usedBufferSecs} pcntOfTarget: ${pcntOfTarget}`);
156+
}
157+
}
158+
159+
bufferHealth() {
160+
return (1 - this.ringbuffer.available_write() / this.ringbuffer.capacity()) * 100;
161+
}
162+
163+
// From a array of Float32Array containing planar audio data `input`, writes
164+
// interleaved audio data to `output`. Start the copy at sample
165+
// `inputOffset`: index of the sample to start the copy from
166+
// `inputSamplesToCopy`: number of input samples to copy
167+
// `output`: a Float32Array to write the samples to
168+
// `outputSampleOffset`: an offset in `output` to start writing
169+
interleave(inputs, inputOffset, inputSamplesToCopy, output, outputSampleOffset) {
170+
if (inputs.length * inputs[0].length < output.length) {
171+
throw `not enough space in destination (${inputs.length * inputs[0].length} < ${output.length}})`
172+
}
173+
let channelCount = inputs.length;
174+
let outIdx = outputSampleOffset;
175+
let inputIdx = Math.floor(inputOffset / channelCount);
176+
var channel = inputOffset % channelCount;
177+
for (var i = 0; i < inputSamplesToCopy; i++) {
178+
output[outIdx++] = inputs[channel][inputIdx];
179+
if (++channel == inputs.length) {
180+
channel = 0;
181+
inputIdx++;
182+
}
183+
}
184+
}
185+
186+
bufferAudioData(data) {
187+
if (this.interleavingBuffers.length != data.numberOfChannels) {
188+
this.interleavingBuffers = new Array(this.channelCount);
189+
for (var i = 0; i < this.interleavingBuffers.length; i++) {
190+
this.interleavingBuffers[i] = new Float32Array(data.numberOfFrames);
191+
}
192+
}
193+
194+
debugLog(`bufferAudioData() ts:${data.timestamp} durationSec:${data.duration / 1000000}`);
195+
// Write to temporary planar arrays, and interleave into the ring buffer.
196+
for (var i = 0; i < this.channelCount; i++) {
197+
data.copyTo(this.interleavingBuffers[i], { planeIndex: i });
198+
}
199+
// Write the data to the ring buffer. Because it wraps around, there is
200+
// potentially two copyTo to do.
201+
let wrote = this.ringbuffer.writeCallback(
202+
data.numberOfFrames * data.numberOfChannels,
203+
(first_part, second_part) => {
204+
this.interleave(this.interleavingBuffers, 0, first_part.length, first_part, 0);
205+
this.interleave(this.interleavingBuffers, first_part.length, second_part.length, second_part, 0);
206+
}
207+
);
208+
209+
// FIXME - this could theoretically happen since we're pretty agressive
210+
// about saturating the decoder without knowing the size of the
211+
// AudioData.duration vs ring buffer capacity.
212+
console.assert(wrote == data.numberOfChannels * data.numberOfFrames, 'Buffer full, dropping data!')
213+
214+
// Logging maxBufferHealth below shows we currently max around 73%, so we're
215+
// safe from the assert above *for now*. We should add an overflow buffer
216+
// just to be safe.
217+
// let bufferHealth = this.bufferHealth();
218+
// if (!('maxBufferHealth' in this))
219+
// this.maxBufferHealth = 0;
220+
// if (bufferHealth > this.maxBufferHealth) {
221+
// this.maxBufferHealth = bufferHealth;
222+
// console.log(`new maxBufferHealth:${this.maxBufferHealth}`);
223+
// }
224+
225+
// fillDataBuffer() gives up if too much decode work is queued. Keep trying
226+
// now that we've finished some.
227+
this.fillDataBuffer();
228+
}
229+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<!doctype html>
2+
<style>
3+
body {
4+
font-family: sans-serif;
5+
color: #444;
6+
font-weight: 300;
7+
font-size: larger;
8+
}
9+
button {
10+
font-size: larger;
11+
}
12+
#controls {
13+
margin-bottom: 10px;
14+
}
15+
#loading {
16+
font-size: 2em;
17+
}
18+
.monospace {
19+
font-family: monospace;
20+
}
21+
div#container {
22+
margin: 0 auto 0 auto;
23+
max-width: 60em;
24+
padding: 1em 1.5em 1.3em 1.5em;
25+
}
26+
canvas {
27+
outline: 1px solid black;
28+
}
29+
</style>
30+
<div id=container>
31+
<p>
32+
This sample combines WebCodecs and WebAudio to create a media player that
33+
renders synchronized audio and video.
34+
</p>
35+
<p>
36+
Check out the <a href='../video-decode-display/'>Video Decoding and Display
37+
</a> demo for a simpler introduction to video decoding and rendering. View
38+
<a href='https://youtu.be/U8T5U8sN5d4?t=1572'>this video presentation</a>
39+
for an overview of audio rendering stack.
40+
</p>
41+
<p>
42+
This sample requires <a href='https://web.dev/cross-origin-isolation-guide'>
43+
cross origin isolation</a> to use
44+
<span class='monospace'>SharedArrayBuffer</span>. You may use
45+
<span class='monospace'>node server.js</span> to host this sample locally
46+
with the appropriate HTTP headers.
47+
</p>
48+
<div id=controls>
49+
<p id=loading>Loading...</p>
50+
<button disabled=true>Play</button>
51+
<label for=volume>Volume</label>
52+
<input id=volume type=range value=0.8 min=0 max=1.0 step=0.01></input>
53+
</div>
54+
<canvas width=1280 height=720></canvas>
55+
</div>
56+
<script type="module">
57+
import { WebAudioController } from "./web_audio_controller.js";
58+
59+
// Transfer canvas to offscreen. Painting will be performed by worker without
60+
// blocking the Window main thread.
61+
window.$ = document.querySelector.bind(document);
62+
let canvas = $("canvas");
63+
let offscreenCanvas = canvas.transferControlToOffscreen();
64+
65+
// Instantiate the "media worker" and start loading the files. The worker will
66+
// house and drive the demuxers and decoders.
67+
let mediaWorker = new Worker('./media_worker.js');
68+
mediaWorker.postMessage({command: 'initialize',
69+
audioFile: 'bbb_audio_aac_frag.mp4',
70+
videoFile: 'bbb_video_avc_frag.mp4',
71+
canvas: offscreenCanvas},
72+
{transfer: [offscreenCanvas]});
73+
74+
// Wait for worker initialization. Use metadata to init the WebAudioController.
75+
let initResolver = null;
76+
let initDone = new Promise(resolver => (initResolver = resolver));
77+
let audioController = new WebAudioController();
78+
mediaWorker.addEventListener('message', (e) => {
79+
console.assert(e.data.command == 'initialize-done');
80+
audioController.initialize(e.data.sampleRate, e.data.channelCount,
81+
e.data.sharedArrayBuffer);
82+
initResolver();
83+
initResolver = null;
84+
});
85+
await initDone;
86+
87+
// Set up volume slider.
88+
$('#volume').onchange = (e) => { audioController.setVolume(e.target.value); }
89+
90+
// Enable play now that we're loaded
91+
let playButton = $('button');
92+
let loadingElement = $('#loading');
93+
playButton.disabled = false;
94+
loadingElement.innerText = 'Ready! Click play.'
95+
96+
playButton.onclick = () => {
97+
if (playButton.innerText == "Play") {
98+
console.log("playback start");
99+
100+
// Audio can only start in reaction to a user-gesture.
101+
audioController.play().then(() => console.log('playback started'));
102+
mediaWorker.postMessage({
103+
command: 'play',
104+
mediaTimeSecs: audioController.getMediaTimeInSeconds(),
105+
mediaTimeCapturedAtHighResTimestamp:
106+
performance.now() + performance.timeOrigin
107+
});
108+
109+
sendMediaTimeUpdates(true);
110+
111+
playButton.innerText = "Pause";
112+
113+
} else {
114+
console.log("playback pause");
115+
// Resolves when audio has effectively stopped, this can take some time if
116+
// using bluetooth, for example.
117+
audioController.pause().then(() => { console.log("playback paused");
118+
// Wait to pause worker until context suspended to ensure we continue
119+
// filling audio buffer while audio is playing.
120+
mediaWorker.postMessage({command: 'pause'});
121+
});
122+
123+
sendMediaTimeUpdates(false);
124+
125+
playButton.innerText = "Play"
126+
}
127+
}
128+
129+
// Helper function to periodically send the current media time to the media
130+
// worker. Ideally we would instead compute the media time on the worker thread,
131+
// but this requires WebAudio interfaces to be exposed on the WorkerGlobalScope.
132+
// See https://github.com/WebAudio/web-audio-api/issues/2423
133+
let mediaTimeUpdateInterval = null;
134+
function sendMediaTimeUpdates(enabled) {
135+
if (enabled) {
136+
// Local testing shows this interval (1 second) is frequent enough that the
137+
// estimated media time between updates drifts by less than 20 msec. Lower
138+
// values didn't produce meaningfully lower drift and have the downside of
139+
// waking up the main thread more often. Higher values could make av sync
140+
// glitches more noticeable when changing the output device.
141+
const UPDATE_INTERVAL = 1000;
142+
mediaTimeUpdateInterval = setInterval(() => {
143+
mediaWorker.postMessage({
144+
command: 'update-media-time',
145+
mediaTimeSecs: audioController.getMediaTimeInSeconds(),
146+
mediaTimeCapturedAtHighResTimestamp:
147+
performance.now() + performance.timeOrigin
148+
});
149+
}, UPDATE_INTERVAL);
150+
} else {
151+
clearInterval(mediaTimeUpdateInterval);
152+
mediaTimeUpdateInterval = null;
153+
}
154+
}
155+
</script>
156+
</html>

0 commit comments

Comments
 (0)