Android音频BUG记录-音频数据丢失
本文略讲了Android多媒体开发中音频数据丢失的BUG处理。
最近在做项目,有一个功能是可以发送语音消息(手指按下录音,松开发送)。功能实现是用 AudioRecord 录音获取 PCM 数据,然后手动编码保存。但是测试反馈说录制出来的语音,前几百毫秒丢失了,问我怎么回事。代码是从成熟的项目移植过来的,没有过改动,移植前也没有丢失的情况,所以我怀疑是系统的问题。我试了下系统的录音软件,发现存在同样的问题。于是我问了下系统的开发工程师,他们给的解释是系统底层的启动也需要时间,所以有延时。
下面我放上理想、实际和修复方案流程图,方便理解。
从理想流程中,我们知道了几个关键的时间点:
- 我们调用接口,开始录音的时间点
- 用户看到提示,开始说话的时间点(显示弹框)
- 音频开始采集数据的时间点
在理想流程中,2/3 应该是同时发生的。这样音频就没有丢失。但是实际情况是,2 早于 3 发生,导致用户说的部分语音丢失了。至于出现这种情况的原因嘛,自然就是硬件延时咯。因为虽然音频服务是系统的常驻服务,不需要手动开启和关闭,但是系统底层的硬件,也和 APP 的相关操作一样,用时才开启,不用就关闭。关了再启动,自然也就有启动耗时(此处表现就是音频采集延时/音频丢失)。那怎么解决这个问题呢?这里有两种思路:
- 提前初始化硬件
- 延后用户说话的时机
先说第一种思路,硬件的初始化是在AudioRecord.startRecording()
调用后才开始初始化的。但是我们并不清楚用户什么时候按下手指(即用户何时触发录音),自然也就无法判断提前初始化的时机。
而第二种思路呢,从理论上和实际上看,都有可行性。因为用户按下手指后,一般不会立刻开始说话,而是会等到录音弹框出现后再说(录音弹框的作用就是提示用户可以说话了)。那我们自然可以延后用户说话的时机。
从流程图中,我们可以看出,音频的采集行为是发生在录音过程中的。,我们调用AudioRecord.startRecording()
开始录音,此时系统就判定我们开始录音了,方法AudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING
会返回 true,但此时硬件可能还在初始化,无法录音。这就说明了一件事:硬件的启动延时,导致了系统的录音判断不准确。需要自己维护录音中的状态判断。
顺着第二种思路研究,会发现我们需要解决的最关键的问题是:如何判断硬件初始化完成?
我们先做一个假设:如果硬件还在初始化,不能采集音频,而我们说话了,那么音频自然不会被采集上。此时我们去读声音数据,自然是读不到声音数据的,可表现为音量为 0。但是如果音频硬件可用,采集到的音频音量也就不会为 0。注意,此处的音量,不是系统设置中的音量,而是 PCM 音频数据流的音量。基于这个判断,我们继续研究,问题则又转换成了:如何从采集的 PCM 音频流得到音频音量,以及如何判断音频是否有效。
音量的计算
在日常生活中,我们通常用分贝来形容声音的大小。那么此处,我们也采用分贝来判断音频是否可用。分贝的定义可以百度,分贝是个相对值,其中有两个关键的公式:
最关键的就是第二个了。通过 PCM 数据,我们可以很简单的得到振幅,也就可以很简单的得到分贝的大小(PCM 介绍见 这篇文章 )。此处引用 另一篇文章 的一段话,来说明如何获取分贝。
在编程中,我们可以用以下公式计算两个声音之间的分贝动态范围,单位为分贝:dB = 20 * log(A1 / A2)。
其中 A1 和 A2 是两个声音的振幅,在程序中表示每个声音样本的大小。声音采样大小(也就是量化深度)
为 1bit 时,动态范围为 0,因为只可能有一个振幅。采样大小为 8bit 也就是 1 个字节时,最大振幅是最
小振幅的 256 倍。因此,动态范围是 48 分贝,计算公式:dB = 20 * log(256)。48 分贝的动态范围大约
是一个安静房间和一台运行着电动割草机之间的区别。如果将声音采样大小增加一倍到 16bit,产生的动态范
围则为 96 分贝,计算公式:dB = 20 * log(65536)。这非常接近听力最低阈值和产生痛感之间的区别,这
个范围被认为非常适合还原音乐。
现在我们知道了原理,那么再回过头来介绍下我们现在的代码,下面是我们现在的核心代码。
/**
* 判定是否正在录音
*
* @return true:正在录音
*/
public boolean isRecording() {
// 系统的接口
return mAudioRecord != null && mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING;
}
/**
* 开始录音
*/
public void startRecord() {
try {
mAudioRecord.startRecording();
// mRecordCallback 是应用自定义的回调,回调后即显示录音中的弹框,提示用户说话
mRecordCallback.onStartRecord();
if (isRecording()) {
// 该线程不停的从 AudioRecord 读取数据,并回调给应用
new RecordDataThread().start();
} else {
// 发生错误
mRecordCallback.onStartRecordError(new Exception("未在录音"));
}
} catch (Exception e) {
mRecordCallback.onStartRecordError(ERROR_START_DEVICE);
}
}
从上面的代码中,可以看出,我们开始录音,和录音中的判断,都是调用的系统接口。要修复音频丢失的问题,上面的代码就不可信了,需要改造。
首先判断我们上面的假设是否正确。假设现在有一段 16 位深的 PCM 音频数据 data,现在我们可以通过下面的方法,拿到分贝。
for (int i = 0; i < pcmAry.length; i += 2) {
// 带入振幅,根据公式算出分贝
Log.d(TAG, "当前音量大小:" + 20 * Math.log10(getShort(pcmAry, i)));
}
/**
* byte 流小端排序,第 1 个字节作为 16 位数据的低 8 位,第 2 个字节作为 16 位数据的高 8 位,就得到了一个 16 位深音频的采样振幅
*/
private short getShort(byte[] data, int start) {
return (short) ((data[start] & 0xFF) | (data[start + 1] << 8));
}
采集一段音频,看看实际情况。
上面的截图验证了我们的猜想,当录音硬件未启动成功时,采集不到音频,而我们根据拿到的无效音频计算得到的分贝,都是无效的数值。但是当音频数据可用时,得到的分贝就是正常的数值了。
根据验证得到的结论,我们下面就可以判断音频是否可用了。
// 当前是否正在录音的标志
private boolean isRecording = false;
/**
* 判断音频是否可用
*/
private boolean isRecording(Double voiceDB) {
return voiceDB != null && !voiceDB.isNaN() && !voiceDB.isInfinite();
}
根据思路,我们需要在循环读取 PCM 数据时(循环取决于系统提供的录音中的接口判断),判断音频是否可用,可用时,回调开始录音,告诉用户可以开始说话了,并记录下录音中的状态。就像上面的流程图那样。
注意:此判断方法仅可在开始录音(AudioRecord.startRecording())后,音频硬件还未启动完成时调用,用于判断何时可以录音。即判断 无 —> 有,而不是判断 有 —> ?。另外,虽然实际开始录音的时间点和系统判断的时间点不一致,但是结束录音的时间点应该保持一致,否则就会将问题复杂化。当然,结束录音的时间点保持一致是最正确的选择,因为用户要结束录音,那就可以停止采集音频了。
上面的 BUG 及 BUG 修改思路就说明完了。这个 BUG 我暂未修改,因为时间有限,且影响面比较广。跟产品等沟通后,决定暂不处理。如果要修复,就放到后面的迭代中。到时候有时间,且能充分测试。所以这个修改思路,我实际上只做了部分而非完全验证。如果有朋友验证过,发现我的思路有误或者思路根本不可用,请指出。