这篇文章可以为你提供一个解决录音和播放的同步的思路,而且解决了声音从手机传输到耳机上的延时的问题。
你需要有一些关于音频的基本认识,如果你还不是很了解,建议先阅读前面两篇文章。
场景描述
音乐中只有一种声音有时候很单薄的,我们经常希望把不同的声音加在一起,但是在录制的时候我们需要严格同步起来,把两种声音的时差控制在听觉允许的范围内,才可能获得我们想要的结果。另外一点,在录制的时候,为了不把播放的声音和人声或者器乐声混到一块,通常都需要录制者带着耳机边听边录。
为了实现最终两个或者多个声音能非常好的契合到一起,除了要解决录音和播放的同步,还需要考虑到声音从手机传输到耳机上的延时。这个场景除了会出现在一些比较专业的音乐软件上,常用的 K 歌软件也不可避免会遇到这个问题。
一线希望:MediaSyncEvent?
先抛出结论:并不能解决问题~
肯定先从 SDK 入手,发现 AudioRecord
里面有个方法 startRecording(MediaSyncEvent syncEvent)
, 再看了一遍文档, 仿佛在黑暗中看到了一丝光亮。
The MediaSyncEvent class defines events that can be used to synchronize playback or capture * actions between different players and recorders.
然而对于它的使用资料实在太少,stackoverflow 上有个提问是 0 回答:这里。翻了 Google 很久,最终在官方的 CTS (Compatibility Test Suite) 中找到了它的身影:在 AudioRecordTest 的testSynchronizedRecord
方法中。这里顺便提一下,这些单元测试是非常好实打实的官方学习资料,如果苦于找不到答案的时候,不妨来这里找找看。
研究完testSynchronizedRecord
我们回来看看MediaSyncEvent
它究竟是用来干嘛的?
MediaSycEvent
可以通过 MediaSyncEvent.createEvent()
进行构造,它支持两种事件类型。
1 | /** |
2 | * No sync event specified. When used with a synchronized playback or capture method, the |
3 | * behavior is equivalent to calling the corresponding non synchronized method. |
4 | */ |
5 | public static final int SYNC_EVENT_NONE = AudioSystem.SYNC_EVENT_NONE; |
6 | |
7 | /** |
8 | * The corresponding action is triggered only when the presentation is completed |
9 | * (meaning the media has been presented to the user) on the specified session. |
10 | * A synchronization of this type requires a source audio session ID to be set via |
11 | * {@link #setAudioSessionId(int) method. |
12 | */ |
13 | public static final int SYNC_EVENT_PRESENTATION_COMPLETE = AudioSystem.SYNC_EVENT_PRESENTATION_COMPLETE; |
其实就只有一种,SYNC_EVENT_NONE
就相当于没有同步事件,常规的 AudioRecord.startRecording()
方法就是用的这个参数。从AudioRecordTest.testSynchronizedRecord 的测试用例中可以得知SYNC_EVENT_PRESENTATION_COMPLETE
的作用其实是等AudioTrack
播放完的瞬间才触发AudioRecord
的录音,这明显和我们的需求是不通的,没想明白在哪些场景会有这个需求,Google 要专门提供这个一个参数,如果有想法的朋友可以给我留言。
CyclicBarrier 来帮忙
此路不通之后,我们需要另辟蹊径。在运动员比赛前,我们需要先让大家在同一线上等待,直到看到信号发出再一起出发。在这里,我们也需要让 AudioTrack
和 AudioRecord
先在同一起跑线上等着,然后一起出发,各奔东西。Java 世界里面的CyclicBarrier
就很合适做这件事情。
1 | // play 和 record 两个同步线程 |
2 | CyclicBarrier recordBarrier = new CyclicBarrier(2); |
3 | |
4 | AudioTrack audioTrack; |
5 | AudioRecord audioRecord; |
6 | |
7 | // UI Thread |
8 | public void start(){ |
9 | recordBarrier.reset(); |
10 | audioTrack.play(); |
11 | audioRecord.startRecording(); |
12 | new RecordThread().start(); |
13 | new PlayThread().start(); |
14 | } |
15 | |
16 | class RecordThread extends Thread{ |
17 | public void run(){ |
18 | //等play线程开始写的时候read |
19 | recordBarrier.await(); |
20 | audioRecord.read(); |
21 | } |
22 | } |
23 | |
24 | class PlayThread extends Thread{ |
25 | public void run(){ |
26 | //等reacord线程开始读的时候write |
27 | recordBarrier.await(); |
28 | audioTrack.write(); |
29 | } |
30 | } |
上面通过CyclicBarrier
让 AudioTrack
的 write
和 AudioRecord
的 read
在同一起跑线上,似乎事情已经解决了,然而并没有。虽然你开始往耳机write
数据,但是耳机接收到信号真正发出声音还要一段时间。
处理录音延时问题
我们回到用户真实的使用场景中,来看看问题是如何发生的?
播放源是真实的数据源,比如位于 1ms 的伴奏数据块从写入AudioTrack
开始到耳机播放可能已经是 100ms 后的事情了,而用户这个时候才开始录入自己的声音,这里还可能会有从设备开始采集声音到缓冲区的一个延时,如果是使用蓝牙耳机的话,那延时的问题就会更加突出了。
我们来感受一下延时的情况,在咖啡馆录的音,杂音比较多,但是不难听出来录音是比原来的声音要延迟了。
看下声波图:
解决方案:
当录音和播放开始之后,它们就会在同一时域中平行演绎,根据延时的特点,我们不难得出:
录音时长 = 延迟时长 + 播放时长 + 额外时长(播放完之后的自由录音)
只要我们能知道延迟的时长,在读取录音数据的时候,我们只要截取掉 AudioRecord
前面的延迟数据就可以让问题得到解决了。那怎么才能知道应该截掉多少个 byte 的数据呢?在这里我想到了一个巧妙的解决方法,给大家分享一下思路。
从上面的节拍器的声波图我们可以看到,波峰对应的就是哒
的那一声,录音音轨和节拍器音轨上的波峰差就是我们想知道的延迟时长
。根据这个特点,我们可以设计出获取这个延迟时长
的一个思路:
- 让用户带上耳机,根据固定节奏的节拍器(要有一定时间间隔)声音进行录音,简单的
啦..啦..啦..
就好。 - 根据获取到的录音数据和原始的节拍器声音进行比较, 我取的是 8 个波峰区间数据进行比较,如果延迟误差都在一个小范围内,那就认为是正确的。
具体的算法大概如下:
1 | //ANALYZE_BEAT_LEN = 8 |
2 | int[] maxPositions = new int[ANALYZE_BEAT_LEN]; |
3 | for(i = 0; i != maxPositions.length; i++){ |
4 | byte[] segBytes = getSegBytes(); //获取一拍时长的数据 |
5 | maxPositions[i] = getMaxSamplePos(segBytes);// 获取拍中波峰所在的大致位置 |
6 | } |
7 | |
8 | //按小到大排序 |
9 | Arrays.sort(maxPositions); |
10 | |
11 | //取中间一半的值,如果平均值误差在 10 毫秒内,就认为是正确的 |
12 | int sampleTotalValue = 0; |
13 | int sampleLen = ANALYZE_BEAT_LEN / 2; |
14 | int[] sampleValues = new int[sampleLen]; |
15 | |
16 | for(int beginIndex = sampleLen / 2, i=0; i != sampleLen; i++){ |
17 | sampleValues[i] = maxPositions[ i + beginIndex]; |
18 | sampleTotalValue += sampleValues[i]; |
19 | } |
20 | |
21 | int averSampleValue = sampleTotalValue / sampleLen; |
22 | |
23 | boolean isValid = true; |
24 | for(int sampleValue : sampleValues){ |
25 | //errorRangeByteLen : 10 毫秒的 byte 长度 |
26 | if(Math.abs(averSampleValue - sampleValue) > errorRangeByteLen){ |
27 | isValid = false; |
28 | } |
29 | } |
30 | |
31 | if(isValid){ |
32 | stopPlay = true; |
33 | // 结果 |
34 | int result = averSampleValue; |
35 | } |
结果展示
波形图:
声音结果:
调整之后情况就改善多了,听觉上基本感受不到延迟了。但是这样会给用户带来一些不方便,换耳机的时候需要重新调整。个人的认知实在有限,虽然这可能是个有效的方法,但肯定不是最佳的做法,同时好奇像唱吧这种软件是如何处理的?欢迎大牛们交流一下想法~
参考资料
关于Agile Studio工作室
我们是一支由资深独立开发者和设计师组成的团队,成员均有扎实的技术实力和多年的产品设计开发经验,提供可信赖的软件定制服务。
未经声明,本站文章均为原创,转载请附上链接:
http://blog.agilestudio.cn/Android-Sync-Audio-Record-With-Play/