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

背景

为什么需要远程传输音频呢?这是因为 Sonic 云真机平台的用户还有涉及游戏和音视频方向的团队在使用,特别是某些音视频的测试需要听取设备的音频是否达标,是否出现在相应位置等等场景。游戏就不用说了,虽然现在 Sonic 可以横屏游戏,但是没有声音是缺少灵魂的。
最终需求就是,能够在 web 浏览器上听到远程真机的设备音频。

方案选取

以往做远程音频传输,有两个方案。

  1. app 开启麦克风权限,通过麦克风录制设备音频发送到后台处理。听着好像不错,但是你想想看群控的时候,基本机架上的手机都在进行测试、远控。如果开启麦克风,会把其他设备的杂音一并录制进去,体验非常不好。
  2. app 获取安卓的 audiorecord 接口,直接获取设备内置声卡的音频。但是兼容性不太好,只能兼容安卓 10 或以上。 综合考虑了一下,毕竟低端机很少用于音视频测试,于是选了方案二就准备开工了。

具体实现

获取 audiorecord 的开源项目有 sndcpy,他的处理方式比较粗暴,直接将 audiorecord 获取到的 pcm(16bit)音频流暴露给 pc 本地,然后 pc 本地用 vlc 软件进行播放。这种方式会有两个地方不太符合 Sonic 的需求。

  1. 用户需要额外安装 vlc 在 pc 本地,这肯定是增加了门槛。需要用前端播放器进行播放。
  2. 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 会将继续沉淀做精品,感谢~


↙↙↙阅读原文可查看相关链接,并与作者交流