android音频编辑之音频裁剪

前言

本篇开始讲解音频编辑的具体操作,从相对简单的音频裁剪开始。要进行音频裁剪,我的方案是开启一个Service服务用于音频裁剪的耗时操作,主界面发送裁剪命令,同时注册EventBus接受裁剪的消息(当然也可以使用广播接受的方式)。因此,在本篇主要会讲解以下内容:

  • 音频编辑项目的整体结构
  • 音频裁剪方法的流程实现
  • 获取音频文件相关信息
  • 计算裁剪时间点对应文件中数据的位置
  • 写入wav文件头信息
  • 写入wav文件裁剪部分的音频数据

下面是音频裁剪效果图:

音频裁剪

音频编辑项目的整体结构

该音频测试项目的结构其实很简单,大致就是以Fragment为基础的各个界面,以IntentService为基础的后台服务,以及最重要的音频编辑工具类实现。大致结构如下:

  • CutFragment,裁剪页面。选择音频,裁剪音频,播放裁剪后的音频,同时注册了EventBus以便接受后台音频编辑操作发送的消息进行更新。
  • AudioTaskService,音频编辑服务Service。继承自IntentService,可以在后台任务的线程中执行耗时音频编辑操作。
  • AudioTaskCreator,音频编辑任务命令发送器。通过它可以启动音频编辑服务AudioTaskService,并发送具体的编辑操作给它。
  • AudioTaskHandler,音频编辑任务处理器。AudioTaskService接受到的intent任务都交给它去处理。这里具体处理裁剪,合成等操作。
  • AudioEditUtil, 音频编辑工具类。提供裁剪,合成等音频编辑的方法。
  • 另外还有其他相关的音频工具类。

现在我们看看它们之间的主要流程实现:

CutFragment发起音频裁剪任务,同时接收更新音频编辑消息

public class CutFragment extends Fragment {

  ...

  /**
   * 裁剪音频
   */
  private void cutAudio() {

    String path1 = tvAudioPath1.getText().toString();

    if(TextUtils.isEmpty(path1)){
      ToastUtil.showToast("音频路径为空");
      return;
    }

    float startTime = Float.valueOf(etStartTime.getText().toString());
    float endTime = Float.valueOf(etEndTime.getText().toString());

    if(startTime <= 0){
      ToastUtil.showToast("时间不对");
      return;
    }
    if(endTime <= 0){
      ToastUtil.showToast("时间不对");
      return;
    }
    if(startTime >= endTime){
      ToastUtil.showToast("时间不对");
      return;
    }

    //调用AudioTaskCreator发起音频裁剪任务
    AudioTaskCreator.createCutAudioTask(getContext(), path1, startTime, endTime);
  }
  
  /**
   * 接收并更新裁剪消息
   */
  @Subscribe(threadMode = ThreadMode.MAIN) public void onReceiveAudioMsg(AudioMsg msg) {
    if(msg != null && !TextUtils.isEmpty(msg.msg)){
      tvMsgInfo.setText(msg.msg);
      mCurPath = msg.path;
    }
  }

}

AudioTaskCreator启动音频裁剪任务AudioTaskService

public class AudioTaskCreator {

  ...

  /**
   * 启动音频裁剪任务
   * @param context
   * @param path
   */
  public static void createCutAudioTask(Context context, String path, float startTime, float endTime){

    Intent intent = new Intent(context, AudioTaskService.class);
    intent.setAction(ACTION_AUDIO_CUT);
    intent.putExtra(PATH_1, path);
    intent.putExtra(START_TIME, startTime);
    intent.putExtra(END_TIME, endTime);

    context.startService(intent);
  }

}

AudioTaskService服务将接受的Intent任务交给AudioTaskHandler处理

/**
 * 执行后台任务的服务
 */
public class AudioTaskService extends IntentService {

  private AudioTaskHandler mTaskHandler;

  public AudioTaskService() {
    super("AudioTaskService");
  }

  @Override public void onCreate() {
    super.onCreate();

    mTaskHandler = new AudioTaskHandler();
  }

  /**
   * 实现异步任务的方法
   *
   * @param intent Activity传递过来的Intent,数据封装在intent中
   */
  @Override protected void onHandleIntent(Intent intent) {

    if (mTaskHandler != null) {
      mTaskHandler.handleIntent(intent);
    }
  }
}

AudioTaskService服务将接受的Intent任务交给AudioTaskHandler处理,根据不同的Intent action,调用不同的处理方法

/**
 * 
 */
public class AudioTaskHandler {

  public void handleIntent(Intent intent){

    if(intent == null){
      return;
    }

    String action = intent.getAction();

    switch (action){
      case AudioTaskCreator.ACTION_AUDIO_CUT:

      {
        //裁剪
        String path = intent.getStringExtra(AudioTaskCreator.PATH_1);
        float startTime = intent.getFloatExtra(AudioTaskCreator.START_TIME, 0);
        float endTime = intent.getFloatExtra(AudioTaskCreator.END_TIME, 0);
        cutAudio(path, startTime, endTime);
      }
        break;
        
        //其他编辑任务
        ...
        
      default:
      break;
    }

  }

  /**
   * 裁剪音频
   * @param srcPath 源音频路径
   * @param startTime 裁剪开始时间
   * @param endTime 裁剪结束时间
   */
  private void cutAudio(String srcPath, float startTime, float endTime){
    //具体裁剪操作
  }
  
}

音频裁剪方法的实现

接下来是音频裁剪的具体操作。还记得上一篇文章说的,音频的裁剪操作都是要基于PCM文件或者WAV文件上进行的,所以对于一般的音频文件都是需要先解码得到PCM文件或者WAV文件,才能进行具体的音频编辑操作。因此音频裁剪操作需要经历以下步骤:

  1. 计算解码后的wav音频路径
  2. 对源音频进行解码,得到解码后源WAV文件
  3. 创建源wav文件和目标WAV音频频的RandomAccessFile,以便对它们后面对它们进行读写操作
  4. 根据采样率,声道数,采样位数,和当前时间,计算开始时间和结束时间对应到源文件的具体位置
  5. 根据采样率,声道数,采样位数,裁剪音频数据大小等,计算得到wav head文件头byte数据
  6. 将wav head文件头byte数据写入到目标文件中
  7. 将源文件的开始位置到结束位置的数据复制到目标文件中
  8. 删除源wav文件,重命名目标wav文件为源wav文件,即得到最终裁剪后的wav文件

如下,对源音频进行解码,得到解码后的音频文件,然后根据解码音频文件得到Audio音频相关信息,里面记录音频相关的信息如采样率,声道数,采样位数等。

/**
 * 
 */
public class AudioTaskHandler {

  /**
   * 裁剪音频
   * @param srcPath 源音频路径
   * @param startTime 裁剪开始时间
   * @param endTime 裁剪结束时间
   */
  private void cutAudio(String srcPath, float startTime, float endTime){
    String fileName = new File(srcPath).getName();
    String nameNoSuffix = fileName.substring(0, fileName.lastIndexOf('.'));
    fileName = nameNoSuffix + Constant.SUFFIX_WAV;
    String outName = nameNoSuffix + "_cut.wav";

    //裁剪后音频的路径
    String destPath = FileUtils.getAudioEditStorageDirectory() + File.separator + outName;

    //解码源音频,得到解码后的文件
    decodeAudio(srcPath, destPath);

    if(!FileUtils.checkFileExist(destPath)){
      ToastUtil.showToast("解码失败" + destPath);
      return;
    }

    //获取根据解码后的文件得到audio数据
    Audio audio = getAudioFromPath(destPath);

    //裁剪操作
    if(audio != null){
      AudioEditUtil.cutAudio(audio, startTime, endTime);
    }

    //裁剪完成,通知消息
    String msg = "裁剪完成";
    EventBus.getDefault().post(new AudioMsg(AudioTaskCreator.ACTION_AUDIO_CUT, destPath, msg));
  }
  
  /**
   * 获取根据解码后的文件得到audio数据
   * @param path
   * @return
   */
  private Audio getAudioFromPath(String path){
    if(!FileUtils.checkFileExist(path)){
      return null;
    }

    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
      try {
        Audio audio = Audio.createAudioFromFile(new File(path));
        return audio;
      } catch (Exception e) {
        e.printStackTrace();
      }
    }

    return null;
  }
  
}

获取音频文件相关信息

而获取Audio信息其实就是解码时获取MediaFormat,然后获取音频相关的信息的。

/**
 * 音频信息
 */
public class Audio {
    private String path;
    private String name;
    private float volume = 1f;
    private int channel = 2;
    private int sampleRate = 44100;
    private int bitNum = 16;
    private int timeMillis;

    ...

    @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) public static Audio createAudioFromFile(File inputFile) throws Exception {
        MediaExtractor extractor = new MediaExtractor();
        MediaFormat format = null;
        int i;

        try {
            extractor.setDataSource(inputFile.getPath());
        }catch (Exception ex){
            ex.printStackTrace();
            extractor.setDataSource(new FileInputStream(inputFile).getFD());
        }

        int numTracks = extractor.getTrackCount();
        for (i = 0; i < numTracks; i++) {
            format = extractor.getTrackFormat(i);
            if (format.getString(MediaFormat.KEY_MIME).startsWith("audio/")) {
                extractor.selectTrack(i);
                break;
            }
        }
        if (i == numTracks) {
            throw new Exception("No audio track found in " + inputFile);
        }

        Audio audio = new Audio();
        audio.name = inputFile.getName();
        audio.path = inputFile.getAbsolutePath();
        audio.sampleRate = format.containsKey(MediaFormat.KEY_SAMPLE_RATE) ? format.getInteger(MediaFormat.KEY_SAMPLE_RATE) : 44100;
        audio.channel = format.containsKey(MediaFormat.KEY_CHANNEL_COUNT) ? format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) : 1;
        audio.timeMillis = (int) ((format.getLong(MediaFormat.KEY_DURATION) / 1000.f));

        //根据pcmEncoding编码格式,得到采样精度,MediaFormat.KEY_PCM_ENCODING这个值不一定有
        int pcmEncoding = format.containsKey(MediaFormat.KEY_PCM_ENCODING) ? format.getInteger(MediaFormat.KEY_PCM_ENCODING) : AudioFormat.ENCODING_PCM_16BIT;
        switch (pcmEncoding){
            case AudioFormat.ENCODING_PCM_FLOAT:
                audio.bitNum = 32;
                break;
            case AudioFormat.ENCODING_PCM_8BIT:
                audio.bitNum = 8;
                break;
            case AudioFormat.ENCODING_PCM_16BIT:
            default:
                audio.bitNum = 16;
                break;
        }

        extractor.release();

        return audio;
    }

}

这里要注意,通过MediaFormat获取音频信息的时候,获取采样位数是要先查找MediaFormat.KEY_PCM_ENCODING这个key对应的值,如果是AudioFormat.ENCODING_PCM_8BIT,则是8位采样精度,如果是AudioFormat.ENCODING_PCM_16BIT,则是16位采样精度,如果是AudioFormat.ENCODING_PCM_FLOAT(android 5.0 版本新增的类型),则是32位采样精度。当然可能MediaFormat中没有包含MediaFormat.KEY_PCM_ENCODING这个key信息,这时就使用默认的AudioFormat.ENCODING_PCM_16BIT,即默认的16位采样精度(也可以说2个字节作为一个采样点编码)。

接下来就是真正的裁剪操作了。根据audio中的音频信息得到将要写入的wav文件头信息字节数据,创建随机读写文件,写入文件头数据,然后源随机读写文件移动到指定的开始时间开始读取,目标随机读写文件将读取的数据写入,知道源随机文件读到指定的结束时间停止,这样就完成了音频文件的裁剪操作。

public class AudioEditUtil {
  /**
   * 裁剪音频
   * @param audio 音频信息
   * @param cutStartTime 裁剪开始时间
   * @param cutEndTime 裁剪结束时间
   */
  public static void cutAudio(Audio audio, float cutStartTime, float cutEndTime){
    if(cutStartTime == 0 && cutEndTime == audio.getTimeMillis() / 1000f){
      return;
    }
    if(cutStartTime >= cutEndTime){
      return;
    }

    String srcWavePath = audio.getPath();
    int sampleRate = audio.getSampleRate();
    int channels = audio.getChannel();
    int bitNum = audio.getBitNum();
    RandomAccessFile srcFis = null;
    RandomAccessFile newFos = null;
    String tempOutPath = srcWavePath + ".temp";
    try {

      //创建输入流
      srcFis = new RandomAccessFile(srcWavePath, "rw");
      newFos = new RandomAccessFile(tempOutPath, "rw");

      //源文件开始读取位置,结束读取文件,读取数据的大小
      final int cutStartPos = getPositionFromWave(cutStartTime, sampleRate, channels, bitNum);
      final int cutEndPos = getPositionFromWave(cutEndTime, sampleRate, channels, bitNum);
      final int contentSize = cutEndPos - cutStartPos;

      //复制wav head 字节数据
      byte[] headerData = AudioEncodeUtil.getWaveHeader(contentSize, sampleRate, channels, bitNum);
      copyHeadData(headerData, newFos);

      //移动到文件开始读取处
      srcFis.seek(WAVE_HEAD_SIZE + cutStartPos);

      //复制裁剪的音频数据
      copyData(srcFis, newFos, contentSize);

    } catch (Exception e) {
      e.printStackTrace();

      return;

    }finally {
      //关闭输入流
      if(srcFis != null){
        try {
          srcFis.close();
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
      if(newFos != null){
        try {
          newFos.close();
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
    }

    // 删除源文件,
    new File(srcWavePath).delete();
    //重命名为源文件
    FileUtils.renameFile(new File(tempOutPath), audio.getPath());
  }
}

计算裁剪时间点对应文件中数据的位置

需要注意的是根据时间计算在文件中的位置,它是这么实现的:

  /**
   * 获取wave文件某个时间对应的数据位置
   * @param time 时间
   * @param sampleRate 采样率
   * @param channels 声道数
   * @param bitNum 采样位数
   * @return
   */
  private static int getPositionFromWave(float time, int sampleRate, int channels, int bitNum) {
    int byteNum = bitNum / 8;
    int position = (int) (time * sampleRate * channels * byteNum);

    //这里要特别注意,要取整(byteNum * channels)的倍数
    position = position / (byteNum * channels) * (byteNum * channels);

    return position;
  }

这里要特别注意,因为time是个float的数,所以计算后的position取整它并不一定是(byteNum * channels)的倍数,而position的位置必须要是(byteNum * channels)的倍数,否则后面的音频数据就全部乱了,那么在播放时就是撒撒撒撒的噪音,而不是原来的声音了。原因是音频数据是按照一个个采样点来计算的,一个采样点的大小就是(byteNum * channels),所以要取(byteNum * channels)的整数倍。

写入wav文件头信息

接着看看往新文件写入wav文件头是怎么实现的,这个在上一篇中也是有讲过的,不过还是列出来吧:

  /**
   * 获取Wav header 字节数据
   * @param totalAudioLen 整个音频PCM数据大小
   * @param sampleRate 采样率
   * @param channels 声道数
   * @param bitNum 采样位数
   * @throws IOException
   */
  public static byte[] getWaveHeader(long totalAudioLen, int sampleRate, int channels, int bitNum) throws IOException {

    //总大小,由于不包括RIFF和WAV,所以是44 - 8 = 36,在加上PCM文件大小
    long totalDataLen = totalAudioLen + 36;
    //采样字节byte率
    long byteRate = sampleRate * channels * bitNum / 8;

    byte[] header = new byte[44];
    header[0] = 'R'; // RIFF
    header[1] = 'I';
    header[2] = 'F';
    header[3] = 'F';
    header[4] = (byte) (totalDataLen & 0xff);//数据大小
    header[5] = (byte) ((totalDataLen >> 8) & 0xff);
    header[6] = (byte) ((totalDataLen >> 16) & 0xff);
    header[7] = (byte) ((totalDataLen >> 24) & 0xff);
    header[8] = 'W';//WAVE
    header[9] = 'A';
    header[10] = 'V';
    header[11] = 'E';
    //FMT Chunk
    header[12] = 'f'; // 'fmt '
    header[13] = 'm';
    header[14] = 't';
    header[15] = ' ';//过渡字节
    //数据大小
    header[16] = 16; // 4 bytes: size of 'fmt ' chunk
    header[17] = 0;
    header[18] = 0;
    header[19] = 0;
    //编码方式 10H为PCM编码格式
    header[20] = 1; // format = 1
    header[21] = 0;
    //通道数
    header[22] = (byte) channels;
    header[23] = 0;
    //采样率,每个通道的播放速度
    header[24] = (byte) (sampleRate & 0xff);
    header[25] = (byte) ((sampleRate >> 8) & 0xff);
    header[26] = (byte) ((sampleRate >> 16) & 0xff);
    header[27] = (byte) ((sampleRate >> 24) & 0xff);
    //音频数据传送速率,采样率*通道数*采样深度/8
    header[28] = (byte) (byteRate & 0xff);
    header[29] = (byte) ((byteRate >> 8) & 0xff);
    header[30] = (byte) ((byteRate >> 16) & 0xff);
    header[31] = (byte) ((byteRate >> 24) & 0xff);
    // 确定系统一次要处理多少个这样字节的数据,确定缓冲区,通道数*采样位数
    header[32] = (byte) (channels * 16 / 8);
    header[33] = 0;
    //每个样本的数据位数
    header[34] = 16;
    header[35] = 0;
    //Data chunk
    header[36] = 'd';//data
    header[37] = 'a';
    header[38] = 't';
    header[39] = 'a';
    header[40] = (byte) (totalAudioLen & 0xff);
    header[41] = (byte) ((totalAudioLen >> 8) & 0xff);
    header[42] = (byte) ((totalAudioLen >> 16) & 0xff);
    header[43] = (byte) ((totalAudioLen >> 24) & 0xff);

    return header;
  }

这里比上一篇中精简了一些,只要传入音频数据大小,采样率,声道数,采样位数这四个参数,就可以得到wav文件头信息了,然后再将它写入到wav文件开始处。

/**
   * 复制wav header 数据
   *
   * @param headerData wav header 数据
   * @param fos 目标输出流
   */
  private static void copyHeadData(byte[] headerData, RandomAccessFile fos) {
    try {
      fos.seek(0);
      fos.write(headerData);
    } catch (Exception ex) {
      ex.printStackTrace();
    }
  }

写入wav文件裁剪部分的音频数据

接下来就是将裁剪部分的音频数据写入到文件中了。这里要先移动源文件的读取位置到裁剪起始处,即

//移动到文件开始读取处
srcFis.seek(WAVE_HEAD_SIZE + cutStartPos);

这样就可以从源文件读取裁剪处的数据了

  /**
   * 复制数据
   *
   * @param fis 源输入流
   * @param fos 目标输出流
   * @param cooySize 复制大小
   */
  private static void copyData(RandomAccessFile fis, RandomAccessFile fos, final int cooySize) {

    byte[] buffer = new byte[2048];
    int length;
    int totalReadLength = 0;

    try {

      while ((length = fis.read(buffer)) != -1) {

        fos.write(buffer, 0, length);

        totalReadLength += length;

        int remainSize = cooySize - totalReadLength;
        if (remainSize <= 0) {
          //读取指定位置完成
          break;
        } else if (remainSize < buffer.length) {
          //离指定位置的大小小于buffer的大小,换remainSize的buffer
          buffer = new byte[remainSize];
        }
      }
    } catch (Exception ex) {
      ex.printStackTrace();
    }
  }

上面代码目的就是读取startPos开始,到startPos+copySize之间的数据。

总结

到这里的话,想必对裁剪的整体流程有一定的了解了,总结起来的话,首先是对音频解码,得到解码后的wav文件或者pcm文件,然后取得音频的文件头信息(包括采样率,声道数,采样位数,时间等),然后计算得到裁剪时间对应到文件中数据位置,以及裁剪的数据大小,然后计算得到裁剪后的wav文件头信息,并写入新文件中,最后将源文件裁剪部分的数据写入到新文件中,最终得到裁剪后的wav文件了。

读者可能会有疑问,我想要裁剪的是mp3文件,这里只是得到裁剪后的wav文件,那怎么得到裁剪后的mp3文件呢?这个就需要对该wav文件进行mp3编码压缩了,具体实现可以参考我的Github项目 AudioEdit

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容