上文 《Android 音频倍速的原理与算法分析》 中, 我们针对音频倍速的基本原理进行了梳理,并逐步引申出了 Android 平台上常用的2种算法实现:Sonic 和 SoundTouch。
初步结论是,在用户启用音频倍速时,我们需要 根据具体场景切换不同实现 ,以此保证最佳的用户体验。
举例来说,对于常规音乐——尤其是背景乐、打击感比较强的音乐,我们优先选择 SoundTouch, 而对于人声更纯粹的音频(相声评书、歌手清唱等)而言,Sonic 才是更好的选择。
本文以 Google 开源的 ExoPlayer 为例,从源码分析播放器自身的 Sonic 具体是如何实现的倍速;之后,再尝试将 SoundTouch 集成,为播放器提供不同应用场景下不同的倍速实现。
Sonic 源码分析 1. AudioProcessor 音频处理器简介ExoPlayer 默认内部集成了 Sonic 实现音频的变速及变调,并且是 java 版本的,适合着手学习。
开发者只需要实现 ExoPlayer 提供的 AudioProcessor 接口就能对定制属于自己的音频效果,比如变速变调、萝莉音、背景音效等等。
这样的设计非常常见,比如 OkHttp 的 interceptor、View 的事件分发和拦截机制等等。
// 音频处理器接口,将音频数据作为输入并进行转换,从而修改其通道数,编码或采样率。 public interface AudioProcessor { // 将Processor配置为处理指定格式的输入音频 // 1.调用此方法后,调用isActive()以确定音频处理器是否处于活动状态。 如果此实例处于活动状态,则返回配置的输出音频格式; // 2.调用此方法后,有必要flush()以应用新配置; // 3.应用新配置之前,仍可通过旧的输入/输出格式安全地将数据入列和输出; // 4.当配置发生变更,请调用queueEndOfStream()。 AudioFormat configure(AudioFormat inputAudioFormat) throws UnhandledAudioFormatException; // 返回当前Processor是否是活跃的,并处理InputBuffer。 boolean isActive(); // 将音频数据通过 InputBuffer 入列以供处理。 void queueInput(ByteBuffer buffer); // 将输入流标记为结束 // 调用getOutput()将返回所有剩余的输出数据,可能需要多次调用才能读取所有剩余的输出数据。一旦读取了所有剩余的输出数据,isEnded()将返回true。 void queueEndOfStream(); // 返回一个缓冲区,该缓冲区包含在其 position 和 limit 之间的已处理输出数据。 ByteBuffer getOutput(); // 返回当前Processor是否不再有数据的输出,直到其调用 flush() 且新的数据输入进来。 boolean isEnded(); // 清除所有缓冲的数据和挂起的输出。 // 若之后Processor仍处于活跃状态,还需准备一个最新格式配置的新输入流。 void flush(); // 重置并释放所有资源 void reset(); }2. SonicAudioProcessor 流程分析
ExoPlayer 中,当用户针对音频进行倍速播放时,会在 DefaultAudioSink 中进行配置:
// DefaultAudioSink.java public final class DefaultAudioSink implements AudioSink { public static class DefaultAudioProcessorChain implements AudioProcessorChain { // ... private final SonicAudioProcessor sonicAudioProcessor; // player.setSpeed() 最终执行该方法,通过 sonicAudioProcessor.setSpeed() 应用倍速配置 @Override public PlaybackParameters applyPlaybackParameters(PlaybackParameters playbackParameters) { silenceSkippingAudioProcessor.setEnabled(playbackParameters.skipSilence); return new PlaybackParameters( sonicAudioProcessor.setSpeed(playbackParameters.speed), sonicAudioProcessor.setPitch(playbackParameters.pitch), sonicAudioProcessor.setVolume(playbackParameters.volume), playbackParameters.skipSilence); } } }
了解 SonicAudioProcessor 完整的工作流程有利于进一步理解 Sonic,其内部包含专门处理变速变调的逻辑,这里我们只关注核心流程:
// SonicAudioProcessor.java public final class SonicAudioProcessor extends AudioProcessor { private Sonic sonic; private ByteBuffer buffer; private ShortBuffer shortBuffer; private ByteBuffer outputBuffer; // 1. 设置倍速后进行标记,下次新的音频数据输入时,重建Sonic public float setSpeed(float speed) { if (this.speed != speed) { this.speed = speed; pendingSonicRecreation = true; } return speed; } // 2. 缓冲区数据清空,根据新的配置重建或释放Sonic @Override public void flush() { if (isActive()) { if (pendingSonicRecreation) { // 2.1 重建对应音调、音速的Sonic sonic = new Sonic(speed, ...); } else if (sonic != null) { // 2.2 重置sonic sonic.flush(); } } // ... } // 3.只有当前音速、音调、声音至少有一项发生变更,SonicProcessor才会开始处理数据(活跃的) public boolean isActive() { return pendingOutputAudioFormat.sampleRate != Format.NO_VALUE && (Math.abs(speed - 1f) >= 0.01f || Math.abs(pitch - 1f) >= 0.01f || Math.abs(volume - 1f) >= 0.01f || pendingOutputAudioFormat.sampleRate != pendingInputAudioFormat.sampleRate); } // 4.【重要】处理音频的InputBuffer,比如倍速处理 @Override public void queueInput(ByteBuffer inputBuffer) { // ... 详细处理 } // 5.返回音频的OutputBuffer,这个Buffer内已经是倍速完成后的音频数据了 @Override public ByteBuffer getOutput() { ByteBuffer outputBuffer = this.outputBuffer; this.outputBuffer = EMPTY_BUFFER; return outputBuffer; } // 6.标记本次输入结束 @Override public void queueEndOfStream() { if (sonic != null) { // 音频输入结束,这里强制将buffer中剩下的数据进行倍速处理并返回 // 该方法执行完成后,Sonic中buffer是干净的 sonic.queueEndOfStream(); } inputEnded = true; } // 7.该方法返回true,表示AudioProcessor的本次处理结束 @Override public boolean isEnded() { return inputEnded && (sonic == null || sonic.getOutputSize() == 0); } }
纵观整个音频处理的流程,读者可以确定,最重要的核心逻辑在 queueInput() 方法中,其内部包含了音频输入pcm数据的变速和变调的逻辑:
// SonicAudioProcessor.java @Override public void queueInput(ByteBuffer inputBuffer) { if (inputBuffer.hasRemaining()) { ShortBuffer shortBuffer = inputBuffer.asShortBuffer(); int inputSize = inputBuffer.remaining(); inputBytes += inputSize; // 1. 将未处理的pcm数据输入sonic sonic.queueInput(shortBuffer); inputBuffer.position(inputBuffer.position() + inputSize); } // 2. 从sonic的buffer中获取变速后的outputSize, // 创建一个空的shortBuffer用于接收变速后的pcm输出数据 int outputSize = sonic.getOutputSize(); if (outputSize > 0) { if (buffer.capacity() < outputSize) { buffer = ByteBuffer.allocateDirect(outputSize).order(ByteOrder.nativeOrder()); shortBuffer = buffer.asShortBuffer(); } else { buffer.clear(); shortBuffer.clear(); } // 3.将变速后的数据输出到shortBuffer,outputBuffer最终由getOutput()方法返回 sonic.getOutput(shortBuffer); outputBytes += outputSize; buffer.limit(outputSize); outputBuffer = buffer; } }3.Sonic 分析
经过上述分析,可得出 Sonic 向外暴露的几个重要方法如下:
final class Sonic { // 1.将InputBuffer中的剩余数据加入自己的队列 public void queueInput(ShortBuffer buffer); // 2.获取可用输出,输出到ShortBuffer中,buffer将从position位置输入数据 public void getOutput(ShortBuffer buffer); // 3.获取可用输出的size public int getOutputSize(); // 4.将已经入列的所有数据强制生成输出,不会在输出中导致额外的延迟,但有可能会导致失真 public void queueEndOfStream(); // 5.清除状态以准备接收新的InputBuffer public void flush(); }
从关键的 API 可以看出,Sonic 内部处理也需要引入 数据缓冲区 保证同步机制以及避免音频失真,并提供 queueEndOfStream、flush 方法响应 SonicAudioProcessor 对应方法的调用。
本文只针对核心的倍速算法流程进行分析,即 Sonic 的 queueInput 方法:
// Sonic.java public void queueInput(ShortBuffer buffer) { // 1.计算buffer中数据对应的帧数和字节数 int framesToWrite = buffer.remaining() / channelCount; int bytesToWrite = framesToWrite * channelCount * 2;、 // 2.保证自身的inputBuffer有足够的空间并输入数据 inputBuffer = ensureSpaceForAdditionalframes(inputBuffer, inputframeCount, framesToWrite); buffer.get(inputBuffer, inputframeCount * channelCount, bytesToWrite / 2); inputframeCount += framesToWrite; // 3.处理数据数据 processStreamInput(); } private void processStreamInput() { // ... // 4.这里只关心倍速相关处理: if (s > 1.00001 || s < 0.99999) { // 4.1 若变速,执行倍速算法 changeSpeed(s); } else { // 4.2 并未倍速,将数据原封不动输入到outputBuffer copyToOutput(inputBuffer, 0, inputframeCount); inputframeCount = 0; } // ... }
从注释中可看出,changeSpeed()方法就是核心的变速算法:
private void changeSpeed(float speed) { // ... // 1.多次执行直至输入数据处理完毕 do { if (remainingInputToCopyframeCount > 0) { positionframes += copyInputToOutput(positionframes); } else { // 2.核心方法,计算基音周期 int period = findPitchPeriod(inputBuffer, positionframes); // 3.核心方法,进行语音信号的合成,以达到倍速的效果 if (speed > 1.0) { positionframes += period + skipPitchPeriod(inputBuffer, positionframes, speed, period); } else { positionframes += insertPitchPeriod(inputBuffer, positionframes, speed, period); } } } while (positionframes + maxRequiredframeCount <= frameCount); // ... } // 4.skipPitchPeriod()内部计算交给了overlapAdd()函数,不细讲 private int skipPitchPeriod(short[] samples, int position, float speed, int period) { // ... overlapAdd(...); return newframeCount; }集成 SoundTouch 1. 编译so
和Sonic提供了java和C++多平台实现不同,SoundTouch 只有 C++ 的实现,因此需要通过CMake编译生成so文件,从而接入在Android平台上。
对此笔者参考了 SoundTouchDemo 版本的代码,由于该仓库版本较旧,因此略微进行了更新,感兴趣的读者可以参考 soundtouch-android 这个仓库:
2. 定义 SoundTouch 类https://github.com/qingmei2/soundtouch-android
从本质上讲,抛开语音信号处理的具体算法实现,Sonic 和 SoundTouch 在结构上以及思想上并无不同,都是内部维护 Buffer 并不断 接收输入 和 返回输出:
public class SoundTouch { static { System.loadLibrary("soundtouch"); } // 1.初始化SoundTouch,需要传入音频流类型、声道数、采样率、采样大小、音频速度、音调等参数 private static synchronized native final void setup(int track, int channels, int samplingRate, int bytesPerSample, float tempo, float pitchSemi); // 2.将pcm数据输入,参考Sonic#queueInput private static synchronized native final void putBytes(int track, byte[] input, int length); // 3.获取输出和输出大小,参考Sonic#getOutput 以及 Sonic#getOutputSize private static synchronized native final int getBytes(int track, byte[] output, int toGet); // 4.设置倍速 private static synchronized native final void setTempoChange(int track, float tempoChange); // 5.参考Sonic#flush private static synchronized native final void finish(int track, int bufSize); }
3.定义 SoundTouchAudioProcessor 类读者只需关注核心逻辑,省略变调等其它实现,完整代码参考 这里 。
整体逻辑梳理清楚后,即可依葫芦画瓢,定制对应的 SoundTouchAudioProcessor 实现了,限于篇幅,本文仅列出最重要的queueInput方法的实现:
final class SoundTouchAudioProcessor { public void queueInput(ByteBuffer inputBuffer) { SoundTouch soundTouch = (SoundTouch)Assertions.checkNotNull(this.soundTouch); new StringBuilder(""); byte[] input = new byte[0]; int outputSize; if (inputBuffer.hasRemaining()) { ShortBuffer shortBuffer = inputBuffer.asShortBuffer(); outputSize = inputBuffer.remaining(); this.inputBytes += (long)outputSize; input = new byte[outputSize]; // 1.和Sonic不同,这里我们将InputBuffer中剩余的数据取出,存放到新的byte[]中 for(int i = 0; i < outputSize; ++i) { input[i] = inputBuffer.get(inputBuffer.position() + i); } // 2.将输入交给soundTouch soundTouch.putBytes(input); inputBuffer.position(inputBuffer.position() + outputSize); } byte[] output = new byte[4096]; // 3.获取输出,相比Sonic方便的是,SoundTouch#getBytes 将数据大小一并返回了 outputSize = soundTouch.getBytes(output); if (outputSize > 0) { if (this.buffer.capacity() < outputSize) { this.buffer = ByteBuffer.allocateDirect(outputSize).order(ByteOrder.nativeOrder()); this.shortBuffer = this.buffer.asShortBuffer(); } else { this.buffer.clear(); this.shortBuffer.clear(); } // 4.最后和Sonic一样,将输出交给outputBuffer this.buffer.put(Arrays.copyOf(output, outputSize)); this.outputBytes += (long)outputSize; this.buffer.limit(outputSize); this.buffer.position(0); this.outputBuffer = this.buffer; } } }
最终,我们成功实现了SoundTouchAudioProcessor,并可根据业务需求,动态调整SonicAudioProcessor和SoundTouchAudioProcessor音频倍速处理的切换。
参考本文部分文案节选自下述资料,有兴趣的读者可以进行针对性深入了解。
-
A Review of Time-Scale Modification of Music Signals @ Jonathan Driedger @ Meinard Müller
-
TSM时域压扩(变速不变调)算法总结 @DBinary
-
Exoplayer学习07 音频处理器 AudioProcessor @海底夜行人
-
音频变速变调原理及 soundtouch 代码分析 @floer rivor
-
音频变速变调 -sonic 源码分析 @floer rivor
-
google-ExoPlayer @GitHub
-
bilibili-ijkplayer @GitHub
-
bilibili-soundtouch @GitHub
-
waywardgeek-sonic @GitHub
Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub。
- 我的Android学习体系
- 关于文章纠错
- 关于知识付费
- 关于《反思》系列
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)