avatar

JS MediaStream格式转换

JS MediaStream格式转换

背景

在 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
// server.js
var https = require('https')
var fs = require('fs')
var path = require('path')
var WebSocketServer = require('ws').Server

var 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) {
// 文件不存在,返回404状态码
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)
})
// var wss = new WebSocketServer({ port: wsPort })

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
// audioWorker.js
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文件。
文章作者: pengweifu
文章链接: https://www.pengwf.com/2025/04/06/web/JS-MediaStream/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 麦子的博客
打赏
  • 微信
    微信
  • 支付宝
    支付宝

评论