|
| 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 | +} |
0 commit comments