前言

上一篇大致介绍了一下实时视频的开发流程,这一篇所要介绍的双向对讲算是上一篇内容的小进阶。由 JT/T 1078协议(以下简称1078)中介绍可知,双向对讲的数据传输方式也是在 5.5.3 表19中定义的。下达双向对讲指令通过改变修改 0x9101 消息ID中的参数来实现,本篇不再赘述。功能实现思路与上一篇相同,使用 nodejsFFmpeg 实现。测试系统环境为 Linux .

准备

见上一篇

流程

实时视频流程大致如下:

1
2
3
4
5
6
7
8
9
participant 网页
participant 服务器
participant 设备

网页 ->> 设备: 下达双向对讲指令
设备 ->> 服务器: 推送音频数据
服务器 ->> 网页: 转换音频格式推流至网页,并通知开启网页麦克风
网页 ->> 服务器: 采集音频数据并推送
服务器 ->> 设备: 将网页端音频转码,发送至设备

网页采集音频

浏览器利用麦克风采集麦克风的方法,网上的代码有很多,方法也是如出一辙。
下面直接贴出代码:

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
var leftchannel = [];
var rightchannel = [];
var recorder = null;
var recording = false;
var recordingLength = 0;
var volume = null;
var audioInput = null;
var sampleRate = null;
var audioContext = null;
var context = null;
var outputElement = document.getElementById('output'); // 用于提示当前录音状态

if (!navigator.getUserMedia)
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia || navigator.msGetUserMedia;

if (navigator.getUserMedia) {
navigator.getUserMedia({ audio: true }, success, function (e) {
alert('Error capturing audio.');
});
} else {
alert('getUserMedia not supported in this browser.');
};

function interleave(leftChannel, rightChannel) {
var length = leftChannel.length + rightChannel.length;
var result = new Float32Array(length);

var inputIndex = 0;

for (var index = 0; index < length;) {
result[index++] = leftChannel[inputIndex];
result[index++] = rightChannel[inputIndex];
inputIndex++;
}
return result;
}

function mergeBuffers(channelBuffer, recordingLength) {
var result = new Float32Array(recordingLength);
var offset = 0;
var lng = channelBuffer.length;
for (var i = 0; i < lng; i++) {
var buffer = channelBuffer[i];
result.set(buffer, offset);
offset += buffer.length;
}
return result;
}

function writeUTFBytes(view, offset, string) {
var lng = string.length;
for (var i = 0; i < lng; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}

function success(e) {
console.log('Record Init Succcess!');

audioContext = window.AudioContext || window.webkitAudioContext;
context = new audioContext();
sampleRate = context.sampleRate; // 44100
volume = context.createGain();
audioInput = context.createMediaStreamSource(e);

audioInput.connect(volume);

var bufferSize = 2048;
recorder = context.createScriptProcessor(bufferSize, 2, 2);

recorder.onaudioprocess = function (e) {
if (!recording) return;
var left = e.inputBuffer.getChannelData(0);
var right = e.inputBuffer.getChannelData(1);
leftchannel.push(new Float32Array(left));
rightchannel.push(new Float32Array(right));
recordingLength += bufferSize;
console.log('recording...');
combineAudio([new Float32Array(left)], [new Float32Array(right)], bufferSize, sampleRate);
}

volume.connect(recorder);
recorder.connect(context.destination);
}

function combineAudio(leftc, rightc, bfSize, sampleRate) {
var leftBuffer = mergeBuffers(leftc, bfSize);
var rightBuffer = mergeBuffers(rightc, bfSize);
var interleaved = interleave(leftBuffer, rightBuffer);
var dataLen = interleaved.length * 2;
var buffer, view, index = 0;

buffer = new ArrayBuffer(dataLen);
view = new DataView(buffer);

var lng = interleaved.length;
var volume = 1;
for (var i = 0; i < lng; i++) {
view.setInt16(index, interleaved[i] * (0x7FFF * volume), true);
index += 2;
}

var blob = new Blob([view], { type: 'audio/wave' }); // 最终音频数据
// 将blob写入 websocket, 略
}

上述代码主要思路为通过改变全局变量 recording 控制录音,这个可以使用 button 元素实现。开启录音后,浏览器采集左右声道的音频数据,将数据存入Buffer后,合并两通道数据然后通过 DataViewBlob 包装后的数据发送至服务器。为了保证实时性,我这里使用的是 websocket 发送至后台。上述代码得到的音频数据为 PCMS16LE 原始数据,默认采样率为44.1k,所以体积比较巨大。可以通过减少声道数量和降低采样率,或者直接在浏览器端重新编码来减少体积。

音频转码

音频转码涉及两个部分,一是将网页端采集的音频数据转换成设备支持的格式发送给设备;二是将设备端发送的音频数据转成网页端支持的格式进行播放。
设备支持什么格式呢?由于设备的不同,可能初始默认的音频格式都不尽相同。最方便的肯定是直接操作设备进行查看了。还可以在下达双向对讲指令后,解析RTP包参照1078 表12 进行查看。我手里这台设备的音频格式是 G726LE.
首先进行网页端播放设备音频流的实现。有了上一篇文章的经验,音视频服务器和网页端播放器都是准备好的,一下子省去了很多的工作,只需要将设备音频数据转码发送给音视频服务器即可。然后,网页端从音视频服务器提供的地址拉流就能进行播放了。看起来很简单有木有,突然有点小兴奋。
接下来的思路与视频处理相似,使用子进程开启 FFmpeg 进行转码并推流。由于推流要使用 flv 格式的 RTMP 流,而 flv 支持的音频格式是有限的(点击查看),光是进行音频转码就占用了一个FFmpeg子进程,所以还需要另一个进程将转码后的数据推流出去。听起来很麻烦,但FFmpeg支持数据流处理,所以实现起来非常简单,只需要将两个子进程的STDIN, STDOUT pipe 起来就行了,代码如下:

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
const { spawn } = require('child_process');
const FFMPEG = '/usr/bin/ffmpeg';

var cmds1 = [
'-loglevel', 'panic',
'-probesize', '32',
'-f', 'g726le',
'-code_size', '5',
'-ar', '8000',
'-ac', '1',
'-i', '-',
'-ar', '44100',
'-acodec', 'libmp3lame',
'-f', 'mp3',
'pipe:1'
];
var cmds2 = [
'-i', '-',
'-vn',
'-c', 'copy',
'-f', 'flv',
rtmpUrl
];

var child1 = spawn(FFMPEG, cmds1);
var child2 = spawn(FFMPEG, cmds2);

child1.stdin.write(data);
child1.stdout.pipe(child2.stdin);

这样,网页端播放器就能使用 RTMP 链接播放设备的音频数据了。
回来处理网页端到设备端的部分,思路也清晰了起来,只需将音频数据写到设备就行了。已经知道了设备支持的编码格式是 G726LE,所以首先需要将音频转码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { spawn } = require('child_process');
const FFMPEG = '/usr/bin/ffmpeg';

var cmds = [
'-loglevel', 'panic',
'-f', 's16le',
'-ar', '44.1k',
'-ac', '2',
'-i', '-',
'-acodec', 'g726le',
'-code_size', '5',
'-ar', '8k',
'-ac', '1',
'-f', 'g726le',
'pipe:1'
];
var _process = spawn(FFMPEG, cmds);

开启子进程后,将网页端数据写入进程STDIN,再将STDOUT数据写入设备连至服务器的TCP连接即可。因为流程中,是先下达指令,设备连接上来后,再进行网页端数据写入,设备的连接是很容易获取到的。

总结

实时视频和双向对讲的实现思路大体上是一致,区别在FFmpeg的参数不同,多开启了几个进程而已。不由得感叹FFmpeg的强大。前后两篇文章中,涉及到业务的代码都是一笔带过,而主要列举了FFmpeg的命令。不要小看这简单的几行命令,对于从没接触过FFmpeg和音视频知识几乎为零的我来说,花费了大量的时间来搜索和尝试。相比之下,业务上码代码花费的时间更少。业务代码中也有很多值得注意的点,如设备连接和断开时,FFmpeg进程的管理;FFmpeg进程写入时,EPIPE的处理(管理各个子进程的事件回调);双向对讲状态的共享(Redis实现)等等。经过这一项目的一番折腾,也算是了解一些音视频方面的知识,也对这方面有了一番兴趣。打算借着没有减退的热度开个音视频相关的小项目,希望自己不要弃坑。