概述
上文 《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 {
AudioFormat configure(AudioFormat inputAudioFormat) throws UnhandledAudioFormatException;
boolean isActive();
void queueInput(ByteBuffer buffer);
void queueEndOfStream();
ByteBuffer getOutput();
boolean isEnded();
void flush();
void reset();
}
2. SonicAudioProcessor 流程分析
ExoPlayer 中,当用户针对音频进行倍速播放时,会在 DefaultAudioSink 中进行配置:
public final class DefaultAudioSink implements AudioSink {
public static class DefaultAudioProcessorChain implements AudioProcessorChain {
private final SonicAudioProcessor sonicAudioProcessor;
@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 ,其内部包含专门处理变速变调的逻辑,这里我们只关注核心流程:
public final class SonicAudioProcessor extends AudioProcessor {
private Sonic sonic;
private ByteBuffer buffer;
private ShortBuffer shortBuffer;
private ByteBuffer outputBuffer;
public float setSpeed(float speed) {
if (this.speed != speed) {
this.speed = speed;
pendingSonicRecreation = true;
}
return speed;
}
@Override
public void flush() {
if (isActive()) {
if (pendingSonicRecreation) {
sonic = new Sonic(speed, ...);
} else if (sonic != null) {
sonic.flush();
}
}
}
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);
}
@Override
public void queueInput(ByteBuffer inputBuffer) {
}
@Override
public ByteBuffer getOutput() {
ByteBuffer outputBuffer = this.outputBuffer;
this.outputBuffer = EMPTY_BUFFER;
return outputBuffer;
}
@Override
public void queueEndOfStream() {
if (sonic != null) {
sonic.queueEndOfStream();
}
inputEnded = true;
}
@Override
public boolean isEnded() {
return inputEnded && (sonic == null || sonic.getOutputSize() == 0);
}
}
纵观整个音频处理的流程,读者可以确定,最重要的核心逻辑在 queueInput() 方法中,其内部包含了音频输入pcm 数据的变速和变调的逻辑:
@Override
public void queueInput(ByteBuffer inputBuffer) {
if (inputBuffer.hasRemaining()) {
ShortBuffer shortBuffer = inputBuffer.asShortBuffer();
int inputSize = inputBuffer.remaining();
inputBytes += inputSize;
sonic.queueInput(shortBuffer);
inputBuffer.position(inputBuffer.position() + inputSize);
}
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();
}
sonic.getOutput(shortBuffer);
outputBytes += outputSize;
buffer.limit(outputSize);
outputBuffer = buffer;
}
}
3.Sonic 分析
经过上述分析,可得出 Sonic 向外暴露的几个重要方法如下:
final class Sonic {
public void queueInput(ShortBuffer buffer);
public void getOutput(ShortBuffer buffer);
public int getOutputSize();
public void queueEndOfStream();
public void flush();
}
从关键的 API 可以看出,Sonic 内部处理也需要引入 数据缓冲区 保证同步机制以及避免音频失真,并提供 queueEndOfStream 、flush 方法响应 SonicAudioProcessor 对应方法的调用。
本文只针对核心的倍速算法流程进行分析,即 Sonic 的 queueInput 方法:
public void queueInput(ShortBuffer buffer) {
int framesToWrite = buffer.remaining() / channelCount;
int bytesToWrite = framesToWrite * channelCount * 2;、
inputBuffer = ensureSpaceForAdditionalFrames(inputBuffer, inputFrameCount, framesToWrite);
buffer.get(inputBuffer, inputFrameCount * channelCount, bytesToWrite / 2);
inputFrameCount += framesToWrite;
processStreamInput();
}
private void processStreamInput() {
if (s > 1.00001 || s < 0.99999) {
changeSpeed(s);
} else {
copyToOutput(inputBuffer, 0, inputFrameCount);
inputFrameCount = 0;
}
}
从注释中可看出,changeSpeed() 方法就是核心的变速算法:
private void changeSpeed(float speed) {
do {
if (remainingInputToCopyFrameCount > 0) {
positionFrames += copyInputToOutput(positionFrames);
} else {
int period = findPitchPeriod(inputBuffer, positionFrames);
if (speed > 1.0) {
positionFrames += period + skipPitchPeriod(inputBuffer, positionFrames, speed, period);
} else {
positionFrames += insertPitchPeriod(inputBuffer, positionFrames, speed, period);
}
}
} while (positionFrames + maxRequiredFrameCount <= frameCount);
}
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 这个仓库:
https://github.com/qingmei2/soundtouch-android
2. 定义 SoundTouch 类
从本质上讲,抛开语音信号处理的具体算法实现,Sonic 和 SoundTouch 在结构上以及思想上并无不同,都是内部维护 Buffer 并不断 接收输入 和 返回输出:
public class SoundTouch {
static {
System.loadLibrary("soundtouch");
}
private static synchronized native final void setup(int track, int channels, int samplingRate, int bytesPerSample, float tempo, float pitchSemi);
private static synchronized native final void putBytes(int track, byte[] input, int length);
private static synchronized native final int getBytes(int track, byte[] output, int toGet);
private static synchronized native final void setTempoChange(int track, float tempoChange);
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];
for(int i = 0; i < outputSize; ++i) {
input[i] = inputBuffer.get(inputBuffer.position() + i);
}
soundTouch.putBytes(input);
inputBuffer.position(inputBuffer.position() + outputSize);
}
byte[] output = new byte[4096];
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();
}
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 音频倍速处理的切换。
参考
本文部分文案节选自下述资料,有兴趣的读者可以进行针对性深入了解。
关于我
Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ??,也欢迎关注我的 博客 或者 GitHub。
|