引言
最近开发的远程控制功能需要增加音频采集的功能,而Google为了保护唱片协会的利益,不允许获取系统原始输出的音频。如果有Root权限的话,你自然可以轻易的做到这件事。但是我们的使用场景是不能获取Root权限的,所以我们借助了一些硬件的支持,最终达到了截获手机原始音频输出的效果。具体的实现方案也是经历了几个发展阶段,接下里我就按时间顺序介绍一下这部分的发展历程。
方案一:外接声卡
方案介绍
这个方案的基本思路如下图,通过音频线将手机的音频数据传入声卡,然后将声卡和服务器通过USB相连,最终从服务器上截获该声卡的音频数据。
为了达到这个效果,必须将每款手机和与其相连的声卡建立绑定关系,这就需要每一个声卡都有一个唯一的序列号,这样当我们需要截获某一款手机的音频时,我们只需要从绑定关系表中查到与这款手机相连的声卡序列号,然后通过该序列号找到对应的声卡设备并进行录制。遗憾的是,在现有的产品中,我们没找到具有唯一序列号的USB声卡产品,我们只找到了HS-100B,它虽然没有唯一序列号,但是我们可以通过外接EEPROM的方式,写入自定义内容作为序列号。
所以,我们参考了HS-100B的产品说明书,从中我们得知EEPROM需要存储的内容如下图,其中画红线的部分可以用来定义声卡的序列号,我们之所以用Product String作为序列号的存储区域,主要是因为这部分内容可以通过FFMPEG的设备显示功能展示出来,我们只需要做一个字符串匹配就能定位需要截获的声卡设备。
EEPROM写入数据方式
- 购买EZP2013烧录器
- 安装烧录软件
- 选择EEPROM类型
- 写入数据
显示序列号的方式
- Mac: ffmpeg -f avfoundation -list_devices true -i ""
- Linux: aplay -l (yum install alsa-utils alsa-lib)
方案总结
这个方案总的来说,实现起来是比较麻烦的,虽然他可以获取到多声道的音频数据,但是其工作量太大,既要烧入数据,又要焊接电路,而且还要维护手机与音频采集卡的映射关系。
方案二:音频输出转接音频输入
介绍
这个实现方案是受一款现有耳机产品的启发,我们做了一个超级简易版。基本思路如下图,通过一个音频公头接线端子,将手机的音频输出接入到麦克风输入中,然后通过手机中的APP录制麦克风的输入从而达到内录的效果。
我们参考了Google的3.5毫米耳机规范,将音频公头接线端子的左右声道连接一个电阻并连入地线,然后选取左声道连接一个电阻接入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模块。
这里我们需要编辑/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直接通过BlueZ构建一个ALSA设备。此外,我觉得还应该通过类似于tinyb和lbt4j的库,来达到在Java中调度蓝牙模块的效果。
文章说明
更多有价值的文章均收录于贝贝猫的文章目录
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
创作声明: 本文基于下列所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。
参考内容
[1] 如何用 Android 手机完美录屏?收下这份「录屏 + 直播」全面指南
[2] Android音视频之AudioRecord
[3] a2dp-stream
[4] ubuntu-bluetooth-guide
[5] BlueZ_5
[6] BlueZ5_and_A2DP
[7] PulseAudio
[8] Bluez Secret
[9] Bluetooth
[10] A2DP學習筆記