WebAudio音频编辑,复制剪切粘贴操作

1,851 阅读6分钟

前言

我以前从来没接触过音频操作,甚至压根不知道这个api,然后新公司里面要做音频编辑,声纹之类啥的,用wavesurfer.js可视化,编辑音频复制粘贴剪切删除等等,这几天一直在怼这俩个,网上百度资料感觉也好少,我现在也只是会了个皮毛,简单记录下,如果对webaudio完全没了解过的,建议先看看前面api的介绍,如果以前有弄过或者看过一点点的可以直接跳到后面的例子
使用wavesurfer.js配合webaudio api实现音频波形可视化,简单编辑操作(复制、粘贴、剪切、删除、插入、撤销、重做),可以参考这篇,我有点菜,有什么写的不好的地方请大家多多指点下谢谢!

AudioContext

AudioContext接口表示由链接在一起的音频模块构建的音频处理图,每个模块由一个AudioNode表示。音频上下文控制它包含的节点的创建和音频处理或解码的执行。

AudioContext()创建并返回一个新的 AudioContext 对象。参数可选

let audioCtx = new AudioContext(options)

音频节点分三大类:

  • Source Node:能产生音频的节点,只有输出,没有输入。
  • Process Node:对音频进行处理的节点,有输入(可能有多个)和输出。
  • Destination Node:通常为音频播放设备,如扬声器。

AudioDestinationNode

AudioDestinationNode接口代表给定上下文中音频图的最终目的地——通常是设备的扬声器。

AudioContext.createBuffer()

createBuffer()用于新建一个空白的 AudioBuffer 对象 ,以便用于填充数据,通过AudioBufferSourceNode 播放 。

参数依次为:声频声道数,样本帧数,采样率。返回值 AudioBuffer

let arrayBuffer = audioCtx.createBuffer(numOfChannels,length,sampleRate);

AudioContext.createBufferSource()

createBufferSource() 方法用于创建一个新的AudioBufferSourceNode接口,该接口可以通过AudioBuffer 对象来播放音频数据。

创建一个 2 秒的缓冲器,并用白噪音填充它,然后通过AudioBufferSourceNode来播放它

// 创建音频上下文
let audioCtx = new AudioContext();
// 立体声
let channels = 2;
// 创建一个 采样率与音频环境 (AudioContext) 相同的 时长 2 秒的 音频片段。
let frameCount = audioCtx.sampleRate * 2.0;
// 创建空白的AudioBuffer对象
let myArrayBuffer = audioCtx.createBuffer(channels,frameCount,audioCtx.sampleRate);

function fn(){
    // 使用白噪声填充;
    // 就是 -1.0 到 1.0 之间的随机数
    for (let channel = 0; channel < channels; channel++) {
        // 这允许我们读取实际音频片段 (AudioBuffer) 中包含的数据
        let nowBuffering = myArrayBuffer.getChannelData(channel);
        for (let i = 0; i < frameCount; i++) {
            // Math.random() is in [0; 1.0]
            // audio needs to be in [-1.0; 1.0]
            nowBuffering[i] = Math.random() * 2 - 1;
        }
    }
    // 获取一个 音频片段源节点 (AudioBufferSourceNode)。
    // 当我们想播放音频片段时,我们会用到这个源节点。
    let source = audioCtx.createBufferSource();
    // 把刚才生成的片段加入到 音频片段源节点 (AudioBufferSourceNode)。
    source.buffer = myArrayBuffer;
    // 把 音频片段源节点 (AudioBufferSourceNode) 连接到
    // 音频环境 (AudioContext) 的终节点,这样我们就能听到声音了。
    source.connect(audioCtx.destination);
    // 开始播放声源
    source.start();
}

fn();

AudioContext.createMediaElementSource()

createMediaElementSource() 方法用于创建一个新的 MediaElementAudioSourceNode 对象,输入audio/viedo 标签元素,对应的音频即可被播放或者修改。 网络加载的音频文件(比如audio标签) ,需要将其转换成音频源节点,才能连接到路由图中。

结合AudioDestinationNode的例子,使用到了GainNode 增益节点(表示音量的变化 )。

let myAudio = document.querySelector('#audio');
let sourceNode = audioCtx.createMediaElementSource(myAudio); // 从audio标签创建一个音频源节点
let gainNode = audioCtx.createGain(); // 创建一个增益节点
gainNode.gain.value = 0; // 将增益设置为0(相当于音量设置为0)
$audio.addEventListener('play', () => {
  // 在1秒的时间内指数增长到1,实现一个播放渐入效果
  gainNode.gain.exponentialRampToValueAtTime(1, 1); 
});
sourceNode.connect(gainNode); // 音频源节点连接到增益节点
gainNode.connect(audioCtx.destination); // 增益节点连接到destination进行播放

AudioContext.decodeAudioData()

decodeAudioData()方法可用于异步解码音频文件中的 ArrayBuffer

参数依次为:请求返回的音频流audioData,解码成功后的回调函数(decodedData)。

// 旧语法
audioCtx.decodeAudioData(audioData, function(decodedData) {});
// 新语法
audioCtx.decodeAudioData(audioData).then(function(decodedData) {});

AudioBuffer

AudioBuffer接口表示存在内存里的一段短小的音频资源,利用AudioContext.decodeAudioData()方法从一个音频文件构建,或者利用 AudioContext.createBuffer()从原始数据构建。把音频放入 AudioBuffer 后,可以传入到一个 AudioBufferSourceNode进行播放。

属性

AudioBuffer.sampleRate 只读

存储在缓存区的 PCM 数据的采样率:浮点数,单位为 sample/s。

AudioBuffer.length 只读

返回存储在缓存区的 PCM 数据的采样帧数:整形。

AudioBuffer.duration 只读

返回存储在缓存区的 PCM 数据的时长:双精度型(单位为秒)。

AudioBuffer.numberOfChannels 只读

返回存储在缓存区的 PCM 数据的声道数:整形。

方法

AudioBuffer.getChannelData()

getChannelData() 方法返回一Float32Array ,获取指定声道数据。

参数channel 属性是要获取特定声道数据的索引。0 代表第一个声道。 如果索引值大于或等于AudioBuffer.numberOfChannels, 会抛出一个索引大小异常(INDEX_SIZE_ERR )的错误。

let myArrayBuffer = audioCtx.createBuffer(声频声道数,样本帧数,采样率);
let nowBuffering = myArrayBuffer.getChannelData(channel);

AudioBuffer.copyFromChannel()

copyFromChannel() 方法将样本从 AudioBuffer 的指定声道复制到目标数组中

参数依次为:目标数组,声道索引,被复制的声道数据(Float32Array)的起始索引(从channel的哪个位置开始复制到目标数组)

myArrayBuffer.copyFromChannel(destination,channelNumber,startInChannel);

AudioBuffer.copyToChannel()

copyToChannel() 方法将样本从源数组AudioBuffer 复制到指定声道。

参数依次为:目标数组,声道索引,被复制的声道数据(Float32Array)的起始索引(从源数组要复制到AudioBuffer的哪个位置)

AudioBuffer.copyToChannel(source, channelNumber, startInChannel)

例子

使用以上几个方法实现复制,这里我们拿input文件选择框选中的文件结合FileReader举例

复制

let inp = document.getElementById('input'); // 获取input节点
inp.onchange = e => {
  let file = e.target.files[0]; // 获取选中的音频文件
  let reader = new FileReader();// FileReader 异步读取存储在用户计算机上的文件
  reader.onload = async event => { // 读取完成
    let audioCtx = new (window.AudioContext || window.webkitAudioContext)(); // 创建音频上下文
    let arrayBuffer = event.target.result;// result是被读取文件的 ArrayBuffer 数据对象
    // audioBuffer 就是要被我们复制的源数据了
    let audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);// 解码 arrayBuffer 为 audioBuffer
    let channels = audioBuffer.numberOfChannels; // 源数据的声道数
    let rate = audioBuffer.sampleRate; // 源数据的采样率
    let start = 5 ; // 从第几秒开始复制
    let end = 10 ; // 复制到第几秒结束
    // 这里的 >> 0 在这里 你们就理解成和向下取整差不多意思就可以了(具体我也不太懂)
    let startOffset = (start * rate) >> 0; // 起始位置 = 开始时间 * 采样率
	let endOffset = (end * rate) >> 0; // 结束位置 = 结束时间 * 采样率
	let frameCount = endOffset - startOffset; // 音频帧数/长度 = 结束位置 - 起始位置
    // 创建同样采用率、同样声道数量,指定长度的空的AudioBuffer
	let newAudioBuffer = audioCtx.createBuffer(channels, frameCount, rate);
    // 以上是前置条件,下面是执行复制的代码
    ...
    ...
    ...
  };
  reader.readAsArrayBuffer(file); // 按字节读取文件内容,并转换ArrayBuffer对象
}

方式一 使用getChannelData 通过数组截取实现

// 遍历声道
for (let i = 0; i < channels; i++) {
  // 截取 audioBuffer 指定索引段的数据 赋值给 newAudioBuffer
  newAudioBuffer.getChannelData(i).set(audioBuffer.getChannelData(i).slice(startOffset, endOffset));
}

方式二 通过copyFromChannel和copyToChannel实现复制

// 创建临时的Array存放要复制的buffer数据
let tempArray = new Float32Array(frameCount);
// 遍历声道
for (let i = 0; i < channels; i++) {
  // 把数据从 audioBuffer 复制到 tempArray,startOffset 是你要从哪里开始截取
  audioBuffer.copyFromChannel(tempArray, i, startOffset);
  // 把数据从 tempArray 取出来复制到 newAudioBuffer,如果你要做的是插入,这里的0替换成你要插入的数组的索引
  newAudioBuffer.copyToChannel(tempArray, i, 0);
}

最后可以播放音频测试下复制效果

// 创建AudioBufferSourceNode对象
let source = audioCtx.createBufferSource();
source.buffer = newAudioBuffer;
// 连接音频播放设备
source.connect(audioCtx.destination);
// 资源开始播放
source.start();

粘贴

粘贴的数据会覆盖原来位置那部分的数据,假设copyData是我们已经复制的数据,audioBuffer是源音频数据,currentTime是我们要粘贴到音频的指定位置(S)

let copyData = newAudioBuffer; // 复制的数据
let currentTime = 10; // 我们要把复制的数据粘贴到源音频第10秒的位置
let channels = audioBuffer.numberOfChannels; // 声道数
let rate = audioBuffer.sampleRate; // 采样率
let duration = audioBuffer.duration; // 音频时长
let copyToStart = (currentTime * rate) >> 0; // 要复制到的目标的开始位置
let copyToEnd = copyToStart + copyData.length; // 要复制到的目标的结束位置 = 开始位置 + 复制的音频长度
// 音频帧数差值 =  ( 复制的音频长度 - ( 源音频长度 - 当前光标所在位置为止长度 ) ) * 采样率
let diffLength = copyData.length - (audioBuffer.length - copyToStart);
// 音频时长如果大于 当前光标时间点 + 复制的音频时长,音频帧数则同等于源音频帧数,否则等于 源音频帧数 + 音频帧数差值
let frameCount = duration > currentTime + copyDuration ? audioBuffer.length : audioBuffer.length + diffLength;
// 创建同样同样声道数、采样率,指定长度的空的AudioBuffer
let newBuffer = ac.createBuffer(channels, frameCount, rate);
for (let i = 0; i < channels; i++) {
  let before = audioBuffer.getChannelData(i).slice(0, copyToStart); // 复制的音频要插入的时间点的前面部分
  let add = copyData.getChannelData(i).slice(0, copyData.length); // 复制的音频
  let after = audioBuffer.getChannelData(i).slice(copyToEnd); // 复制的音频要插入的时间点的后面部分
  // 拼接数据 这个newBuffer就是粘贴完成后的新数据
  newBuffer.getChannelData(i).set([...before, ...add, ...after]);
}

插入

插入的数据不会覆盖原来位置那部分的数据,同上 audioBuffer 为源音频数据

let copyData = newAudioBuffer; // 复制的数据
let currentTime = 10; // 我们要把复制的数据粘贴到源音频第10秒的位置
let channels = audioBuffer.numberOfChannels; // 声道数
let rate = audioBuffer.sampleRate; // 采样率
let copyToStart = (currentTime * rate) >> 0; // 要复制到的目标的开始位置
// 音频帧数等于 源音频长度 + 复制音频长度
let frameCount = audioBuffer.length + copyData.length;
// 创建同样采样率、同样声道数量,指定长度的空的AudioBuffer
let newBuffer = ac.createBuffer(channels, frameCount, rate);
for (let i = 0; i < channels; i++) {
  let before = audioBuffer.getChannelData(i).slice(0, copyToStart); // 复制的音频要插入的时间点的前面部分
  let add = copyData.getChannelData(i).slice(0, copyData.length); // 复制的音频
  let after = audioBuffer.getChannelData(i).slice(copyToStart); // 复制的音频要插入的时间点的后面部分
  // 拼接数据 这个newBuffer就是插入完成后的新数据
  newBuffer.getChannelData(i).set([...before, ...add, ...after]);
}

剪切

剪切就是复制数据,然后把复制的部分截掉,先执行复制操作,然后获取复制数据的长度,同上 audioBuffer 为源音频数据,copyData是已经复制的数据

let copyLength = copyData.copyLength;
let start = 5 ; // 从第几秒开始剪切
let end = 10 ; // 剪切到第几秒
let cutStart = (start * rate) >> 0; // 要剪切的目标的开始位置
let cutEnd = (end * rate) >> 0; // 要剪切的目标的结束位置
// 音频帧数 = 源音频长度 - 复制的音频数据长度
let frameCount = audioBuffer.length - copyLength;
// 创建同样采样率、同样声道数量,指定长度的空的AudioBuffer
let newBuffer = ac.createBuffer(channels, frameCount, rate);
for (let i = 0; i < channels; i++) {
  let before = audioBuffer.getChannelData(i).slice(0, cutStart); // 复制数据 前面部分
  let after = audioBuffer.getChannelData(i).slice(cutEnd); // 复制数据 后面部分
  // 截掉复制的数据后,拼接数据前后部分
  newBuffer.getChannelData(i).set([...before, ...after]);
}

删除

删除和剪切操作一样,区别只是不复制数据,不做演示,参考剪切。

撤销和重做

撤销和重做其实就是用一个数组arr记录(push)每次操作后的newBuffer,用index记录下标,撤销就是index--,重做就是index++,然后获取数组指定下标的数据,再判断一下边界就好了,index>0 才执行撤销,index<数组长度-1 才执行重做, 如果撤销之后又执行了其他操作,直接截取index前面的部分 arr = arr.slice(0, index+ 1) ,数组后面数据被新操作覆盖了。