實現純前端下的音頻剪輯處理


原文:https://juejin.im/post/5d91c2d85188251662293adb

前言

最近在做一個項目,需要對webRTC錄制的音頻進行處理,包括音頻的裁剪、多音頻合並,甚至要將某個音頻的某一部分替換成另一個音頻。

原本筆者打算將這件工作交給服務端去完成,但考慮,其實無論是前端還是后台,所做的工作是差不多的,而且交給服務端還需要再額外走一個上傳、下載音頻的流程,這不僅增添了服務端的壓力,而且還有網絡流量的開銷,於是萌生出一個想法:為什么音頻處理這件事不能讓前端來做呢?

於是在筆者的半摸索半實踐下,產生出了這篇文章。廢話少說,先上倉庫地址,這是一個開箱即用的前端音頻剪輯sdk(點進去了不如就star一下吧)

ffmpeg

ffmpeg是實現前端音頻處理的非常核心的模塊,當然,不僅是前端,ffmpge作為一個提供了錄制、轉換以及流化音視頻的業界成熟完整解決方案,它也應用在服務端、APP應用等多種場景下。關於ffmpeg的介紹,大家自行google即可,這里不說太多。

由於ffmpeg在處理過程中需要大量的計算,直接放在前端頁面上去運行是不可能的,因為我們需要單獨開個web worker,讓它自己在worker里面運行,而不至於阻塞頁面交互。

可喜的是,萬能的github上已經有開發者提供了ffmpge.js,並且提供worker版本,可以拿來直接使用。

 

於是我們便有了大體的思路:當獲取到音頻文件后,將其解碼后傳送給worker,讓其進行計算處理,並將處理結果以事件的方式返回,這樣我們就可以對音頻為所欲為了:)

 

 

開啟美妙之旅前的必要工作

需要提前聲明的是,由於筆者的項目需求,是僅需對.mp3格式進行處理的,因此下面的代碼示例以及倉庫地址里面所涉及的代碼,也主要是針對mp3,當然,其實不管是哪種格式,思路是類似的。

創建worker

創建worker的方式非常簡單,直接new之,注意的是,由於同源策略的限制,要使worker正常工作,則要與父頁面同源,由於這不是重點,所以略過

function createWorker(workerPath: string) {
  const worker = new Worker(workerPath);
  return worker;
}

postMessage轉promise

仔細看ffmpeg.js文檔的童鞋都會發現,它在處理音頻的不同階段都會發射事件給父頁面,比如stdoutstartdone等等,如果直接為這些事件添加回調函數,在回調函數里去區分、處理一個又一個音頻的結果,是不大好維護的。個人更傾向於將其轉成promise:

function pmToPromise(worker, postInfo) {
  return new Promise((resolve, reject) => {
    // 成功回調
    const successHandler = function(event) {
      switch (event.data.type) {
        case "stdout":
          console.log("worker stdout: ", event.data.data);
          break;

        case "start":
          console.log("worker receive your command and start to work:)");
          break;

        case "done":
          worker.removeEventListener("message", successHandler);
          resolve(event);
          break;

        default:
          break;
      }
    };
    
    // 異常捕獲
    const failHandler = function(error) {
      worker.removeEventListener("error", failHandler);
      reject(error);
    };

    worker.addEventListener("message", successHandler);
    worker.addEventListener("error", failHandler);
    postInfo && worker.postMessage(postInfo);
  });
}

通過這層轉換,我們就可以將一次postMessage請求,轉換成了promise的方式來處理,更易於空間上的拓展

audio、blob與arrayBuffer的互相轉換

ffmpeg-worker所需要的數據格式是arrayBuffer,而一般我們能直接使用的,要么是音頻文件對象blob,或者音頻元素對象audio,甚至有可能僅是一條鏈接url,因此這幾種格式的轉換是非常有必要的:

audio轉arrayBuffer

function audioToBlob(audio) {
  const url = audio.src;
  if (url) {
    return axios({
      url,
      method: 'get',
      responseType: 'arraybuffer',
    }).then(res => res.data);
  } else {
    return Promise.resolve(null);
  }
}

筆者暫時想到的audio轉blob的方式,就是發起一段ajax請求,將請求類型設置為arraybuffer,即可拿到arrayBuffer.

blob轉arrayBuffer

這個也很簡單,只需要借助FileReader將blob內容提取出來即可

function blobToArrayBuffer(blob) {
  return new Promise(resolve => {
    const fileReader = new FileReader();
    fileReader.onload = function() {
      resolve(fileReader.result);
    };
    fileReader.readAsArrayBuffer(blob);
  });
}

arrayBuffer轉blob

利用File創建出一個blob

function audioBufferToBlob(arrayBuffer) {
  const file = new File([arrayBuffer], 'test.mp3', {
    type: 'audio/mp3',
  });
  return file;
}

blob轉audio

blob轉audio是非常簡單的,js提供了一個原生API——URL.createObjectURL,借助它我們可以把blob轉成本地可訪問鏈接進行播放

function blobToAudio(blob) {
  const url = URL.createObjectURL(blob);
  return new Audio(url);
}

接下來我們進入正題。

音頻裁剪——clip

所謂裁剪,即是指將給定的音頻,按給定的起始、結束時間點,提取這部分的內容,形成新的音頻,先上代碼:

class Sdk {
  end = "end";

  // other code...

  /**
   * 將傳入的一段音頻blob,按照指定的時間位置進行裁剪
   * @param originBlob 待處理的音頻
   * @param startSecond 開始裁剪時間點(秒)
   * @param endSecond 結束裁剪時間點(秒)
   */
  clip = async (originBlob, startSecond, endSecond) => {
    const ss = startSecond;
    // 獲取需要裁剪的時長,若不傳endSecond,則默認裁剪到末尾
    const d = isNumber(endSecond) ? endSecond - startSecond : this.end;
    // 將blob轉換成可處理的arrayBuffer
    const originAb = await blobToArrayBuffer(originBlob);
    let resultArrBuf;

    // 獲取發送給ffmpge-worker的指令,並發送給worker,等待其裁剪完成
    if (d === this.end) {
      resultArrBuf = (await pmToPromise(
        this.worker,
        getClipCommand(originAb, ss)
      )).data.data.MEMFS[0].data;
    } else {
      resultArrBuf = (await pmToPromise(
        this.worker,
        getClipCommand(originAb, ss, d)
      )).data.data.MEMFS[0].data;
    }

    // 將worker處理過后的arrayBuffer包裝成blob,並返回
    return audioBufferToBlob(resultArrBuf);
  };
}

我們定義了該接口的三個參數:需要被剪裁的音頻blob,以及裁剪的開始、結束時間點,值得注意的是這里的getClipCommand函數,它負責將傳入的arrayBuffer、時間包裝成ffmpeg-worker約定的數據格式

/**
 * 按ffmpeg文檔要求,將帶裁剪數據轉換成指定格式
 * @param arrayBuffer 待處理的音頻buffer
 * @param st 開始裁剪時間點(秒)
 * @param duration 裁剪時長
 */
function getClipCommand(arrayBuffer, st, duration) {
  return {
    type: "run",
    arguments: `-ss ${st} -i input.mp3 ${
      duration ? `-t ${duration} ` : ""
    }-acodec copy output.mp3`.split(" "),
    MEMFS: [
      {
        data: new Uint8Array(arrayBuffer),
        name: "input.mp3"
      }
    ]
  };
}

多音頻合成——concat

多音頻合成很好理解,即將多個音頻按數組先后順序合並成一個音頻

class Sdk {
  // other code...

  /**
   * 將傳入的一段音頻blob,按照指定的時間位置進行裁剪
   * @param blobs 待處理的音頻blob數組
   */
  concat = async blobs => {
    const arrBufs = [];
  
    for (let i = 0; i < blobs.length; i++) {
      arrBufs.push(await blobToArrayBuffer(blobs[i]));
    }
  
    const result = await pmToPromise(
      this.worker,
      await getCombineCommand(arrBufs),
    );
    return audioBufferToBlob(result.data.data.MEMFS[0].data);
  };
}

上述代碼中,我們是通過for循環來將數組里的blob一個個解碼成arrayBuffer,可能有童鞋會好奇:為什么不直接使用數組自帶的forEach方法去遍歷呢?寫for循環未免麻煩了點。其實是有原因的:我們在循環體里使用了await,是期望這些blob一個個解碼完成后,才執行后面的代碼,for循環是同步執行的,但forEach的每個循環體是分別異步執行的,我們無法通過await的方式等待它們全部執行完成,因此使用forEach並不符合我們的預期。

同樣,getCombineCommand函數的職責與上述getClipCommand類似:

async function getCombineCommand(arrayBuffers) {
  // 將arrayBuffers分別轉成ffmpeg-worker指定的數據格式
  const files = arrayBuffers.map((arrayBuffer, index) => ({
    data: new Uint8Array(arrayBuffer),
    name: `input${index}.mp3`,
  }));
  
  // 創建一個txt文本,用於告訴ffmpeg我們所需進行合並的音頻文件有哪些(類似這些文件的一個映射表)
  const txtContent = [files.map(f => `file '${f.name}'`).join('\n')];
  const txtBlob = new Blob(txtContent, { type: 'text/txt' });
  const fileArrayBuffer = await blobToArrayBuffer(txtBlob);

  // 將txt文件也一並推入到即將發送給ffmpeg-worker的文件列表中
  files.push({
    data: new Uint8Array(fileArrayBuffer),
    name: 'filelist.txt',
  });

  return {
    type: 'run',
    arguments: `-f concat -i filelist.txt -c copy output.mp3`.split(' '),
    MEMFS: files,
  };
}

在上面代碼中,與裁剪操作不同的是,被操作的音頻對象不止一個,而是多個,因此需要創建一個“映射表”去告訴ffmpeg-worker一共需要合並哪些音頻以及它們的合並順序。

音頻裁剪替換——splice

它有點類似clip的升級版,我們從指定的位置刪除音頻A,並在此處插入音頻B:

class Sdk {
  end = "end";
  // other code...

  /**
   * 將一段音頻blob,按指定的位置替換成另一端音頻
   * @param originBlob 待處理的音頻blob
   * @param startSecond 起始時間點(秒)
   * @param endSecond 結束時間點(秒)
   * @param insertBlob 被替換的音頻blob
   */
  splice = async (originBlob, startSecond, endSecond, insertBlob) => {
    const ss = startSecond;
    const es = isNumber(endSecond) ? endSecond : this.end;

    // 若insertBlob不存在,則僅刪除音頻的指定內容
    insertBlob = insertBlob
      ? insertBlob
      : endSecond && !isNumber(endSecond)
      ? endSecond
      : null;

    const originAb = await blobToArrayBuffer(originBlob);
    let leftSideArrBuf, rightSideArrBuf;

    // 將音頻先按指定位置裁剪分割
    if (ss === 0 && es === this.end) {
      // 裁剪全部
      return null;
    } else if (ss === 0) {
      // 從頭開始裁剪
      rightSideArrBuf = (await pmToPromise(
        this.worker,
        getClipCommand(originAb, es)
      )).data.data.MEMFS[0].data;
    } else if (ss !== 0 && es === this.end) {
      // 裁剪至尾部
      leftSideArrBuf = (await pmToPromise(
        this.worker,
        getClipCommand(originAb, 0, ss)
      )).data.data.MEMFS[0].data;
    } else {
      // 局部裁剪
      leftSideArrBuf = (await pmToPromise(
        this.worker,
        getClipCommand(originAb, 0, ss)
      )).data.data.MEMFS[0].data;
      rightSideArrBuf = (await pmToPromise(
        this.worker,
        getClipCommand(originAb, es)
      )).data.data.MEMFS[0].data;
    }

    // 將多個音頻重新合並
    const arrBufs = [];
    leftSideArrBuf && arrBufs.push(leftSideArrBuf);
    insertBlob && arrBufs.push(await blobToArrayBuffer(insertBlob));
    rightSideArrBuf && arrBufs.push(rightSideArrBuf);

    const combindResult = await pmToPromise(
      this.worker,
      await getCombineCommand(arrBufs)
    );

    return audioBufferToBlob(combindResult.data.data.MEMFS[0].data);
  };
}

上述代碼有點類似clipconcat的復合使用。

到這里,就基本實現了我們的需求,僅需借助worker,前端自己也能處理音頻,豈不美哉?

上述這些代碼只是為了更好的說明講解,所以做了些簡化,有興趣的童鞋可直接源碼,歡迎交流、拍磚:)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM