用 JavaScript 编写 MPEG1 解码器
chenjs001
7年前
<p>几年前,我开始从事于完全用JavaScript编写的MPEG1视频解码器上。现在,我终于找到了清理该库的时间,改善其性能、使其具有更高的错误恢复能力和模块化能力,并添加MP2音频解码器和MPEG-TS解析器。这使得该库不仅仅是一个MPEG解码器,而是一个完整的视频播放器。</p> <p>在本篇博文中,我想谈一谈我在开发这个库时遇到的挑战和各种有趣的事情。你将在官方网站上找到demo、源代码和文档以及为什么要使用JSMpeg:</p> <p>最近,我需要为一位客户在JSMpeg中实现音频流传输,然后我才意识到该库处于一种多么可怜的状态。从其首次发布以来,它已经有很多发展了。在过去的几年里,WebGL渲染器、WebSocket客户端、渐进式加载、基准测试设备等等已被加入。但所有这些都保存在一个单一的、庞大的类中,条件判断随处可见。</p> <p>我决定首先通过分离它的逻辑组件来梳理清楚其中的混乱。我还总结了完成实现需要哪些:解复用器、MP2解码器和音频输出:</p> <ul> <li> <p>源代码(Sources): AJAX, 渐进式AJAX和WebSocket</p> </li> <li> <p>解复用器(Demuxer): MPEG-TS (Transport Stream)</p> </li> <li> <p>解码器(Decoder): MPEG1视频& MP2音频</p> </li> <li> <p>渲染器(Render): Canvas2D & WebGL</p> </li> <li> <p>音频输出:WebAudio</p> </li> </ul> <p>加上一些辅助类:</p> <ul> <li> <p>一个位缓存(Bit Buffer),用于管理原始数据</p> </li> <li> <p>一个播放器(Player),整合其他组件</p> </li> </ul> <p>每个组件(除了Sources之外)都有一个.write(buffer)方法来为其提供数据。这些组件可以“连接”到接收处理结果的目标组件上。流经该库的完整流程如下所示:</p> <pre> <code class="language-javascript"> / -> MPEG1 Video Decoder -> Renderer Source -> Demuxer \ -> MP2 Audio Decoder -> Audio Output</code></pre> <p>JSMpeg目前有3种不同的Source实现(AJAX\AJAX渐进式和WebSocket),还有2种不同的渲染器(Canvas2D和WebGL)。该库的其他部分对这此并不了解 - 即视频解码器不关心渲染器内部逻辑。采用这种方法可以轻松添加新的组件:更多的Source,解复用器,解码器或输出。</p> <p>我对这些连接在库中的工作方式并不完全满意。每个组件只能有一个目标组件(除了多路解复用器,每个流有都有一个目标组件)。这是一个折衷。最后,我觉得:其他部分会因为没有充分的理由而过度工程设计并使得库过于复杂化。</p> <h3>WebGL渲染</h3> <p>MPEG1解码器中计算密集度最高的任务之一是将MPEG内部的YUV格式(准确地说是Y'Cr'Cb)转换为RGBA,以便浏览器可以显示它。简而言之,这个转换看起来像这样:</p> <pre> <code class="language-javascript">for (var i = 0; i < pixels.length; i+=4 ) { var y, cb, cr = /* fetch this from the YUV buffers */; pixels[i + 0 /* R */] = y + (cb + ((cb * 103) >> 8)) - 179; pixels[i + 1 /* G */] = y - ((cr * 88) >> 8) - 44 + ((cb * 183) >> 8) - 91; pixels[i + 2 /* B */] = y + (cr + ((cr * 198) >> 8)) - 227; pixels[i + 4 /* A */] = 255; }</code></pre> <p>对于单个1280x720视频帧,该循环必须执行921600次以将所有像素从YUV转换为RGBA。每个像素需要对目标RGB数组写入3次(我们可以预先填充alpha组件,因为它始终是255)。这是每帧270万次写入操作,每次需要5-8次加、减、乘和位移运算。对于一个60fps的视频,我们 <strong>每秒钟完成10亿次以上的操作</strong> 。再加上JavaScript的开销。JavaScript可以做到这一点,计算机可以做到这一点,这一事实仍然让我大开眼界。</p> <p>使用 WebGL ,这种颜色转换(以及随后在屏幕上显示)可以大大加快。逐像素的少量操作对 GPU 而言是小菜一碟。GPU 可以并行处理多个像素,因为它们是独立于任何其他像素的。运行在 GPU 上的 WebGL 着色器(shader)甚至不需要这些烦人的位移 - GPU 喜欢浮点数:</p> <pre> <code class="language-javascript">void main() { float y = texture2D(textureY, texCoord).r; float cb = texture2D(textureCb, texCoord).r - 0.5; float cr = texture2D(textureCr, texCoord).r - 0.5; gl_FragColor = vec4( y + 1.4 * cb, y + -0.343 * cr - 0.711 * cb, y + 1.765 * cr, 1.0 ); }</code></pre> <p>使用 WebGL,颜色转换所需的时间从 JS 总时间的 50% 下降到仅需 YUV 纹理上传时间的约 1% 。</p> <p>我遇到了一个与 WebGL 渲染器偶然相关的小问题。JSMpeg 的视频解码器不会为每个颜色平面生成三个 Uint8Arrays ,而是一个 Uint8ClampedArrays 。它是这样做的,因为 MPEG1 标准规定解码的颜色值必须是紧凑的,而不是分散的。让浏览器通过 ClampedArray 进行交织比在 JavaScript 中执行更快。</p> <p>依然存在于某些浏览器(Chrome和Safari)中的缺陷会阻止WebGL直接使用Uint8ClampedArray。因此,对于这些浏览器,我们必须为每个帧的每个数组创建一个Uint8Array视图。这个操作非常快,因为没有需要真实复制的事情,但我仍然希望不使用它。</p> <p>JSMpeg会检测到这个错误,并仅在需要时使用该解决方法。我们只是尝试上传一个固定数组并捕获此错误。令人遗憾的是,这种检测会触发控制台中的一个非静默的警告,但这总比没有好吧。</p> <pre> <code class="language-javascript">WebGLRenderer.prototype.allowsClampedTextureData = function() { var gl = this.gl; var texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, 1, 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, new Uint8ClampedArray([0]) ); return (gl.getError() === 0); };</code></pre> <h3>对直播流媒体的WebAudio</h3> <p>很长一段时间里,我假设为了向WebAudio提供原始PCM样本数据而没有太多延迟或爆破音,你需要使用ScriptProcessorNode。只要你从脚本处理器获得回调,你就可以及时复制解码后的采样数据。这确实有效。我试过这个方法。它需要相当多的代码才能正常工作,当然这是计算密集型和不优雅的作法。</p> <p>幸运的是,我最初的假设是错误的。</p> <p>WebAudio上下文维护自己的计时器,它有别于JavaScript的Date.now()或performance.now()。 此外,你可以根据上下文的时间指导你的WebAudio源在未来的准确时间调用start()。有了这个,你可以将非常短的PCM缓冲器串在一起,而不会有任何瑕疵。</p> <p>你只需计算下一个缓冲区的开始时间,就可以连续添加所有之前的缓冲区的时间。总是使用 WebAudio Context 自己的时间来做这件事是很重要的。</p> <pre> <code class="language-javascript">var currentStartTime = 0; function playBuffer(buffer) { var source = context.createBufferSource(); /* load buffer, set destination etc. */ var now = context.currentTime; if (currentStartTime < now) { currentStartTime = now; } source.start(currentStartTime); currentStartTime += buffer.duration; }</code></pre> <p>不过需要注意的是:我需要获得队列音频的精确剩余时间。我只是简单地将它作为当前时间和下一个启动时间的区别来实现:</p> <pre> <code class="language-javascript">// Don't do that! var enqueuedTime = (currentStartTime - context.currentTime);</code></pre> <p>我花了一段时间才弄明白,这行不通。你可以看到,上下文的 currentTime 只是每隔一段时间才更新一次。它不是一个精确的实时值。</p> <pre> <code class="language-javascript">var t1 = context.currentTime; doSomethingForAWhile(); var t2 = context.currentTime; t1 === t2; // true</code></pre> <p>因此,如果需要精确的音频播放位置(或者基于它的任何内容),你必须恢复到 JavaScript 的 performance.now() 方法。</p> <h3>iOS 上的音频解锁</h3> <p>你将要爱上苹果时不时扔到 Web 开发人员脸上的麻烦。其中之一就是在播放任何内容之前都需要在页面上解锁音频。总的来说,音频播放只能作为对用户操作的响应而启动。你点击了一个按钮,音频则播放了。</p> <p>这是有道理的。我不反驳它。当你访问某个网页时,你不希望在未经通知的情况下发出声音。</p> <p>是什么让它变得糟糕透顶呢?是因为苹果公司既没有提供一种利索的解锁音频的方法,也没有提供一种方法来查询 WebAudio Context 是否已经解锁。你所要做的就是播放一个音频源并不断检查是否正在顺序播放。尽管如此,在播放之后你还不能马上检查。是的,你必须等一会!</p> <pre> <code class="language-javascript">WebAudioOut.prototype.unlock = function(callback) { // This needs to be called in an onclick or ontouchstart handler! this.unlockCallback = callback; // Create empty buffer and play it var buffer = this.context.createBuffer(1, 1, 22050); var source = this.context.createBufferSource(); source.buffer = buffer; source.connect(this.destination); source.start(0); setTimeout(this.checkIfUnlocked.bind(this, source, 0), 0); }; WebAudioOut.prototype.checkIfUnlocked = function(source, attempt) { if ( source.playbackState === source.PLAYING_STATE || source.playbackState === source.FINISHED_STATE ) { this.unlocked = true; this.unlockCallback(); } else if (attempt < 10) { // Jeez, what a shit show. Thanks iOS! setTimeout(this.checkIfUnlocked.bind(this, source, attempt+1), 100); } };</code></pre> <p> </p> <p>来自:https://www.oschina.net/translate/decode-it-like-its-1999</p> <p> </p>