零基础小白跨行做前端一年多,以前从来没接触过音频操作,代码写的不怎么好,你们可以参考下实现方式,敲代码思路就随便看看,我没思路,还是生手,所以不要喷我不要嘲讽我谢谢了
这是使用wavesurfer.js配合webaudio api实现音频波形可视化,简单编辑操作
完全不懂webaudio的可参考前文,下面直接附代码
因为我全文基本每一行都写了注释,所以没有做一步步拆解了,前文拆解步骤,引入的是线上的wavesurfer.js,这俩个文件可以直接复制到本地,网页选择音频文件看效果。
js
这是封装好的方法,可以引入直接使用
/** 记录每次操作完成后的audioBuffer 每次撤销重做都是直接读数据
* @typedef {Object} WavesurferEditParams
* @property {Object} buffer 当前音波图的 audioBuffer,必传
* @property {Number} maxCount=10 记录操作记录,最多可记录多少条,非必传
* @property {AudioContext} ac=AudioContext 音频上下文,非必传
*/
function WavesurferEdit(opt) {
let ac = null; // audioContent
let currentBuffer = null;
let currentRegion = null; // 当前选中的region
let copyData = null; // 复制的音频数据
let copyDuration = null; // 复制的音频时长
let operationRecode = []; // 操作记录
let operationIndex = 0; // 记录当前操作的下标
let maxCount = null; // 记录操作记录,最多可记录多少条
let channels = null; // 声道数
let rate = null; // 采样率
init();
/* 初始化 */
function init() {
currentBuffer = opt.buffer || {};
ac = opt.ac || new (window.AudioContext || window.webkitAudioContext)(); // 获取音频上下文
maxCount = opt.maxCount || 10;
rate = currentBuffer.sampleRate;
channels = currentBuffer.numberOfChannels;
operationRecode.push(currentBuffer); // 操作记录新增当前数据
}
/** 复制
* @param {Object} region=currentBuffer 当前选中的区域,必传
* @param {audioBuffer} buffer 当前音波图的 audioBuffer,非必传
*/
this.copy = function (region, buffer, isCopy = true) {
currentRegion = region; // 当前选中区域
if (!currentRegion) return;
currentBuffer = buffer || currentBuffer; // 源音频
let { start, end } = currentRegion; // 获取当前区域的开始结束时间
let startOffset = (start * rate) >> 0; // 起始位置 = 起始时间 * 采样率
let endOffset = (end * rate) >> 0; // 结束位置 = 结束时间 * 采样率
let frameCount = endOffset - startOffset; // 音频帧数 = 结束位置 - 起始位置
// 创建同样同样声道数、采样率,指定长度的空的AudioBuffer
let newBuffer = ac.createBuffer(channels, frameCount, rate);
for (let i = 0; i < channels; i++) {
// 复制音频
newBuffer
.getChannelData(i)
.set(
currentBuffer
.getChannelData(i)
.slice(startOffset, endOffset)
);
}
// 是否需要复制,默认复制(删除操作不需要复制)
if (isCopy) {
copyData = newBuffer;
copyDuration = end - start;
}
return {
buffer: currentBuffer,
curIndex: operationIndex,
maxIndex: operationRecode.length,
copyData,
copyLength: newBuffer.length,
};
};
/** 粘贴 做的是覆盖操作,会覆盖指定位置那一部分的音频
* @param {Number} currentTime 当前光标所在时间,必传
* @param {audioBuffer} buffer 当前音波图的 audioBuffer,非必传
*/
this.paste = function (currentTime, buffer) {
if (!copyData) return;
currentBuffer = buffer || currentBuffer; // 源音频
let duration = currentBuffer.duration; // 源音频时长
let copyToStart = (currentTime * rate) >> 0; // 要复制到的目标的开始位置
let copyToEnd = copyToStart + copyData.length; // 要复制到的目标的结束位置 = 开始位置 + 复制的音频长度
// 音频帧数差值 = ( 复制的音频长度 - ( 源音频长度 - 当前光标所在位置为止长度 ) )
let diffLength = copyData.length - (currentBuffer.length - copyToStart);
// 音频时长如果大于 当前光标时间点 + 复制的音频时长,音频帧数则同等于源音频帧数,否则等于 源音频帧数 + 音频帧数差值
let frameCount =
duration > currentTime + copyDuration
? currentBuffer.length
: currentBuffer.length + diffLength;
// 创建同样同样声道数、采样率,指定长度的空的AudioBuffer
let newBuffer = ac.createBuffer(channels, frameCount, rate);
for (let i = 0; i < channels; i++) {
let before = currentBuffer.getChannelData(i).slice(0, copyToStart); // 复制的音频要插入的时间点的前面部分
let add = copyData.getChannelData(i).slice(0, copyData.length); // 复制的音频
let after = currentBuffer.getChannelData(i).slice(copyToEnd); // 复制的音频要插入的时间点的后面部分
newBuffer.getChannelData(i).set([...before, ...add, ...after]);
}
pushRecord(newBuffer);
return {
buffer: currentBuffer,
curIndex: operationIndex,
maxIndex: operationRecode.length,
copyData,
};
};
/** 插入 做的是拼接操作,会在指定位置插入新数据,拼接成新的音频
* @param {Number} currentTime 当前光标所在时间,必传
* @param {audioBuffer} buffer 当前音波图的 audioBuffer,非必传
*/
this.insert = function (currentTime, buffer) {
if (!copyData) return;
currentBuffer = buffer || currentBuffer; // 源音频
let copyToStart = (currentTime * rate) >> 0; // 要复制到的目标的开始位置
// 音频帧数等于 源音频长度 + 复制音频长度
let frameCount = currentBuffer.length + copyData.length;
// 创建同样采样率、同样声道数量,指定长度的空的AudioBuffer
let newBuffer = ac.createBuffer(channels, frameCount, rate);
for (let i = 0; i < channels; i++) {
let before = currentBuffer.getChannelData(i).slice(0, copyToStart); // 复制的音频要插入的时间点的前面部分
let add = copyData.getChannelData(i).slice(0, copyData.length); // 复制的音频
let after = currentBuffer.getChannelData(i).slice(copyToStart); // 复制的音频要插入的时间点的后面部分
newBuffer.getChannelData(i).set([...before, ...add, ...after]);
}
pushRecord(newBuffer);
return {
buffer: currentBuffer,
curIndex: operationIndex,
maxIndex: operationRecode.length,
copyData,
};
};
/** 剪切
* @param {Object} region=currentBuffer 当前选中的区域,必传
* @param {audioBuffer} buffer 当前音波图的 audioBuffer,非必传
*/
this.cut = function (region, buffer, isCopy = true) {
currentRegion = region; // 当前选中区域
if (!currentRegion) return;
currentBuffer = buffer || currentBuffer; // 源音频
let copyLength = this.copy(region, buffer, isCopy).copyLength; // 如果是删除 不复制数据 剪切才复制 所以这里用局部变量
let { start, end } = currentRegion; // 获取当前区域的开始结束时间
let cutStart = (start * rate) >> 0; // 要剪切的目标的开始位置
let cutEnd = (end * rate) >> 0; // 要剪切的目标的结束位置
// 音频帧数 = 源音频长度 - 复制的音频数据长度
let frameCount = currentBuffer.length - copyLength; // 创建同样采样率、同样声道数量,指定长度的空的AudioBuffer
let newBuffer = ac.createBuffer(channels, frameCount, rate);
for (let i = 0; i < channels; i++) {
let before = currentBuffer.getChannelData(i).slice(0, cutStart); // 复制数据 前面部分
let after = currentBuffer.getChannelData(i).slice(cutEnd); // 复制数据 后面部分
newBuffer.getChannelData(i).set([...before, ...after]);
}
pushRecord(newBuffer);
currentRegion = null;
return {
buffer: currentBuffer,
curIndex: operationIndex,
maxIndex: operationRecode.length,
copyData,
};
};
/** 删除
* @param {Object} region=currentBuffer 当前选中的区域,必传
* @param {audioBuffer} buffer 当前音波图的 audioBuffer,非必传
*/
this.remove = function (region, buffer) {
// 删除其实就是和剪切一样的操作,只是剪切要复制数据,删除不复制
return this.cut(region, buffer, false);
};
/** 撤销 */
this.backout = function () {
if (operationIndex === 0) return; // 第一条记录
operationIndex > 0 && --operationIndex; // 每次撤销操作后 下标--
currentBuffer = operationRecode[operationIndex];
return {
buffer: currentBuffer,
curIndex: operationIndex,
maxIndex: operationRecode.length,
copyData,
};
};
/** 重做 */
this.renewal = function () {
if (operationIndex === operationRecode.length - 1) return; // 已经是最新记录
operationIndex < maxCount - 1 && ++operationIndex; // 每次重做操作后 下标++ 因为下标从0开始 这里-1
currentBuffer = operationRecode[operationIndex];
return {
buffer: currentBuffer,
curIndex: operationIndex,
maxIndex: operationRecode.length,
copyData,
};
};
/* 保存操作记录 */
function pushRecord(newBuffer) {
if (operationIndex < operationRecode.length - 1) {
// 如果撤销后执行了其他操作 则覆盖后面的记录
operationRecode = operationRecode.slice(0, operationIndex + 1);
}
operationIndex < maxCount - 1 && ++operationIndex; // 每次操作后 下标++ 因为下标从0开始 这里-1
// 如果当前记录条数小于 最大存储条数限制 直接push
if (operationRecode.length < maxCount) {
operationRecode.push(newBuffer); // 每次操作 把当前的数据push到数组里面保存
} else {
// 如果数组的长度大于等于maxOperateCount 则删掉数组第一个元素,再push
operationRecode.shift();
operationRecode.push(newBuffer);
}
currentBuffer = operationRecode[operationIndex]; // 每次操作后 赋值新的buffer
}
}
html
这是html文件,简单的使用示范,就随便参考下好了,只是简单实现了下 按钮的禁用等操作
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>音频剪切</title>
<script src="https://unpkg.com/wavesurfer.js"></script>
<script src="https://unpkg.com/wavesurfer.js/dist/plugin/wavesurfer.regions.min.js"></script>
<script src="https://unpkg.com/wavesurfer.js/dist/plugin/wavesurfer.minimap.min.js"></script>
<script src="https://unpkg.com/wavesurfer.js/dist/plugin/wavesurfer.timeline.min.js"></script>
<script src="./wavesurferEdit.js"></script>
</head>
<body>
<div>
<input type="file" id="input" />
<div id="wave-minimap"></div>
<div id="waveform" onclick="removeRegion()"></div>
<div id="wave-timeline"></div>
<div class="btn-group">
<button onclick="handleEvent('play')">播放</button>
<button onclick="handleEvent('pause')">暂停</button>
<button disabled id="cut" onclick="handleOperate('cut')">
剪切
</button>
<button disabled id="remove" onclick="handleOperate('remove')">
删除
</button>
<button disabled id="copy" onclick="handleOperate('copy')">
复制
</button>
<button disabled id="paste" onclick="handleOperate('paste')">
粘贴
</button>
<button disabled id="insert" onclick="handleOperate('insert')">
插入
</button>
<button
disabled
id="backout"
onclick="handleOperate('backout')"
>
撤销
</button>
<button
disabled
id="renewal"
onclick="handleOperate('renewal')"
>
重做
</button>
</div>
</div>
</body>
<script>
let input = document.getElementById('input');
let buffer = null;
let currentRegion = null; // 当前选中region
let operate = {
copy: document.getElementById('copy'),
paste: document.getElementById('paste'),
insert: document.getElementById('insert'),
cut: document.getElementById('cut'),
remove: document.getElementById('remove'),
backout: document.getElementById('backout'),
renewal: document.getElementById('renewal'),
};
// 创建 wavesurfer 实例
var wavesurfer = WaveSurfer.create({
container: '#waveform', // 音波图容器
splitChannels: true, // 使用音频通道的单独波形渲染
waveColor: 'rgba(109,117,203,1)', // 波形颜色
progressColor: 'rgba(109,117,203,1)', // 播放过的波形颜色
plugins: [
WaveSurfer.regions.create({
dragSelection: true, // 允许拖拽选中创建region
}),
WaveSurfer.minimap.create({
container: '#wave-minimap', // 迷你音波图容器
waveColor: 'rgba(109,117,203,1)', // 波形颜色
progressColor: 'rgba(109,117,203,1)', // 播放过的波形颜色
height: 40, // 迷你音波图高度
}),
WaveSurfer.timeline.create({
container: '#wave-timeline', // 时间线容器
}),
],
});
input.onchange = e => {
let file = e.target.files[0];
wavesurfer.loadBlob(file);
// 监听 region 单击
wavesurfer.on('ready', () => {
buffer = wavesurfer.backend.buffer; // 获取wavesurfer返回的audioBuffer
editor = new WavesurferEdit({
buffer,
ac: wavesurfer.backend.ac,
maxCount: 20,
});
});
wavesurfer.on('region-click', (region, e) => {
e.stopPropagation(); // 阻止冒泡
currentRegion = region;
});
// 监听 region 更新时
wavesurfer.on('region-updated', (region, e) => {
currentRegion = region;
removeRegion(region); // 只保留最后一个region
});
// 监听 region 更新结束
wavesurfer.on('region-update-end', (region, e) => {
currentRegion = region;
isDisabled();
});
};
function handleOperate(e) {
if (!editor) return alert('请先选择音频');
let val = null;
let timeArr = ['paste', 'insert'];
// 粘贴、插入传参为当前光标时间点,复制、剪切、删除传参为当前选中区域
if (timeArr.includes(e)) val = wavesurfer.getCurrentTime();
else val = currentRegion;
let res = editor[e](val);
if (!res) return;
operate.paste.disabled = res.copyData ? false : true; // 如果没有复制的数据,粘贴按钮禁用
operate.insert.disabled = res.copyData ? false : true; // 如果没有复制的数据,插入按钮禁用
operate.backout.disabled = res.curIndex > 0 ? false : true; // 如果已经撤销到第一步操作,撤销按钮禁用
operate.renewal.disabled =
res.curIndex < res.maxIndex - 1 ? false : true; // 如果已经是最新的操作,重做按钮禁用
if (e !== 'copy') {
// 如果不是复制操作,则将返回的buffer赋值,加载新的buffer,重新渲染音波图,因为复制操作只是复制了内容,没有做任何更改
buffer = res.buffer;
render();
}
if (e === 'cut' || e === 'remove') {
// 如果是剪切、删除操作,则移除当前的region
removeRegion();
}
}
/* 只保留一个区域 */
function removeRegion(region = {}) {
// 如果当前没有选中的region 重置currentRegion 为 null
if (!Object.keys(region).length) currentRegion = null;
let regions = wavesurfer.regions.list;
for (const key in regions) {
if (region.id === regions[key].id) continue;
regions[key].remove();
}
isDisabled();
}
/* 复制、剪切、删除按钮是否禁用 */
function isDisabled() {
// 只有currentRegion有选中区域的时候 复制、剪切、删除才允许点击
operate.copy.disabled = currentRegion ? false : true;
operate.cut.disabled = currentRegion ? false : true;
operate.remove.disabled = currentRegion ? false : true;
}
/* 重新渲染 音波图 */
function render() {
wavesurfer.backend.load(buffer); // 加载新的audioBuffer
wavesurfer.drawBuffer(); // 重新渲染音波图
wavesurfer.minimap.render(); // 重新渲染迷你音波图
}
/* 播放暂停 测试音频是否正常 */
function handleEvent(e) {
wavesurfer[e]();
}
</script>
</html>