引言
最近开发的远程控制功能需要增加音频采集的功能,而 Google 为了保护唱片协会的利益,不允许获取系统原始输出的音频。如果有 Root 权限的话,你自然可以轻易的做到这件事。但是我们的使用场景是不能获取 Root 权限的,所以我们借助了一些硬件的支持,最终达到了截获手机原始音频输出的效果。具体的实现方案也是经历了几个发展阶段,接下里我就按时间顺序介绍一下这部分的发展历程。
方案一:外接声卡
方案介绍
这个方案的基本思路如下图,通过音频线将手机的音频数据传入声卡,然后将声卡和服务器通过 USB 相连,最终从服务器上截获该声卡的音频数据。为了达到这个效果,必须将每款手机和与其相连的声卡建立绑定关系,这就需要每一个声卡都有一个唯一的序列号,这样当我们需要截获某一款手机的音频时,我们只需要从绑定关系表中查到与这款手机相连的声卡序列号,然后通过该序列号找到对应的声卡设备并进行录制。遗憾的是,在现有的产品中,我们没找到具有唯一序列号的 USB 声卡产品,我们只找到了HS-100B[1],它虽然没有唯一序列号,但是我们可以通过外接EEPROM[2]的方式,写入自定义内容作为序列号。所以,我们参考了HS-100B 的产品说明书[3],从中我们得知EEPROM[4]需要存储的内容如下图,其中画红线的部分可以用来定义声卡的序列号,我们之所以用 Product String 作为序列号的存储区域,主要是因为这部分内容可以通过 FFMPEG 的设备显示功能展示出来,我们只需要做一个字符串匹配就能定位需要截获的声卡设备。
EEPROM 写入数据方式
购买EZP2013 烧录器[5]
安装烧录软件[6]
选择 EEPROM 类型
写入数据[7]
显示序列号的方式
Mac: ffmpeg -f avfoundation -list_devices true -i ""
Linux: aplay -l (yum install alsa-utils alsa-lib)
方案总结
这个方案总的来说,实现起来是比较麻烦的,虽然他可以获取到多声道的音频数据,但是其工作量太大,既要烧入数据,又要焊接电路,而且还要维护手机与音频采集卡的映射关系。
方案二:音频输出转接音频输入
方案介绍
这个实现方案是受一款现有耳机产品[8]的启发,我们做了一个超级简易版。基本思路如下图,通过一个音频公头接线端子[9],将手机的音频输出接入到麦克风输入中,然后通过手机中的 APP 录制麦克风的输入从而达到内录的效果。我们参考了 Google 的3.5 毫米耳机规范[10],将音频公头接线端子[11]的左右声道连接一个电阻并连入地线,然后选取左声道连接一个电阻接入 MIC,从而达到截获左声道输出的效果。
然后就是通过 APP 录制音频数据的部分了,首先我们需要构造一个 AudioRecord 对象,其中需要的最小录音缓存 buffer 大小可以通过 getMinBufferSize 方法得到。如果 buffer 容量过小,将导致对象构造的失败。
int recordBufSize = AudioRecord.getMinBufferSize(frequency, channelConfiguration, EncodingBitRate);
AudioRecord audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, frequency, channelConfiguration, EncodingBitRate, recordBufSize);
复制
其中,音频源我们选择public static final int MIC = 1;
,采样率我使用了44100
,因为我们这个方案只能截获单声道的数据,所以声道设置为CHANNEL_IN_MONO
,采样大小我选用了ENCODING_PCM_16BIT
。设置完采集参数之后,就开始录音并输出 PCM 数据。
byte data[] = new byte[recordBufSize];
FileOutputStream os = new FileOutputStream(filename);
while (isRecording) {
read = audioRecord.read(data, 0, recordBufSize);
if (AudioRecord.ERROR_INVALID_OPERATION != read) {
os.write(data);
}
}
复制
APP 这部分,我觉得简单的描述一下基本操作就够了,剩下的就是通过 Socket 将音频数据传输出去。
方案总结
这个方案相对于方案一来说就简单了很多,接几个电阻就能直接使用了,虽然目前还没找到多声道录音的方式,但是已经基本满足我们的使用需要了。值得一提的是,这两个方案都有一个共同的问题,就是需要手机有 3.5mm 耳机接口,而近来的安卓手机都在逐渐的移除 3.5mm 耳机接口。这时候你可能会说,可以通过一个转接头将耳机接口转接到 Type-C 接口啊,可是因为我们的业务中需要通过 USB 来建立 ADB 连接,而且要用其给手机充电,所以 Type-C 接口会一直连接在服务器上。为了让这类手机也能捕获到音频数据,我们调研了第三种方案,通过蓝牙传输音频数据。
方案三:蓝牙获取音频数据
相关知识
在介绍整个方案之前,我觉得有必要简单描述一下蓝牙传输音频时使用到的 A2DP 协议,以及我们用到的音频服务代理 PulseAudio。
A2DP
A2DP 全名是 Advanced Audio Distribution Profile 蓝牙音频传输模型协定。简单地说它就是一个音频传输协议,蓝牙耳机都是通过该协议来接收手机上传送过来的音频数据并播放的。这里你可能会有疑问,一般来说,都是手机将音频数据传输给蓝牙耳机,或者 PC 将音频数据传输给蓝牙耳机,那么,到底是怎么让手机将音频数据传输给电脑呢?其实,A2DP 协议中有一个角色的概念,通讯双方在建立连接的时候会确立自己的角色,手机上自带的蓝牙模块一般都是音频数据源这个角色(Audio Source),而蓝牙耳机默认的角色是音频接收端(Audio Sink),所以,要想让手机通过蓝牙发送音频数据给服务器上的蓝牙模块,就需要修改服务器上的蓝牙配置文件,让它以音频接收端(Audio Sink)的角色建立连接。
PulseAudio
PulseAudio 是在 GNOME 或 KDE 等桌面环境中广泛使用的音频服务。它在内核音频组件(比如 ALSA 和 OSS)和音频程序之间充当代理的角色。在我们的场景中,主要用到了它的一个蓝牙设备发现模块,来自动地在蓝牙连接建立完成之后通过 A2DP 协议虚拟出一块声音设备。
BlueZ
BlueZ 是 Linux 官方蓝牙协议栈。它是一个基于 GNU General Public License (GPL)发布的开源项目,从 Linux2.4.6 开始便成为 Linux 内核的一部分。我们在 Linux 上操作 Bluetooth 实际上就是它提供的支持。
方案介绍
这个方案的基本思路如下图,手机扮演一个 Audio Source 的角色(A2DP 发送端),服务器外接一个蓝牙模块扮演 Audio Sink 的角色(A2DP 接收端),将手机与服务器蓝牙模块配对后,通过 PulseAudio 的蓝牙模块将服务器上外接的蓝牙(A2DP 接收端)虚拟为一个音频源,进行声音采集,这个方案目前还有一些问题,我后面会介绍。
PipeLine
Remote Device-SRC ---> SINK-Bluetooth-SRC ---> SINK-PulseAudioBlueToothModule-SRC ---> SINK-MyApp
其中SRC
代表数据源,SINK
代表数据接收方。
确立 AudioSink 角色
这个方案的重点是如何让服务器上的蓝牙模块扮演 Audio Sink 角色,这就涉及到 Linux 上的BlueZ[12]模块。这里我们需要编辑/etc/bluetooth/audio.conf
,在[General]
区段加入Enable=Source
,并且关闭其作为 Audio Source 角色的能力,加入Disable=Socket
,最终配置文件的内容如下:
[General]
Enable=Source
Disable=Socket
复制
完成了蓝牙音频角色配置之后,重启蓝牙服务systemctl restart bluetooth
。
设置 PulseAudio
接着,我们还需要对 PulseAudio 进行一些设置,添加module-bluetooth-discover
和module-bluetooth-policy
模块的支持,这个模块默认是加载的,如果没有加载这个模块的话,可以通过pactl load-module module-bluetooth-discover
手动加载,或者修改 PulseAudio 的配置文件/etc/pulse/default.pa
加入如下内容。
### Automatically load driver modules for Bluetooth hardware
.ifexists module-bluetooth-policy.so
load-module module-bluetooth-policy
.endif
.ifexists module-bluetooth-discover.so
load-module module-bluetooth-discover
.endif
复制
连接蓝牙
配置完 BlueZ 和 PulseAudio 之后,剩下的工作就是配对蓝牙设备并建立连接了。首先我们需要确认一下蓝牙控制器是否工作正常。这里hci0
是蓝牙控制器的名字,第三行的 UP 表示其已经启动。如果该蓝牙控制器未启动,您可以通过hciconfig hci0 up
来进行启动。
root # hciconfig -a
hci0: Type: BR/EDR Bus: USB
BD Address: 00:02:72:2F:A9:33 ACL MTU: 1021:8 SCO MTU: 64:1
UP RUNNING PSCAN
RX bytes:1166 acl:0 sco:0 events:43 errors:0
TX bytes:960 acl:0 sco:0 commands:43 errors:0
Features: 0xbf 0xfe 0xcf 0xfe 0xdb 0xff 0x7b 0x87
Packet type: DM1 DM3 DM5 DH1 DH3 DH5 HV1 HV2 HV3
Link policy: RSWITCH SNIFF
Link mode: SLAVE ACCEPT
Name: 'BlueZ 5.21'
Class: 0x000104
Service Classes: Unspecified
Device Class: Computer, Desktop workstation
HCI Version: 4.0 (0x6) Revision: 0x1000
LMP Version: 4.0 (0x6) Subversion: 0x220e
Manufacturer: Broadcom Corporation (15)
复制
当然,您也可以通过/etc/bluetooth/main.conf
设置蓝牙模块开机自动启动。
[Policy]
AutoEnable=true
复制
确认完蓝牙控制器的状态之后,就是完整的蓝牙配对过程如下:启动蓝牙控制器user $ bluetoothctl
列出所有蓝牙控制器[bluetooth]# list
显示蓝牙控制器的相关信息[bluetooth]# show controller_mac_address
选择要操作的蓝牙控制器(可能插着多个蓝牙模块)[bluetooth]# select controller_mac_address
供电[bluetooth]# power on
开启代理
[bluetooth]# agent on
[bluetooth]# default-agent
复制
设置蓝牙控制器可以被发现并且可以配对(3 分钟有效)
[bluetooth]# discoverable on
[bluetooth]# pairable on
复制
扫描设备[bluetooth]# scan on
列出发现的设备[bluetooth]# devices
配对设备[bluetooth]# pair device_mac_address
如果有必要的话输入 PIN[agent]PIN code: ####
允许链接权限[agent]Authorize service service_uuid (yes/no): yes
设置信任设备[bluetooth]# trust device_mac_address
连接设备[bluetooth]# connect device_mac_address
显示设备的相关信息[bluetooth]# info device_mac_address
退出[bluetooth]# quit
确认结果
蓝牙连接成功之后,PulseAudio 会自动帮我们虚拟出声音设备,我们可以通过pactl list cards
来查看虚拟出来的声卡设备。可以看到当前的 Profile 是a2dp_source
,如果您的声卡 profile 不是a2dp_source
的话可以通过pactl set-card-profile 10 a2dp_source
来指定。
root # pactl list cards
...
Card #2
Name: bluez_card.44_80_EB_26_0C_73
Driver: module-bluez5-device.c
Owner Module: 23
Properties:
device.description = "Nexus 6"
device.string = "44:80:EB:26:0C:73"
device.api = "bluez"
device.class = "sound"
device.bus = "bluetooth"
device.form_factor = "phone"
bluez.path = "/org/bluez/hci0/dev_44_80_EB_26_0C_73"
bluez.class = "0x5a020c"
bluez.alias = "Nexus 6"
device.icon_name = "audio-card-bluetooth"
Profiles:
a2dp_source: High Fidelity Capture (A2DP Source) (sinks: 0, sources: 1, priority: 10, available: yes)
headset_audio_gateway: Headset Audio Gateway (HSP/HFP) (sinks: 1, sources: 1, priority: 20, available: no)
off: Off (sinks: 0, sources: 0, priority: 0, available: yes)
Active Profile: a2dp_source
Ports:
phone-output: Phone (priority: 0, latency offset: 0 usec, not available)
Part of profile(s): headset_audio_gateway
phone-input: Phone (priority: 0, latency offset: 0 usec, available)
Part of profile(s): a2dp_source, headset_audio_gateway
复制
当手机端有声音播放时,我们可以通过pactl list sources
来查看 Audio Source。我们在 APP 中就是使用这个 Audio Source 作为音频采集源。
root # pactl list sources
...
Source #15
State: RUNNING
Name: bluez_source.44_80_EB_26_0C_73.a2dp_source
Description: Nexus 6
Driver: module-bluez5-device.c
Sample Specification: s16le 2ch 44100Hz
Channel Map: front-left,front-right
Owner Module: 23
Mute: no
Volume: front-left: 65536 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
balance 0.00
Base Volume: 65536 / 100% / 0.00 dB
Monitor of Sink: n/a
Latency: 25000 usec, configured 135294 usec
Flags: HARDWARE DECIBEL_VOLUME LATENCY
Properties:
bluetooth.protocol = "a2dp_source"
device.description = "Nexus 6"
device.string = "44:80:EB:26:0C:73"
device.api = "bluez"
device.class = "sound"
device.bus = "bluetooth"
device.form_factor = "phone"
bluez.path = "/org/bluez/hci0/dev_44_80_EB_26_0C_73"
bluez.class = "0x5a020c"
bluez.alias = "Nexus 6"
device.icon_name = "audio-card-bluetooth"
Ports:
phone-input: Phone (priority: 0, available)
Active Port: phone-input
Formats:
pcm
复制
使用技巧
此外在使用 PulseAudio 时,我还用到了update-source-proplist
来给声卡打标记,使我可以通过字符串匹配找到指定设备连接的声卡。
root # echo "update-source-proplist bluez_source.44_80_EB_26_0C_73.a2dp_source device.description=\"44_80_EB_26_0C_73\"" | pacmd
root # pactl list sources
...
Source #16
State: RUNNING
Name: bluez_source.44_80_EB_26_0C_73.a2dp_source
Description: 44_80_EB_26_0C_73
Driver: module-bluez5-device.c
Sample Specification: s16le 2ch 44100Hz
Channel Map: front-left,front-right
Owner Module: 23
Mute: no
Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
balance 0.00
Base Volume: 65536 / 100% / 0.00 dB
Monitor of Sink: n/a
Latency: 25000 usec, configured 135294 usec
Flags: HARDWARE DECIBEL_VOLUME LATENCY
Properties:
bluetooth.protocol = "a2dp_source"
device.description = "44_80_EB_26_0C_73"
device.string = "44:80:EB:26:0C:73"
device.api = "bluez"
device.class = "sound"
device.bus = "bluetooth"
device.form_factor = "phone"
bluez.path = "/org/bluez/hci0/dev_44_80_EB_26_0C_73"
bluez.class = "0x5a020c"
bluez.alias = "Nexus 6"
device.icon_name = "audio-card-bluetooth"
Ports:
phone-input: Phone (priority: 0, available)
Active Port: phone-input
Formats:
pcm
复制
方案总结
这个方案我只是达到了『能跑通』的程度,在测试的时候发现如果手机端没有声音时,该虚拟声卡的Active Profile会变为Active Profile: off
,并且 PulseAudio Source 消失,这样我们的 APP 中会丢失声音采集设备,继而切换到默认声音采集卡。此外这个方案也需要维护一个由声卡到手机的映射关系,不过我觉得大部分情况下可以通过查看蓝牙模块已配对的手机的方式,快速得到这个对应关系。
将来的工作
因为方案三的调查工作基本上都是在我业务之余,挤出时间进行的,后面因为一些原因中断了更进一步的调查。不过,以我现在的理解来看的话,应该可以实现一个基于 PulseAudio 的AudioDeviceModule,来解决当手机端没有声音时 PulseAudio Source 消失的情况,或者参考bluez-alsa[13]直接通过 BlueZ 构建一个 ALSA 设备。此外,我觉得还应该通过类似于tinyb[14]和lbt4j[15]的库,来达到在 Java 中调度蓝牙模块的效果。
参考内容
[1]https://sspai.com/post/40962 [2]https://juejin.im/post/5be1802f6fb9a049e41229fa [3]https://jprvita.wordpress.com/2009/12/15/1-2-3-4-a2dp-stream/ [4]https://linuxtoy.org/archives/ubuntu-bluetooth-guide.html [5]https://wiki.gentoo.org/wiki/Bluetooth#BlueZ_5 [6]http://www.variwiki.com/index.php?title=BlueZ5_and_A2DP [7]https://wiki.gentoo.org/wiki/PulseAudio [8]http://www.lightofdawn.org/blog/?viewDetailed=00031 [9]http://www.lightofdawn.org/blog/?viewCat=Bluetooth [10]https://ops9.blogspot.com/2013/09/a2dp.html
引用链接
HS-100B: https://www.cmedia.com.tw/products/USB20_FULL_SPEED/HS100B
[2]EEPROM: https://baike.baidu.com/item/EEPROM
[3]HS-100B的产品说明书: https://www.beikejiedeliulangmao.top/data/audio/HS100-datasheet-v1-2.pdf
[4]EEPROM: https://baike.baidu.com/item/EEPROM
[5]EZP2013烧录器: https://search.jd.com/Search?keyword=EZP2013
[6]烧录软件: https://www.beikejiedeliulangmao.top/data/audio/EZP2013V4.0.zip
[7]数据: https://www.beikejiedeliulangmao.top/data/audio/data.bin
[8]耳机产品: https://www.youtube.com/watch?v=tRbu8ow_dvk
[9]音频公头接线端子: https://www.google.com/search?q=%E9%9F%B3%E9%A2%91%E5%85%AC%E5%A4%B4%E6%8E%A5%E7%BA%BF%E7%AB%AF%E5%AD%90
[10]3.5毫米耳机规范: https://source.android.com/devices/accessories/headset/plug-headset-spec.html
[11]音频公头接线端子: https://www.google.com/search?q=%E9%9F%B3%E9%A2%91%E5%85%AC%E5%A4%B4%E6%8E%A5%E7%BA%BF%E7%AB%AF%E5%AD%90
[12]BlueZ: http://www.bluez.org/about/
[13]bluez-alsa: https://github.com/Arkq/bluez-alsa
[14]tinyb: https://github.com/intel-iot-devkit/tinyb
[15]lbt4j: https://github.com/olir/lbt4j