用 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 所能作的事情还有不少不少,期待你们一块儿去深挖。

参考

本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们
相关文章
相关标签/搜索