Android 分场景集成不同音频倍速算法的实现

概述

上文 《Android 音频倍速的原理与算法分析》 中, 我们针对音频倍速的基本原理进行了梳理,并逐步引申出了 Android 平台上常用的2种算法实现:SonicSoundTouch

初步结论是,在用户启用音频倍速时,我们需要 根据具体场景切换不同实现 ,以此保证最佳的用户体验。

举例来说,对于常规音乐——尤其是背景乐、打击感比较强的音乐,我们优先选择 SoundTouch, 而对于人声更纯粹的音频(相声评书、歌手清唱等)而言,Sonic 才是更好的选择。

本文以 Google 开源的 ExoPlayer 为例,从源码分析播放器自身的 Sonic 具体是如何实现的倍速;之后,再尝试将 SoundTouch 集成,为播放器提供不同应用场景下不同的倍速实现。

Sonic 源码分析

1. AudioProcessor 音频处理器简介

ExoPlayer 默认内部集成了 Sonic 实现音频的变速及变调,并且是 java 版本的,适合着手学习。

开发者只需要实现 ExoPlayer 提供的 AudioProcessor 接口就能对定制属于自己的音频效果,比如变速变调、萝莉音、背景音效等等。

这样的设计非常常见,比如 OkHttpinterceptorView 的事件分发和拦截机制等等。

// 音频处理器接口,将音频数据作为输入并进行转换,从而修改其通道数,编码或采样率。
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 内部处理也需要引入 数据缓冲区 保证同步机制以及避免音频失真,并提供 queueEndOfStreamflush 方法响应 SonicAudioProcessor 对应方法的调用。

本文只针对核心的倍速算法流程进行分析,即 SonicqueueInput 方法:

// 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提供了javaC++多平台实现不同,SoundTouch 只有 C++ 的实现,因此需要通过CMake编译生成so文件,从而接入在Android平台上。

对此笔者参考了 SoundTouchDemo 版本的代码,由于该仓库版本较旧,因此略微进行了更新,感兴趣的读者可以参考 soundtouch-android 这个仓库:

https://github.com/qingmei2/soundtouch-android

2. 定义 SoundTouch 类

从本质上讲,抛开语音信号处理的具体算法实现,SonicSoundTouch 在结构上以及思想上并无不同,都是内部维护 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,并可根据业务需求,动态调整SonicAudioProcessorSoundTouchAudioProcessor音频倍速处理的切换。

参考

本文部分文案节选自下述资料,有兴趣的读者可以进行针对性深入了解。

关于我

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub

猜你喜欢

转载自blog.csdn.net/mq2553299/article/details/121312276