用 Web 實現一個簡易的音頻編輯器

banner

前言

市面上,音頻編輯軟件很是多,好比 cubase、sonar 等等。雖然它們功能強大,可是在 Web 上的應用卻顯得愛莫能助。由於 Web 應用的大多數資源都是存放在網絡服務器中的,用 cubase 這些軟件,首先要把音頻文件下載下來,修改完以後再上傳到服務器,最後還要做更新操做,操做效率極其低下。若是能讓音頻直接在 Web 端進行編輯並更新到服務器,則能夠大大提升運營人員的工做效率。下面就爲你們介紹一下如何運用 Web 技術實現高性能的音頻編輯器。前端

本篇文章總共分爲 3 章:c++

  • 第 1 章:聲音相關的理論知識
  • 第 2 章:音頻編輯器的實現方法
  • 第 3 章:音頻編輯器的性能優化

第 1 章 - 聲音相關的理論知識

理論是實踐的依據和根基,瞭解理論能夠更好的幫助咱們實踐,解決實踐中遇到的問題。git

1.1 什麼是聲音

物體振動時激勵着它周圍的空氣質點振動,因爲空氣具備可壓縮性,在質點的相互做用下,振動物體四周的空氣就交替地產生壓縮與膨脹,而且逐漸向外傳播,從而造成聲波。聲波經過介質(空氣、固體、液體)傳入到人耳中,帶動聽小骨振動,通過一系列的神經信號傳遞後,被人所感知,造成聲音。咱們之因此能聽到鋼琴、二胡、大喇叭等樂器發出的聲音,就是由於樂器裏的某些部件經過振動產生聲波,通過空氣傳播到咱們人耳中。github

1.2 聲音的因素

爲何人們的聲音都不同,爲何有些人的聲音很好聽,有些人的聲音卻很猥瑣呢?這節介紹一下聲音的 3 大因素:頻率、振幅和音色,瞭解這些因素以後你們就知道緣由了。算法

1.2.1 頻率

聲音既然是聲波,就會有振幅和頻率。頻率越大音高越高,聲音就會越尖銳,好比女士的聲音頻率就廣泛比男士的大,因此她們的聲音會比較尖銳。人的耳朵一般只能聽到 20Hz 到 20kHz 頻率範圍內的聲波。canvas

1.2.2 振幅

聲波在空氣中傳播時,途經的空氣會交替壓縮和膨脹,從而引發大氣壓強變化。振幅越大,大氣壓強變化越大,人耳聽到的聲波就會越響。人耳可聽的聲壓(聲壓:聲波引發的大氣壓強變化值)範圍爲 (2 * 10 ^ - 5)Pa~20Pa,對應的分貝數爲 0~120dB。它們之間的換算公式爲 20 * log( X / (2 * 10 ^ -5) ),其中 X 爲聲壓。相比較用大氣壓強來表示聲音振幅強度,用分貝表示會更加直觀。咱們平時在形容物體的聲音強度時,通常也都會用分貝,而不會說這個大喇叭發出了多少多少帕斯卡的聲壓(但聽起來好像很厲害得樣子)。數組

1.2.3 音色

頻率和振幅都不是決定一我的聲音是猥瑣仍是動聽的主要因素,決定聲音是否好聽的主要因素爲音色,音色是由聲波中的諧波決定的。天然界中物體振動產生的聲波,都不是單一頻率單一振幅的波(如正弦波),而是能夠分解爲 1 個基波加上無數個諧波。基波和諧波都是正弦波,其中諧波的頻率是基波的整數倍,振幅比基波小,相位也各不相同。如鋼琴中央 dou,它的基波頻率爲 261,其餘無數個諧波頻率爲 261 的整數倍。聲音好聽的人,在發聲時,聲帶產生的諧波比較「好聽」,而聲音猥瑣的人,聲帶產生的諧波比較「猥瑣」。瀏覽器

1.3 聲音的錄製、編輯、回放

無論是歐美的鋼琴、小提琴,仍是中國的嗩吶、二胡、大喇叭,咱們不可能想聽的時候都叫演奏家們去爲咱們現場演奏,若是能將這些好聽聲音存儲起來,咱們就能夠在想聽的時候進行回放了。傳統的聲音錄製方法是經過話筒等設備把聲音的振動轉化成模擬的電流,通過放大和處理,而後記錄到磁帶或傳至音箱等設備發聲。這種方法失真較大, 且消除噪音困難, 也不易被編輯和修改,數字化技術能夠幫咱們解決模擬電流帶來的問題。這節咱們就來介紹下數字化技術是如何作到的。緩存

1.3.1 錄製

聲音是一段連續無規則的聲波,由無數個正弦波組成。數字化錄製過程就是採集這段聲波中離散的點的幅值,量化和編碼後存儲在計算機中。整個過程的基本原理爲:聲音通過麥克風後根據振幅的不一樣造成一段連續的電壓變化信號,這時用脈衝信號採集到離散的電壓變化,最後將這些採集到的結果進行量化和編碼後存儲到計算機中。採樣脈衝頻率通常爲 44.1kHz,這是由於人耳通常只能聽到聲波中 20-20kHz 頻率正弦波部分,根據採樣定律,要從採樣值序列徹底恢復原始的波形,採樣頻率必須大於或等於原始信號最高頻率的 2 倍。所以,若是要保留原始聲波中 20kHz 之內的全部正弦波,採樣頻率必定要大於等於 40kHz。
 性能優化

1.3.2 編輯

聲音數字化後就能夠很是方便的對聲音進行編輯,如展現聲音波形圖,截取音頻,添加靜音效果、漸入淡出效果,經過離散型傅里葉變換查看聲音頻譜圖(各個諧波的分佈圖)或者進行濾波操做(濾除不想要的諧波部分),這些看似複雜的操做卻只須要對量化後的數據簡單進行的計算便可實現。

1.3.3 回放

回放過程就是錄製過程的逆過程,將錄製或者編輯過的音頻數據進行解碼,去量化還原成離散的電壓信號送入大喇叭中。大喇叭如何將電壓信號還原成具體的聲波振幅,這個沒有深刻學習,只能到這了。

第2章-音頻編輯器的實現方法

經過第 1 章的理論知識,咱們知道了什麼是聲音以及聲音的錄製和回放,其中錄製保存下來的聲音數據就叫音頻,經過編輯音頻數據就能獲得咱們想要的回放聲音效果。這章咱們就開始介紹如何用瀏覽器實現音頻編輯工具。瀏覽器提供了 AudioContext 對象用於處理音頻數據,本章首先會介紹下 AudioContext 的基本使用方法,而後介紹如何用 svg 繪製音頻波形以及如何對音頻數據進行編輯。

2.1 AudioContext 介紹

AudioContext 對音頻數據處理過程是一個流式處理過程,從音頻數據獲取、數據加工、音頻數據播放,一步一步流式進行。AudioContext 對象則提供流式加工所須要的方法和屬性,如 context.createBufferSource 方法返回一個音頻數據緩存節點用於存儲音頻數據,這是整個流式的起點;context.destination 屬性爲整個流式的終點,用於播放音頻。每一個方法都會返回一個 AudioNode 節點對象,經過 AudioNode.connect 方法將全部 AudioNode 節點鏈接起來。

下面經過一個簡單的例子來解鎖 AudioContext:

  • 爲了方便起見,咱們不使用服務器上的音頻文件,而使用 FileReader 讀取本地音頻文件
  • 使用 AudioContext 的 decodeAudioData 方法對讀到的音頻數據進行解碼
  • 使用 AudioContext 的 createBufferSource 方法建立音頻源節點,並將解碼結果賦值給它
  • 使用 AudioContext 的 connect 方法鏈接音頻源節點到播放終端節點 - AudioContext 的 destination 屬性
  • 使用 AudioContext 的 start 方法開始播放
// 讀取音頻文件.mp3 .flac .wav等等
    const reader = new FileReader();
    // file 爲讀取到的文件,能夠經過<input type="file" />實現
    reader.readAsArrayBuffer(file);
    reader.onload = evt => {
        // 編碼過的音頻數據
        const encodedBuffer = evt.currentTarget.result;
        // 下面開始處理讀取到的音頻數據
        // 建立環境對象
        const context = new AudioContext();
        // 解碼
        context.decodeAudioData(encodedBuffer, decodedBuffer => {
            // 建立數據緩存節點
            const dataSource = context.createBufferSource();
            // 加載緩存
            dataSource.buffer = decodedBuffer;
            // 鏈接播放器節點destination,中間能夠鏈接其餘節點,好比音量調節節點createGain(),
            // 頻率分析節點(用於傅里葉變換)createAnalyser()等等
            dataSource.connect(context.destination);
            // 開始播放
            dataSource.start();
        })
    }

2.1 什麼是音頻波形

音頻編輯器經過音頻波形圖形化音頻數據,使用者只要編輯音頻波形就能獲得對應的音頻數據,固然內部實現是將對波形的操做轉爲對音頻數據的操做。所謂音頻波形,就是時域上,音頻(聲波)振幅隨着時間的變化狀況,即 X 軸爲時間,Y 軸爲振幅。

2.2 繪製波形

咱們知道,音頻的採樣頻率爲 44.1kHz,因此一段 10 分鐘的音頻總共會有 10 60 44100 = 26460000,超過 2500 萬個數據點。
咱們在繪製波形時,即便僅用 1 個像素表明 1 個點的振幅,波形的寬度也將近 2500 萬像素,不只繪製速度慢,並且很是不利於波形分析。
所以,下面介紹一種近似算法來減小繪製的像素點:咱們首先將每秒鐘採集的 44100 個點平均分紅 100 份,至關於 10 毫秒一份,每一份有 441 個點,
算出它們的最大值和最小值。用最大值表明波峯,用最小值表明波谷,而後用線鏈接全部的波峯和波谷。音頻數據在被量化後,值的範圍爲 [-1,1],
因此咱們這裏取到的波峯波谷都是在 [-1,1] 的區間內的。
因爲數值過小,畫出來的波形不美觀,咱們統一將這些值乘以一個係數好比 64,這樣就能很清晰得觀察到波形的變化了。
繪製波形能夠用 canvas,也能夠用 svg,這裏我選擇使用 svg 進行繪製,由於 svg 是矢量圖,能夠簡化波形縮放算法。

代碼實現

  • 爲了方便使用 svg 進行繪製,引入 svg.js,並初始化 svg 對象 draw
  • 咱們的繪製算法是將每秒鐘採集的 44100 個點平均分紅 100 份,每份是10毫秒共441個數據點,用它們的最大值和最小值做爲這個時間點的波峯和波谷。

而後使用svg.js將全部的波峯波谷經過折線 polyline 鏈接起來造成最後的波形圖。因爲音頻數據點通過量化處理,範圍爲[-1,1],爲了讓波形更加美觀,咱們
會把波峯、波谷統一乘上一個增幅係數來加大 polyline 線條的幅度

  • 初始化變量 perSecPx(每秒鐘繪製像素點的個數)爲100,height 波峯波谷的增幅係數爲128
  • 以10毫秒爲單位獲取全部的波峯波谷數據點 peaks,計算方法就是簡單得計算出它們各自的最大值和最小值
  • 初始化波形圖的寬度 svgWidth = 音頻時長(buff.duration) * 每秒鐘繪製像素點的個數(perSecPx)
  • 遍歷 peaks,將全部的波峯波谷乘上係數並經過 polyline(折線)鏈接起來
const SVG = require('svg.js');
// 建立svg對象
const draw = SVG(document.getElementById('draw'));
// 波形svg對象
let polyline;
// 波形寬度
let svgWidth;
// 展現波形函數
// buffer - 解碼後的音頻數據
function displayBuffer(buff) {
    // 每秒繪製100個點,就是將每秒44100個點分紅100份,
    // 每一份算出最大值和最小值來表明每10毫秒內的波峯和波谷
    const perSecPx = 100;
    // 波峯波谷增幅係數
    const height = 128;
    const halfHight = height / 2;
    const absmaxHalf = 1 / halfHight;
    // 獲取全部波峯波谷
    const peaks = getPeaks(buff, perSecPx);
    // 設置svg的寬度
    svgWidth = buff.duration * perSecPx;
    draw.size(svgWidth);
    const points = [];
    for (let i = 0; i < peaks.length; i += 2) {
        const peak1 = peaks[i] || 0;
        const peak2 = peaks[i + 1] || 0;
        // 波峯波谷乘上係數
        const h1 = Math.round(peak1 / absmaxHalf);
        const h2 = Math.round(peak2 / absmaxHalf);
        points.push([i, halfHight - h1]);
        points.push([i, halfHight - h2]);
    }
    // 鏈接全部的波峯波谷
    const  polyline = draw.polyline(points);
    polyline.fill('none').stroke({ width: 1 });
}
// 獲取波峯波谷
function getPeaks(buffer, perSecPx) {
    const { numberOfChannels, sampleRate, length} = buffer;
    // 每一份的點數=44100 / 100 = 441
    const sampleSize = ~~(sampleRate / perSecPx);
    const first = 0;
    const last = ~~(length / sampleSize)
    const peaks = [];
    // 爲方便起見只取左聲道
    const chan = buffer.getChannelData(0);
    for (let i = first; i <= last; i++) {
        const start = i * sampleSize;
        const end = start + sampleSize;
        let min = 0;
        let max = 0;
        for (let j = start; j < end; j ++) {
            const value = chan[j];
            if (value > max) {
                max = value;
            }
            if (value < min) {
                min = value;
            }
        }
    }
    // 波峯
    peaks[2 * i] = max;
    // 波谷
    peaks[2 * i + 1] = min;
    return peaks;
}

2.3 縮放操做

有時候,須要對某些區域進行放大或者對總體波形進行縮小操做。因爲音頻波形是經過 svg 繪製的,縮放算法就會變得很是簡單,只需直接對 svg 進行縮放便可。

代碼實現

  • 利用svg矢量圖特性,咱們只要將鏈接波分波谷的折線寬度乘上係數 scaleX 便可實現縮放功能,scaleX 大於1則放大,scaleX 小於1則縮小。

其實這是一種僞縮放,由於波形的精度始終是10毫秒,只是將折線圖拉開了。

function zoom(scaleX) {
    draw.width(svgWidth * scaleX);
    polyline.width(svgWidth * scaleX);
}

2.4 裁剪操做

這節主要介紹下裁剪操做的實現,其餘的操做也都是相似的對音頻數據做計算。
所謂裁剪,就是從原始音頻中去除不要的部分,如噪音部分,或者截取想要的部分,如副歌部分。要實現對音頻文件進行裁剪,
首先咱們須要對它 有足夠的認識。
解碼後的音頻數據實際上是一個 AudioBuffer對象 ,
它會被賦值給 AudioBufferSourceNode 音頻源節點的 buffer 屬性,並由 AudioBufferSourceNode
將其帶進 AudioContext 的處理流裏,其中 AudioBufferSourceNode 節點能夠經過 AudioContext 的 createBufferSource 方法生成。
看到這裏有點懵的同窗能夠回到 2.1 一節再回顧一下 AudioContext 的基本用法。
AudioBuffer 對象有 sampleRate(採樣速率,通常爲44.1kHz)、numberOfChannels(聲道數)、
duration(時長)、length(數據長度)4 個屬性,還有 1 個比較重要的方法 getChannelData ,返回 1 個 Float32Array 類型的數組。咱們就是經過改變這個 Float32Array 裏的數據來對
音頻進行裁剪或者其餘操做。裁剪的具體步驟:

  • 首先獲取到待處理音頻的通道數和採樣率
  • 根據裁剪的開始時間點、結束時間點、採樣率算出被裁剪的長度:長度 lengthInSamples = (endTime - startTime) * sampleRate,而後經過 AudioContext 的 createBuffer

方法建立一個長度爲 lengthInSamples 的 AudioBuffer cutAudioBuffer 用於存放裁剪下來的音頻數據,再建立一個長度爲原始音頻長度減去 lengthInSamples 的 AudioBuffer newAudioBuffer 用於存放裁剪後的音頻數據

  • 因爲音頻每每是多聲道的,裁剪操做須要對全部聲道都做裁剪,因此咱們遍歷全部聲道,經過 AudioBuffer 的 getChannelData 方法返回各個聲道 Float32Array 類型的音頻數據
  • 經過 Float32Array 的 subarray 方法獲取須要被裁剪的音頻數據,並經過 set 方法將數據設置到 cutAudioBuffer,同時將被裁剪以後的音頻數據 set 到 newAudioBuffer中
  • 返回 newAudioBuffer 和 cutAudioBuffer
function cut(originalAudioBuffer, start, end) {
    const { numberOfChannels, sampleRate } = originalAudioBuffer;
    const lengthInSamples = (end - start) * sampleRate;
    // offlineAudioContext相對AudioContext更加節省資源
    const offlineAudioContext = new OfflineAudioContext(numberOfChannels, numberOfChannels, sampleRate);
    // 存放截取的數據
    const cutAudioBuffer = offlineAudioContext.createBuffer(
        numberOfChannels,
        lengthInSamples,
        sampleRate
    );
    // 存放截取後的數據
    const newAudioBuffer = offlineAudioContext.createBuffer(
        numberOfChannels,
        originalAudioBuffer.length - cutSegment.length,
        originalAudioBuffer.sampleRate
    );
    // 將截取數據和截取後的數據放入對應的緩存中
    for (let channel = 0; channel < numberOfChannels; channel++) {
        const newChannelData = newAudioBuffer.getChannelData(channel);
        const cutChannelData = cutAudioBuffer.getChannelData(channel);
        const originalChannelData = originalAudioBuffer.getChannelData(channel);
        const beforeData = originalChannelData.subarray(0,
            start * sampleRate - 1);
        const midData = originalChannelData.subarray(start * sampleRate,
            end * sampleRate - 1);
        const afterData = originalChannelData.subarray(
            end * sampleRate
        );
        cutChannelData.set(midData);
        if (start > 0) {
            newChannelData.set(beforeData);
            newChannelData.set(afterData, (start * sampleRate));
        } else {
            newChannelData.set(afterData);
        }
    }
    return {
        // 截取後的數據
        newAudioBuffer,
        // 截取部分的數據
        cutSelection: cutAudioBuffer
    };
};

2.5 撤銷和重作操做

每一次操做前,把當前的音頻數據保存起來。撤銷或者重作時,再把對應的音頻數據加載進來。這種方式有不小的性能開銷,在第 3 章 - 性能優化章節中做具體分析。

第 3 章-音頻編輯器的性能優化

3.1 存在的問題

經過第 2 章介紹的近似法用比較少的點來繪製音頻波形,已基本知足波形查看功能。可是仍存在如下 2 個性能問題:

  1. 若是對波形進行縮放分析,好比將波形拉大 10 倍或者更大的時候,即便 svg 繪製的波形能夠自適應不失真放大,但因爲整個波形放大了 10 倍以上,須要繪製的像素點也增長了 10 倍,致使整個縮放過程很是得卡頓。
  2. 撤銷和重作功能此每次操做都須要保存修改後音頻數據。一份音頻數據,通常都在幾 M 到十幾 M 不等,每次操做都保存的話,勢必會撐爆內存。

3.2 性能優化方案

3.2.1 懶加載

縮放波形卡頓的主要緣由就是所須要繪製的像素點太多,所以咱們能夠經過懶加載的形式減小每次繪製波形時所須要繪製的像素點。
具體方案就是,根據當前波形的滾動位置,實時計算出當前視口須要繪製波形範圍。
所以,須要對第 2 章獲取波峯波谷的函數 getPeaks 進行一下改造, 增長 2 個參數:

  • buffer:解碼後的音頻數據 AudioBuffer
  • pxPerSec:每秒鐘音頻數據橫向須要的像素點,這裏爲 100,每 10 毫秒數據對應 1 組波峯波谷
  • start:當前波形視口滾動起始位置 scrollLeft
  • end:當前波形視口滾動結束位置 scrollLeft + viewWidth。
  • 具體計算時,咱們只會取當前視口內對應時間段的音頻的波峯和波谷。
  • 好比 start 等於 10,end 等於 100,根據咱們 1 個像素對應 1 個 10 毫秒數據量波峯波谷的近似算法,就是取第 10 個 10 毫秒到第 100 個 10 毫秒的波峯波谷,即時間段爲 100 毫秒到 1 秒。
function getPeaks(buffer, pxPerSec, start, end) {
    const { numberOfChannels, sampleRate } = buffer;
    const sampleWidth = ~~(sampleRate / pxPerSec);
    const step = 1;
    const peaks = [];
    for (let c = 0; c < numberOfChannels; c++) {
        const chanData = buffer.getChannelData(c);
        for (let i = start, z = 0; i < end; i += step) {
            let max = 0;
            let min = 0;
            for (let j = i * sampleWidth; j < (i + 1) * sampleWidth; j++) {
                const value = chanData[j];
                max = Math.max(value, max);
                min = Math.min(value, min);
            }
            peaks[z * 2] = Math.max(max, peaks[z * 2] || 0);
            peaks[z * 2 + 1] = Math.min(min, peaks[z * 2 + 1] || 0);
            z++;
        }
    }
    return peaks;
}

3.2.2 撤銷操做的優化

其實咱們只須要保存一份原始未加工過的音頻數據,而後在每次編輯前,把當前執行過的指令集所有保存下來,在撤銷或者重作時,再把對應的指令集對原始音頻數據操做一遍。好比:對波形進行 2 次操做:第 1 次操做時裁剪掉 0-1 秒的部分,保存指令集 A 爲裁剪 0-1 秒;第二次操做時,再一次裁剪 2-3 秒的部分,保存指令集 B 爲裁剪 0-1 秒、裁剪 2-3 秒。撤銷第 2 次操做,只要用前一次指令集 A 對原始波形做一次操做便可。經過這種保存指令集的方式,極大下降了內存的消耗。

總結

聲音實質就是聲波在人耳中振動被人腦感知,決定音質的因素包括振幅、頻率和音色(諧波),人耳只能識別 20-20kHz 頻率和 0-120db 振幅的聲音。
音頻數字化處理過程爲:脈衝抽樣,量化,編碼,解碼,加工,回放。
用 canvas 或者 svg 繪製聲音波形時,會隨着繪製的像素點上升,性能急劇降低,經過懶加載按需繪製的方式能夠有效的提升繪製性能。
經過保存指令集的方式進行撤銷和重作操做,能夠有效的節省內存消耗。
Web Audio API 所能作的事情還有不少不少,期待你們一塊兒去深挖。

參考

本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索