前端音频切割

153 阅读5分钟

某天在网上看到一个博主讲解绘本的音频,讲得声情并茂,小孩特别喜欢,于是想把音频下下来给娃做毛毛虫点读笔制贴。

由于完整音频需要根据每页的书本内容切割成多段音频再导入毛毛虫,我又在网上搜索,想找个免费好用的音频切割软件。

好家伙,几乎全是免费用几次后续要收费,而且只能切割一段视频。

我的需求是一个音频要切割成10来段小音频,这样得操作10来次。小孩的绘本那么多,作为程序员,怎么能可以容忍操作三次以上的重复工作!

那不如自己做个音频批量切割网页吧!

经过和ai的多次沟通,就基本确认了这个项目纯前端就能做,而且方案超级简单:

  1. 将音频文件转换为buffer,通过前端api: window.AudioContext可根据开始时间和结束时间切割buffer;
  2. AudioBuffer转WAV文件的方法,以Blob数据格式返回
  3. 将Blob数据下载为wav格式

可以看看我已实现的demo体验下。

截屏2025-03-14 16.16.14.png

话不多说,开码!

一、将音频文件转换为buffer,并根据开始和结束时间切割

  1. 使用input file即可获取文件的buffer
<input type="file" accept=".mp3,.wav,.ogg,.m4a" @change="changeFile" />
<script lang="ts" setup>
const audioUrl = ref('');
const buffer = ref<ArrayBuffer | null>(null);
function changeFile(file: File) {
  // 释放上一次的资源
  if (audioUrl.value) {
    URL.revokeObjectURL(audioUrl.value);
  }
  audioUrl.value = URL.createObjectURL(file);
  fileName.value = file.name.split('.')[0];
  // 重置区域
  const reader = new FileReader();
  reader.readAsArrayBuffer(file);
  reader.onload = async (res) => {
    buffer.value = res.target?.result as ArrayBuffer
  };
}
</script>
  1. 拿到buffer后就可以开始根据时间进行音频切割啦。
interface AudioBufferInfo {
  audioBuffer: AudioBuffer;
  frameCount: number;
}
// 事件单位为毫秒
async function toAudioBuffer(
  buffer: ArrayBuffer,
  startTime: number,
  endTime: number
): Promise<AudioBufferInfo> {
  const audioContext = new window.AudioContext();
  const audioBuffer = await audioContext.decodeAudioData(buffer); // ArrayBuffer转AudioBuffer[1,8](@ref)
  const channels = audioBuffer.numberOfChannels;
  const sampleRate = audioBuffer.sampleRate;
  const startOffset = Math.floor(startTime * sampleRate);
  const endOffset = Math.floor(endTime * sampleRate);

  const frameCount = endOffset - startOffset;
  const newAudioBuffer = audioContext.createBuffer(
    channels,
    frameCount,
    sampleRate
  );
  const anotherArray = new Float32Array(frameCount);
  for (let channel = 0; channel < channels; channel++) {
    audioBuffer.copyFromChannel(anotherArray, channel, startOffset);
    newAudioBuffer.copyToChannel(anotherArray, channel, 0);
  }
  return { audioBuffer: newAudioBuffer, frameCount };
}

二、AudioBuffer转WAV文件的方法,以Blob数据格式返回

function bufferToWav(audioBuffer: AudioBuffer, len: number): Blob {
  let numOfChan = audioBuffer.numberOfChannels,
    length = len * numOfChan * 2 + 44,
    buffer = new ArrayBuffer(length),
    view = new DataView(buffer),
    channels = [],
    i,
    sample,
    offset = 0,
    pos = 0;

  // write WAVE header
  setUint32(0x46464952); // "RIFF"
  setUint32(length - 8); // file length - 8
  setUint32(0x45564157); // "WAVE"

  setUint32(0x20746d66); // "fmt " chunk
  setUint32(16); // length = 16
  setUint16(1); // PCM (uncompressed)
  setUint16(numOfChan);
  setUint32(audioBuffer.sampleRate);
  setUint32(audioBuffer.sampleRate * 2 * numOfChan); // avg. bytes/sec
  setUint16(numOfChan * 2); // block-align
  setUint16(16); // 16-bit (hardcoded in this demo)

  setUint32(0x61746164); // "data" - chunk
  setUint32(length - pos - 4); // chunk length

  // write interleaved data
  for (i = 0; i < audioBuffer.numberOfChannels; i++)
    channels.push(audioBuffer.getChannelData(i));

  while (pos < length) {
    for (i = 0; i < numOfChan; i++) {
      // interleave channels
      sample = Math.max(-1, Math.min(1, channels[i][offset])); // clamp
      sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0; // scale to 16-bit signed int
      view.setInt16(pos, sample, true); // write 16-bit sample
      pos += 2;
    }
    offset++; // next source sample
  }

  function setUint16(data: number) {
    view.setUint16(pos, data, true);
    pos += 2;
  }

  function setUint32(data: number) {
    view.setUint32(pos, data, true);
    pos += 4;
  }
  // create Blob
  return new Blob([buffer], { type: 'audio/wav' });
}

三、开始下载

  1. 下载单个文件
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
a.href = url;
a.target = '_blank';
a.download = `audio.wav`;
a.click();
// 下载完记得释放资源
URL.revokeObjectURL(url);

  1. 如果想一次性下载多个切片怎么处理?

这里就要用到jszip和file-saver。

jszip是用来可以创建、读取和编辑zip文件,生成 ZIP 时可选择多种输出类型,包括 blob,具体可以看官方文档:stuk.github.io/jszip/

file-saver则持将文件保存到用户的本地文件系统。它通过创建 Blob 对象并将其保存为文件来实现这一功能,适用于各种文件类型,如文本、图像、PDF 等,文档: github.com/eligrey/Fil…

批量下载的流程为:批量创建多个blob -> jszip创建zip实例 -> 将多个blob文件添加到zip文件 -> jszip生成zip数据转换为blob对象 -> file-saver下载

a. 批量创建多个切割的音频blob

// splitAreaList用来存放切割后的blob
const splitAreaList = [];
// 假设导入的音频时长超过5秒
const timeList = [{start: 0, end:3000}, {start: 3000, end:5000}];
async function getBlobByTime(startTime: number, endTime: number) {
  if (!buffer.value) return '';
  const { audioBuffer, frameCount } = await toAudioBuffer(
    // 这里要注意
    // decodeAudioData 方法在解码时会直接修改或释放原始 ArrayBuffer(浏览器优化机制),导致后续无法复用同一 buffer
    // 所以需要使用slice进行复制,防止切割一次后buffer被释放了
    buffer.value.slice(0),
    startTime,
    endTime
  );
  return bufferToWav(audioBuffer, frameCount);
}
timeList.forEach(async (item) => {
  const blob = getBlobByTime(item.start, item.end);
  splitAreaList.push(blob);
})

b. 批量下载音频

// 这里需要安装file-saver和jszip的npm依赖包
import { saveAs } from 'file-saver';
import jszip from 'jszip';
// 创建zip实例
const zip = new jszip();
// 假设splitAreaList是多个格式为wav的blob
splitAreaList.forEach((blob, index) => {
  if (blob) {
    // 将blob文件一个个添加到zip文件
    zip.file(`audio-${(index + 1)}.wav`, blob, { binary: true });
  }
});
// 生成zip数据转换为blob对象
zip.generateAsync({ type: 'blob' }).then((newBlob) => {
  // file-saver下载,完成!
  saveAs(newBlob, 'audio.zip');
});

四、扩展

如果想实现我demo的效果,音频导入后展示音频的波形图,并手动拖拽选择切割区域,这里介绍一个好用的js库wavesurfer.js:wavesurfer.xyz/

创建wavesurfer实例,并注册region插件

import WaveSurfer from 'wavesurfer.js';
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
const regions = RegionsPlugin.create();
const totalTime = ref(0);
const currentTime = ref(0);
const  wavesurfer = WaveSurfer.create({
  container: '#waves',
  waveColor: 'rgb(200, 200, 200)',
  progressColor: 'rgb(100, 100, 100)',
  // url可以为http地址,也可以为blob
  url: audioUrl.value,
  // 播放速度
  audioRate: 1,
  minPxPerSec: 1,
  dragToSeek: true,
  plugins: [regions]
});
// 获取视频总时长
 wavesurfer.on('ready', () => {
  totalTime.value = wavesurfer?.getDuration() || 0;
});
// 播放时获取当前播放时间
wavesurfer.on('timeupdate', (time) => {
  currentTime.value = time;
});
// 点击音浪区域可以播放或暂停
wavesurfer.on('interaction', () => {
  wavesurfer.playPause();
});

最后附上我的源码,大家觉得ok就点个赞呀!

参考文章:

[1]:JS纯前端实现audio音频剪裁剪切复制播放与上传 www.zhangxinxu.com/wordpress/2…