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

前言

最近在做一個項目,需要對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,前端自己也能處理音頻,豈不美哉?

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章