使用wavesurfer.js配合WebAudio API实现可视化简单音频编辑操作

1,356 阅读3分钟

零基础小白跨行做前端一年多,以前从来没接触过音频操作,代码写的不怎么好,你们可以参考下实现方式,敲代码思路就随便看看,我没思路,还是生手,所以不要喷我不要嘲讽我谢谢了
这是使用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>