背景 在 Web 应用开发中,实时音频通信越来越普遍,比如在线语音聊天、实时音频直播等场景。Opus 作为一种高效的音频编码格式,以其出色的音质和较低的带宽消耗,成为了众多音频应用的首选编码方案。本文将深入探讨如何使用 JavaScript 在浏览器中实现音频流的 Opus 编码数据传输,让你能够快速将这一技术集成到自己的 Web 项目中。
Opus 编码简介 Opus 是一种完全开放、免专利费的音频编码格式,由互联网工程任务组(IETF)开发。它可以在非常低的比特率下,提供高质量的音频,适用于从语音到全带宽音频的各种应用场景。相较于其他音频编码格式,Opus 在编码效率和音质之间达到了很好的平衡,因此被广泛应用于实时通信、音频录制和流媒体传输等领域。
实现步骤 1. 编写一个一个简单的音频流传输服务 首先,我们需要一个简单的音频流传输服务,用于模拟音频数据的传输过程。在这个例子中,我们将使用 WebSocket 来实现音频流的传输。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 var https = require ('https' )var fs = require ('fs' )var path = require ('path' )var WebSocketServer = require ('ws' ).Servervar wsPort = 5001 var listeners = {}var httpsServer = https .createServer( { key : fs.readFileSync('ssl.key' ), cert : fs.readFileSync('ssl.crt' ), }, (req, res ) => { const staticDir = path.join(__dirname, './' ) let filePath = path.join(staticDir, req.url === '/' ? 'index.html' : req.url) fs.access(filePath, fs.constants.F_OK, (err ) => { if (err) { res.writeHead(404 ) res.end('File not found' ) return } fs.readFile(filePath, (err, content ) => { if (err) { res.writeHead(500 ) res.end('Server error' ) return } res.writeHead(200 , { 'Content-Type' : getContentType(filePath) }) res.end(content) }) }) } ) .listen(wsPort) var wss = new WebSocketServer({ server : httpsServer }, function (e ) { console .log('callback' , e) }) wss.on('connection' , function (ws, req ) { var connectionId = req.headers['sec-websocket-key' ] listeners[connectionId] = ws console .log('Listener connected' ) ws.on('message' , function (message ) { for (var cid in listeners) { if (cid === connectionId) { continue } listeners[cid].send( message, { binary : true , }, function (err ) { if (err) { console .log('Error: ' , err) } } ) } }) ws.on('close' , function ( ) { delete listeners[connectionId] console .log('Listener disconnected' ) }) }) wss.on('error' , console .error) function getContentType (filePath ) { const ext = path.extname(filePath) switch (ext) { case '.html' : return 'text/html' case '.css' : return 'text/css' case '.js' : return 'text/javascript' case '.json' : return 'application/json' case '.png' : return 'image/png' case '.jpg' : return 'image/jpeg' case '.wasm' : return 'application/wasm' default : return 'text/plain' } } console .log('Listening on port:' , wsPort)
2. Opus编解码 在浏览器端,我们需要使用 Opus 编解码器来对音频流进行处理。Opus 是一个 WebAssembly 模块,我们可以通过 JavaScript 调用它来实现音频的 Opus 编解码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 importScripts('./opus.js' ) function float32ToInt16 (i ) { return Math .max(-32768 , Math .min(32767 , i * 32767 )) } function int16ToFloat32 (i ) { return i / 32767 } Module().then((OpusModule ) => { let outputBuffer = [] let inputBuffer = [] let decoder = null let encoder = null self.onmessage = function (ev ) { const { cmd, codec, data } = ev.data const { channels, sampleRate, frameSize, bufferSize, app } = codec switch (cmd) { case 'init' : decoder = OpusModule._opus_decoder_create(sampleRate, channels) encoder = OpusModule._opus_encoder_create(sampleRate, channels, app) if (decoder && encoder) { self.postMessage({ cmd : 'inited' , }) } break case 'encode' : if (!encoder) { self.postMessage({ cmd : 'error' , data : 'encoder not initialized' , }) break } for (let i = 0 ; i < data.length; i++) { inputBuffer.push(float32ToInt16(data[i])) } if (inputBuffer.length >= frameSize) { const pcm = new Int16Array (inputBuffer.splice(0 , frameSize)) let pcmPtr = OpusModule._malloc(pcm.length * 2 ) let encodedPtr = OpusModule._malloc(frameSize * 2 ) OpusModule.HEAP16.set(pcm, pcmPtr / 2 ) const encodedBytes = OpusModule._opus_encode(encoder, pcmPtr, pcm.length, encodedPtr, frameSize * 2 ) const encodedData = new Uint8Array (OpusModule.HEAPU8.subarray(encodedPtr, encodedPtr + encodedBytes)) self.postMessage({ cmd : 'encoded' , data : encodedData, }) OpusModule._free(pcmPtr) OpusModule._free(encodedPtr) } break case 'decode' : if (!decoder) { self.postMessage({ cmd : 'error' , data : 'decoder not initialized' , }) break } let pcmPtr = OpusModule._malloc(frameSize * 4 ) let encodedPtr = OpusModule._malloc(data.byteLength) OpusModule.HEAPU8.set(new Uint8Array (data), encodedPtr) const decodedBytes = OpusModule._opus_decode(decoder, encodedPtr, data.byteLength, pcmPtr, frameSize * 2 , 0 ) const int16Data = new Int16Array (OpusModule.HEAP16.subarray(pcmPtr >> 1 , (pcmPtr >> 1 ) + decodedBytes)) for (let i = 0 ; i < int16Data.length; i++) { outputBuffer.push(int16ToFloat32(int16Data[i])) } while (outputBuffer.length >= bufferSize) { const decodeData = outputBuffer.splice(0 , bufferSize) self.postMessage({ cmd : 'decoded' , data : decodeData, }) } OpusModule._free(encodedPtr) OpusModule._free(pcmPtr) break default : break } } })
3. 音频流处理 在浏览器端,我们需要使用 MediaStream API 来获取用户的音频流,并将其传递给音频编解码器进行处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > Audio</title > </head > <body > <div > <label > HOST:</label > <input type ="text" id ="host" value ="wss://192.168.31.254:5001" /> </div > <div > <button onclick ="streamStart()" > Start</button > <button onclick ="streamStop()" > Stop</button > </div > <script > const frameDuration = 20 const channels = 1 const app = 2048 const bufferSize = 512 const sampleRate = 48000 const frameSize = (channels * frameDuration * sampleRate) / 1000 const host = document .querySelector('#host' ) let streamer let audioContext let ws let audioInput let audioOutput let recorder let outputBuffer = [] let worker function streamStart ( ) { worker = new Worker('audioWorker.js' ) worker.onmessage = function (e ) { if (e.data.cmd === 'encoded' ) { ws.send(e.data.data) } else if (e.data.cmd === 'decoded' ) { outputBuffer = outputBuffer.concat(e.data.data) } else if (e.data.cmd === 'inited' ) { encodeFun() } } ws = new WebSocket(host.value) ws.binaryType = 'arraybuffer' ws.onopen = function ( ) { audioContext = new AudioContext() worker.postMessage({ cmd : 'init' , codec : { sampleRate : sampleRate, app : app, bufferSize : bufferSize, frameSize : frameSize, channels : channels, }, }) } ws.onclose = function ( ) { streamStop() } ws.onerror = function ( ) { streamStop() } ws.onmessage = function (e ) { worker.postMessage({ cmd : 'decode' , data : e.data, codec : { sampleRate : sampleRate, app : app, bufferSize : bufferSize, frameSize : frameSize, channels : channels, }, }) } } function encodeFun ( ) { navigator.mediaDevices .getUserMedia({ audio : { sampleRate : sampleRate, channelCount : channels, echoCancellation : true , noiseSuppression : true , autoGainControl : true , latency : 0 , sampleSize : 16 , }, }) .then(function (stream ) { streamer = stream audioInput = audioContext.createMediaStreamSource(stream) recorder = audioContext.createScriptProcessor(bufferSize, channels, channels) recorder.onaudioprocess = function (e ) { const buffer = e.inputBuffer.getChannelData(0 ) worker.postMessage({ cmd : 'encode' , data : buffer, codec : { sampleRate : sampleRate, app : app, bufferSize : bufferSize, frameSize : frameSize, channels : channels, }, }) } audioInput.connect(recorder) recorder.connect(audioContext.destination) audioOutput = audioContext.createScriptProcessor(bufferSize, channels, channels) audioOutput.onaudioprocess = function (e ) { if (outputBuffer.length < bufferSize) { e.outputBuffer.getChannelData(0 ).set(new Float32Array (bufferSize)) } else { const buffer = outputBuffer.splice(0 , bufferSize) e.outputBuffer.getChannelData(0 ).set(new Float32Array (buffer)) if (outputBuffer.length > bufferSize * 4 ) { outputBuffer.splice(0 , outputBuffer.length - bufferSize) } } } audioOutput.connect(audioContext.destination) }) } function streamStop ( ) { if (audioContext) { audioInput.disconnect() audioOutput.disconnect() recorder.disconnect() audioContext.close() } if (streamer) { streamer.getTracks().forEach(function (track ) { track.stop() }) } if (ws) { ws.close() } if (worker) { worker.terminate() } streamer = null outputBuffer = [] audioOutput = null audioInput = null recorder = null audioContext = null ws = null worker = null } </script > </body > </html >
4. 编译 Opus 库为 WebAssembly 模块 Opus 库是使用 C 语言编写的,我们需要将其编译为 WebAssembly 模块,以便在浏览器中使用。
4.1 安装 Emscripten Emscripten 是一个能把 C/C++ 代码编译成 WebAssembly 的工具链。你可以按以下步骤安装它:
1 2 3 4 5 git clone https://github.com/emscripten-core/emsdk.git cd emsdk./emsdk install latest ./emsdk activate latest source ./emsdk_env.sh
4.2. 下载 Opus 源码
1 2 3 wget https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz tar -zvxf opus-1.5.2.tar.gz cd opus-1.5.2
4.3. 配置并编译 Opus 在 Opus 源码目录下,使用 Emscripten 进行配置和编译:
1 2 emconfigure ./configure --disable-asm --disable-intrinsics --disable-doc --disable-extra-programs emmake make
–disable-asm和–disable-intrinsics选项用于禁止使用汇编代码和特定的指令集,这是为了确保能顺利编译成 WebAssembly。 4.4. 编译为 WebAssembly 编译完成后,使用 Emscripten 把生成的静态库编译成 WebAssembly:
1 emcc -O3 -s WASM=1 -s MODULARIZE=1 -s EXPORTED_FUNCTIONS='["_opus_encoder_create", "_opus_encode", "_opus_encode_float", "_opus_encoder_destroy", "_opus_decoder_create", "_opus_decode", "_opus_decode_float", "_opus_decoder_destroy", "_malloc", "_free"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -o opus.js ./.libs/libopus.a
*-O3:开启最高级别的优化。
*-s WASM=1:指明生成 WebAssembly 文件。
*-s MODULARIZE=1:将生成的代码封装成一个模块,便于在 JavaScript 中使用。
*-s EXPORTED_FUNCTIONS:指定要导出的 C 函数,这里列出了 Opus 编码所需的关键函数。
*-s EXTRA_EXPORTED_RUNTIME_METHODS:导出额外的运行时方法,例如ccall和cwrap,方便在 JavaScript 中调用 C 函数。
*-o opus.js:指定输出的 JavaScript 文件,同时会生成对应的opus.wasm文件。