Sonic的v1.3.1-releasse已经发布啦!其中有一个功能是远程音频传输,备受用户期待和好评,今天我们来揭开它的神秘面纱吧!
效果图:

背景
为什么需要远程传输音频呢?这是因为Sonic云真机平台的用户还有涉及游戏和音视频方向的团队在使用,特别是某些音视频的测试需要听取设备的音频是否达标,是否出现在相应位置等等场景。游戏就不用说了,虽然现在Sonic可以横屏游戏,但是没有声音是缺少灵魂的。
最终需求就是,能够在web浏览器上听到远程真机的设备音频。
方案选取
以往做远程音频传输,有两个方案。
- app开启麦克风权限,通过麦克风录制设备音频发送到后台处理。听着好像不错,但是你想想看群控的时候,基本机架上的手机都在进行测试、远控。如果开启麦克风,会把其他设备的杂音一并录制进去,体验非常不好。
- app获取安卓的audiorecord接口,直接获取设备内置声卡的音频。但是兼容性不太好,只能兼容安卓10或以上。
综合考虑了一下,毕竟低端机很少用于音视频测试,于是选了方案二就准备开工了。
具体实现
获取audiorecord的开源项目有 sndcpy,他的处理方式比较粗暴,直接将audiorecord获取到的pcm(16bit)音频流暴露给pc本地,然后pc本地用vlc软件进行播放。这种方式会有两个地方不太符合Sonic的需求。
- 用户需要额外安装vlc在pc本地,这肯定是增加了门槛。需要用前端播放器进行播放。
- pcm裸流数据量会偏大,vlc解析之后延迟会达到约2s
在我们组织内部商量了之后,决定:
- 安卓端将pcm流实时压缩成ACC格式,通过localserversocket的方式传递给Agent端。
mMediaCodec.setCallback(new MediaCodec.Callback() {
@Override
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int i) {
ByteBuffer codecInputBuffer = mediaCodec.getInputBuffer(i);
int capacity = codecInputBuffer.capacity();
byte[] buffer = new byte[capacity];
int readBytes = audioRecord.read(buffer, 0, buffer.length);
if (readBytes > 0) {
codecInputBuffer.put(buffer, 0, readBytes);
mediaCodec.queueInputBuffer(i, 0, readBytes, mPresentationTime[0], 0);
totalBytesRead[0] += readBytes;
mPresentationTime[0] = 1000000L * (totalBytesRead[0] / 2) / 44100;
}
}
@Override
public void onOutputBufferAvailable(@NonNull MediaCodec codec, int outputBufferIndex, @NonNull MediaCodec.BufferInfo mBufferInfo) {
if (mBufferInfo.flags == MediaCodec.BUFFER_FLAG_CODEC_CONFIG) {
Logger.i("AudioService", "AAC的配置数据");
} else {
byte[] oneADTSFrameBytes = new byte[7 + mBufferInfo.size];
ADTSUtil.addADTS(oneADTSFrameBytes);
ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferIndex);
outputBuffer.get(oneADTSFrameBytes, 7, mBufferInfo.size);
if (outputStream!=null){
try {
outputStream.write(oneADTSFrameBytes,0,oneADTSFrameBytes.length);
outputStream.flush();
} catch (IOException e) {
stopSelf();
e.printStackTrace();
}
}
}
codec.releaseOutputBuffer(outputBufferIndex, false);
}
});
- 然后Agent端通过websocket发送给前端解析。
audioSocket = new Socket("localhost", appListPort);
inputStream = audioSocket.getInputStream();
int len = 1024;
while (audioSocket.isConnected() && !Thread.interrupted()) {
byte[] buffer = new byte[len];
int realLen;
realLen = inputStream.read(buffer);
if (buffer.length != realLen && realLen >= 0) {
buffer = AgentTool.subByteArray(buffer, 0, realLen);
}
if (realLen >= 0) {
ByteBuffer byteBuffer = ByteBuffer.allocate(buffer.length);
byteBuffer.put(buffer);
byteBuffer.flip();
AgentTool.sendByte(session, byteBuffer);
}
}
- 前端使用jmuxer进行音频解析并播放。
initWebSocket(url) {
const that = this
this.ws = new Socket({
url,
binaryType: 'arraybuffer',
isErrorReconnect: false,
onmessage: function(event) {
var data = that.parse(event.data);
data && that.jmuxer.feed(data);
}
});
}
/**
* 音频解析
* @param {*} data AAC Buffer 视频流
* @returns
*/
parse(data) {
let input = new Uint8Array(data)
return {
audio: input
};
}
}
这种方式可以减少了数据传输大小,一帧压缩到了500b,并提高了音频实时效率(实测延迟降低到1 ~ 1.5s)

踩坑感受
过程中还是踩到不少坑的。例如给压缩后的每帧数据加上ACC头,初始化解码器的回调出现粘包,解析数据后播放器无法播放等等。特别是数据处理的逻辑,搭配Agent的运行,绕过用户手动配具体权限。
我们成员接触过音视频经验的非常少,因此大家花了很长时间预研,试验,测试,都经历了一段时间的互相配合,可以说是不容易了。
结语
就这样,远程音频就做好啦~
感谢这段时间大家对Sonic的支持,Sonic会将继续沉淀做精品,感谢~