1. 程式人生 > >android音訊編輯之音訊裁剪

android音訊編輯之音訊裁剪

轉載請標明出處:
http://blog.csdn.net/hesong1120/article/details/79077013
本文出自:hesong的專欄

前言

本篇開始講解音訊編輯的具體操作,從相對簡單的音訊裁剪開始。要進行音訊裁剪,我的方案是開啟一個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

我的部落格
GitHub
我的簡書
群號:194118438,歡迎入群
微信公眾號 hesong ,微信掃一掃下方二維碼即可關注: