Skip to content

实现 音频剪辑 工具

分类:general | 标签:音频、剪辑、WAV 在线裁剪音频,波形预览,导出 WAV

2.1 功能说明

在线裁剪音频,波形预览,导出 WAV

核心能力

  • 音频文件
  • 起点(秒)
  • 音量增益
  • 淡入淡出

2.1.1 使用指南

音频剪辑使用说明

在浏览器里直接裁剪音频片段:选择文件后解码成波形,框选想保留的区间, 可叠加音量调整与淡入淡出,最后导出为无损 WAV。整个过程在本机完成,音频不会上传服务器。 适合截取铃声、剪掉录音开头的空白、提取采访片段、制作音效素材。

操作步骤

1. 点击“选择文件”载入音频,支持 MP3、WAV、M4A、AAC、OGG、FLAC 等浏览器能解码的格式。

2. 波形出现后,在波形上按住鼠标横向拖动即可框选区间;已有选区时,靠近左右边界按下可以单独拖动该边界做微调。也可以直接在“起点”“终点”里输入精确秒数。

3. 需要时调整音量增益,或勾选淡入淡出消除首尾的爆音。

4. 点击“试听选区”确认效果,满意后点击“导出 WAV”下载。

参数说明

音量增益:单位是分贝。加 6 分贝约等于音量翻倍,减 6 分贝约等于减半。 提升过多会导致削波失真,导出时工具会把超出范围的采样限制在合法区间内,但失真已经产生,建议不要盲目拉高。

淡入淡出:在片段开头做音量由零渐强、结尾做渐弱。从整段音频中间截取时,切口处波形突变会产生咔哒声, 加上 0.05 到 0.2 秒的淡入淡出即可消除。淡入淡出时长不会超过选区本身长度。

导出位深:16 bit 是 CD 标准,兼容所有设备,一分钟立体声约 10 MB; 24 bit 保留更多细节,适合后期还要继续处理的素材,体积约为 16 bit 的 1.5 倍。 采样率与声道数保持原始音频不变。

关于 WAV 与 MP3

WAV 是未压缩的原始 PCM 数据加一个描述性文件头,浏览器可以直接生成,因此本工具用它作为导出格式, 音质与原始解码结果完全一致,没有二次压缩损失。 MP3 属于有损压缩格式,浏览器只能解码不能编码,纯前端生成 MP3 必须额外加载编码器库, 为保持零依赖与离线可用,本工具不提供 MP3 导出。如需 MP3,可把导出的 WAV 再用本站的音频格式转换工具处理。

常见问题

提示解码失败:说明浏览器无法识别该编码。不同浏览器支持的格式有差异,例如部分环境不支持 FLAC 或某些 M4A 变体,可先转成 MP3 或 WAV 再来剪辑。

试听没有声音:浏览器要求音频播放由用户操作触发,请确认是点击按钮播放;同时检查选区是否过短以及增益是否被拉到很低。

大文件卡顿:解码会把整段音频以浮点数形式载入内存,一小时的立体声音频可能占用数百兆内存。建议先粗剪成小段再精修,超过十分钟的文件请耐心等待波形绘制。

导出的文件比原文件大很多:这是正常现象。原文件多为 MP3 等压缩格式,而 WAV 不压缩,体积成倍增长属于格式差异,不是出错。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • formatTime()
  • ensureCtx()
  • decodeAudio()
  • ok()
  • fail()
  • readAsArrayBuffer()
  • onFile()
  • computePeaks()
  • resizeCanvas()
  • drawWave()
  • posToSec()
  • onPointerDown()
  • onPointerMove()
  • onPointerUp()
  • selectAll()
  • resetEdit()
  • buildTrimmed()
  • stopPlay()
  • playSelection()
  • tick()
  • writeString()
  • encodeWav()
  • exportWav()
  • onResize()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const supported = ref(true)
const fileInput = ref(null)
const fileName = ref('')
const decoding = ref(false)
const error = ref('')
const buffer = ref(null)

const startSec = ref(0)
const endSec = ref(0)
const gainDb = ref(0)
const fadeEnabled = ref(false)
const fadeInSec = ref(0.1)
const fadeOutSec = ref(0.1)
const bitDepth = ref(16)
const playing = ref(false)
const exporting = ref(false)

const canvasWrap = ref(null)
const waveCanvas = ref(null)

let audioCtx = null
let peaks = null
let source = null
let playRaf = null
let playStartCtxTime = 0
let dragMode = ''

const totalDuration = computed(() => (buffer.value ? buffer.value.duration : 0))
const selectionDuration = computed(() => Math.max(0, endSec.value - startSec.value))

function formatTime(sec) {
  const s = Math.max(0, sec)
  const m = Math.floor(s / 60)
  const rest = s - m * 60
  return m + ':' + (rest < 10 ? '0' : '') + rest.toFixed(2)
}

function ensureCtx() {
  if (audioCtx) return audioCtx
  const Ctx = window.AudioContext || window.webkitAudioContext
  if (!Ctx) return null
  audioCtx = new Ctx()
  return audioCtx
}

function decodeAudio(ctx, arrayBuffer) {
  return new Promise((resolve, reject) => {
    let settled = false
    const ok = (b) => {
      if (!settled) {
        settled = true
        resolve(b)
      }
    }
    const fail = (e) => {
      if (!settled) {
        settled = true
        reject(e || new Error('decode failed'))
      }
    }
    try {
      const ret = ctx.decodeAudioData(arrayBuffer, ok, fail)
      if (ret && typeof ret.then === 'function') ret.then(ok).catch(fail)
    } catch (e) {
      fail(e)
    }
  })
}

function readAsArrayBuffer(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result)
    reader.onerror = () => reject(new Error('读取文件失败'))
    reader.readAsArrayBuffer(file)
  })
}

async function onFile(e) {
  const f = e.target.files && e.target.files[0]
  if (!f) return
  error.value = ''
  stopPlay()
  buffer.value = null
  peaks = null
  fileName.value = f.name
  const ctx = ensureCtx()
  if (!ctx) {
    supported.value = false
    return
  }
  decoding.value = true
  try {
    const arrayBuffer = await readAsArrayBuffer(f)
    const decoded = await decodeAudio(ctx, arrayBuffer)
    buffer.value = decoded
    startSec.value = 0
    endSec.value = decoded.duration
    await nextTick()
    computePeaks()
    resizeCanvas()
  } catch (err) {
    error.value = '解码失败:浏览器无法识别该音频编码,请换成 MP3 或 WAV 后重试。'
    buffer.value = null
  } finally {
    decoding.value = false
  }
}

/* ---------- 波形 ---------- */
const PEAK_COUNT = 2000

function computePeaks() {
  const buf = buffer.value
  if (!buf) return
  const channels = buf.numberOfChannels
  const len = buf.length
  const blockSize = Math.max(1, Math.floor(len / PEAK_COUNT))
  const result = new Float32Array(PEAK_COUNT * 2)
  const chData = []
  for (let c = 0; c < channels; c += 1) chData.push(buf.getChannelData(c))

  for (let i = 0; i < PEAK_COUNT; i += 1) {
    const from = i * blockSize
    const to = Math.min(len, from + blockSize)
    let min = 0
    let max = 0
    for (let j = from; j < to; j += 1) {
      for (let c = 0; c < channels; c += 1) {
        const v = chData[c][j]
        if (v < min) min = v
        if (v > max) max = v
      }
    }
    result[i * 2] = min
    result[i * 2 + 1] = max
  }
  peaks = result
}

function resizeCanvas() {
  const canvas = waveCanvas.value
  const wrap = canvasWrap.value
  if (!canvas || !wrap) return
  const dpr = window.devicePixelRatio || 1
  const cssW = wrap.clientWidth
  const cssH = 180
  canvas.style.width = cssW + 'px'
  canvas.style.height = cssH + 'px'
  canvas.width = Math.round(cssW * dpr)
  canvas.height = Math.round(cssH * dpr)
  drawWave()
}

function drawWave(playheadSec) {
  const canvas = waveCanvas.value
  if (!canvas || !peaks || !buffer.value) return
  const ctx = canvas.getContext('2d')
  const dpr = window.devicePixelRatio || 1
  const w = canvas.width
  const h = canvas.height
  const mid = h / 2

  ctx.clearRect(0, 0, w, h)
  ctx.fillStyle = '#fafafa'
  ctx.fillRect(0, 0, w, h)

  const dur = totalDuration.value || 1
  const selX1 = (startSec.value / dur) * w
  const selX2 = (endSec.value / dur) * w

  // 选区底色
  ctx.fillStyle = 'rgba(64, 158, 255, 0.10)'
  ctx.fillRect(selX1, 0, Math.max(0, selX2 - selX1), h)

  // 中线
  ctx.strokeStyle = '#e4e7ed'
  ctx.lineWidth = 1
  ctx.beginPath()
  ctx.moveTo(0, mid)
  ctx.lineTo(w, mid)
  ctx.stroke()

  // 波形
  const step = w / PEAK_COUNT
  for (let i = 0; i < PEAK_COUNT; i += 1) {
    const x = i * step
    const min = peaks[i * 2]
    const max = peaks[i * 2 + 1]
    const y1 = mid - max * mid * 0.95
    const y2 = mid - min * mid * 0.95
    const inSel = x >= selX1 && x <= selX2
    ctx.fillStyle = inSel ? '#409eff' : '#c0c4cc'
    ctx.fillRect(x, y1, Math.max(1, step), Math.max(1, y2 - y1))
  }

  // 选区边界
  ctx.strokeStyle = '#409eff'
  ctx.lineWidth = 2 * dpr
  ctx.beginPath()
  ctx.moveTo(selX1, 0)
  ctx.lineTo(selX1, h)
  ctx.moveTo(selX2, 0)
  ctx.lineTo(selX2, h)
  ctx.stroke()

  // 播放头
  if (typeof playheadSec === 'number') {
    const px = (playheadSec / dur) * w
    ctx.strokeStyle = '#f56c6c'
    ctx.lineWidth = 2 * dpr
    ctx.beginPath()
    ctx.moveTo(px, 0)
    ctx.lineTo(px, h)
    ctx.stroke()
  }
}

watch([startSec, endSec], () => {
  if (startSec.value > endSec.value) {
    const t = startSec.value
    startSec.value = endSec.value
    endSec.value = t
  }
  drawWave()
})

/* ---------- 选区拖拽 ---------- */
function posToSec(evt) {
  const canvas = waveCanvas.value
  const rect = canvas.getBoundingClientRect()
  const ratio = Math.min(1, Math.max(0, (evt.clientX - rect.left) / rect.width))
  return ratio * totalDuration.value
}

function onPointerDown(evt) {
  if (!buffer.value) return
  const canvas = waveCanvas.value
  canvas.setPointerCapture(evt.pointerId)
  const sec = posToSec(evt)
  const rect = canvas.getBoundingClientRect()
  const tol = (8 / rect.width) * totalDuration.value
  if (Math.abs(sec - startSec.value) < tol) {
    dragMode = 'start'
  } else if (Math.abs(sec - endSec.value) < tol) {
    dragMode = 'end'
  } else {
    dragMode = 'new'
    startSec.value = Number(sec.toFixed(2))
    endSec.value = Number(sec.toFixed(2))
  }
}

function onPointerMove(evt) {
  if (!dragMode || !buffer.value) return
  const sec = Number(posToSec(evt).toFixed(2))
  if (dragMode === 'start') startSec.value = Math.min(sec, endSec.value)
  else if (dragMode === 'end') endSec.value = Math.max(sec, startSec.value)
  else endSec.value = Math.max(sec, startSec.value)
}

function onPointerUp(evt) {
  if (!dragMode) return
  dragMode = ''
  const canvas = waveCanvas.value
  try {
    if (canvas && evt.pointerId !== undefined) canvas.releasePointerCapture(evt.pointerId)
  } catch (e) {
    // 忽略释放捕获时的异常
  }
  // 选区过短时给一个最小长度,避免空片段
  if (endSec.value - startSec.value < 0.05) {
    endSec.value = Math.min(totalDuration.value, startSec.value + 0.05)
  }
}

function selectAll() {
  startSec.value = 0
  endSec.value = totalDuration.value
}

function resetEdit() {
  gainDb.value = 0
  fadeEnabled.value = false
  fadeInSec.value = 0.1
  fadeOutSec.value = 0.1
  bitDepth.value = 16
  selectAll()
}

/* ---------- 生成裁剪后的 AudioBuffer ---------- */
function buildTrimmed() {
  const buf = buffer.value
  const ctx = ensureCtx()
  if (!buf || !ctx) return null
  const rate = buf.sampleRate
  const from = Math.max(0, Math.floor(startSec.value * rate))
  const to = Math.min(buf.length, Math.floor(endSec.value * rate))
  const len = to - from
  if (len <= 0) return null

  const channels = buf.numberOfChannels
  const out = ctx.createBuffer(channels, len, rate)
  const gain = Math.pow(10, gainDb.value / 20)

  // 淡入淡出样本数,且不超过片段一半,避免两段重叠
  let fadeInLen = 0
  let fadeOutLen = 0
  if (fadeEnabled.value) {
    fadeInLen = Math.min(Math.floor(fadeInSec.value * rate), Math.floor(len / 2))
    fadeOutLen = Math.min(Math.floor(fadeOutSec.value * rate), Math.floor(len / 2))
  }

  for (let c = 0; c < channels; c += 1) {
    const src = buf.getChannelData(c)
    const dst = out.getChannelData(c)
    for (let i = 0; i < len; i += 1) {
      let v = src[from + i] * gain
      if (fadeInLen > 0 && i < fadeInLen) v *= i / fadeInLen
      if (fadeOutLen > 0 && i >= len - fadeOutLen) v *= (len - i) / fadeOutLen
      if (v > 1) v = 1
      else if (v < -1) v = -1
      dst[i] = v
    }
  }
  return out
}

/* ---------- 试听 ---------- */
function stopPlay() {
  if (playRaf) {
    window.cancelAnimationFrame(playRaf)
    playRaf = null
  }
  if (source) {
    try {
      source.onended = null
      source.stop()
    } catch (e) {
      // 忽略重复停止的异常
    }
    source = null
  }
  playing.value = false
  drawWave()
}

async function playSelection() {
  const ctx = ensureCtx()
  if (!ctx) return
  const trimmed = buildTrimmed()
  if (!trimmed) {
    error.value = '选区为空,请先框选一段音频。'
    return
  }
  stopPlay()
  try {
    if (ctx.state === 'suspended') await ctx.resume()
    source = ctx.createBufferSource()
    source.buffer = trimmed
    source.connect(ctx.destination)
    source.onended = () => {
      playing.value = false
      if (playRaf) {
        window.cancelAnimationFrame(playRaf)
        playRaf = null
      }
      drawWave()
    }
    playStartCtxTime = ctx.currentTime
    source.start()
    playing.value = true
    const tick = () => {
      if (!playing.value) return
      const elapsed = ctx.currentTime - playStartCtxTime
      drawWave(Math.min(endSec.value, startSec.value + elapsed))
      playRaf = window.requestAnimationFrame(tick)
    }
    tick()
  } catch (e) {
    playing.value = false
    error.value = '播放失败:' + (e && e.message ? e.message : String(e))
  }
}

/* ---------- WAV 编码 ---------- */
function writeString(view, offset, str) {
  for (let i = 0; i < str.length; i += 1) {
    view.setUint8(offset + i, str.charCodeAt(i))
  }
}

function encodeWav(audioBuffer, depth) {
  const numChannels = audioBuffer.numberOfChannels
  const sampleRate = audioBuffer.sampleRate
  const numFrames = audioBuffer.length
  const bytesPerSample = depth === 24 ? 3 : 2
  const blockAlign = numChannels * bytesPerSample
  const byteRate = sampleRate * blockAlign
  const dataSize = numFrames * blockAlign
  const bufferSize = 44 + dataSize

  const arrayBuffer = new ArrayBuffer(bufferSize)
  const view = new DataView(arrayBuffer)

  // RIFF 头,共 12 字节
  writeString(view, 0, 'RIFF')
  view.setUint32(4, 36 + dataSize, true)
  writeString(view, 8, 'WAVE')

  // fmt 子块,共 24 字节
  writeString(view, 12, 'fmt ')
  view.setUint32(16, 16, true) // 子块长度,PCM 固定 16
  view.setUint16(20, 1, true) // 编码格式,1 表示 PCM
  view.setUint16(22, numChannels, true)
  view.setUint32(24, sampleRate, true)
  view.setUint32(28, byteRate, true)
  view.setUint16(32, blockAlign, true)
  view.setUint16(34, depth, true)

  // data 子块头,共 8 字节,至此正好 44 字节
  writeString(view, 36, 'data')
  view.setUint32(40, dataSize, true)

  const channelData = []
  for (let c = 0; c < numChannels; c += 1) channelData.push(audioBuffer.getChannelData(c))

  let offset = 44
  if (depth === 24) {
    for (let i = 0; i < numFrames; i += 1) {
      for (let c = 0; c < numChannels; c += 1) {
        let s = channelData[c][i]
        if (s > 1) s = 1
        else if (s < -1) s = -1
        const val = Math.round(s < 0 ? s * 0x800000 : s * 0x7fffff)
        // 24 位按小端写入三个字节
        view.setUint8(offset, val & 0xff)
        view.setUint8(offset + 1, (val >> 8) & 0xff)
        view.setUint8(offset + 2, (val >> 16) & 0xff)
        offset += 3
      }
    }
  } else {
    for (let i = 0; i < numFrames; i += 1) {
      for (let c = 0; c < numChannels; c += 1) {
        let s = channelData[c][i]
        if (s > 1) s = 1
        else if (s < -1) s = -1
        view.setInt16(offset, Math.round(s < 0 ? s * 0x8000 : s * 0x7fff), true)
        offset += 2
      }
    }
  }

  return new Blob([arrayBuffer], { type: 'audio/wav' })
}

function exportWav() {
  error.value = ''
  exporting.value = true
  try {
    const trimmed = buildTrimmed()
    if (!trimmed) {
      error.value = '选区为空,请先框选一段音频。'
      return
    }
    const blob = encodeWav(trimmed, bitDepth.value)
    const href = URL.createObjectURL(blob)
    const base = fileName.value.replace(/\.[^.]+$/, '') || 'audio'
    const a = document.createElement('a')
    a.href = href
    a.download = base + '-trimmed.wav'
    a.click()
    window.setTimeout(() => URL.revokeObjectURL(href), 2000)
  } catch (e) {
    error.value = '导出失败:' + (e && e.message ? e.message : String(e))
  } finally {
    exporting.value = false
  }
}

function onResize() {
  if (buffer.value) resizeCanvas()
}

onMounted(() => {
  const Ctx = typeof window !== 'undefined' ? window.AudioContext || window.webkitAudioContext : null
  supported.value = Boolean(Ctx)
  window.addEventListener('resize', onResize)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', onResize)
  stopPlay()
  if (audioCtx) {
    try {
      audioCtx.close()
    } catch (e) {
      // 忽略关闭音频上下文的异常
    }
    audioCtx = null
  }
})

2.3 效果截图

音频剪辑 效果截图

工具访问地址:https://www.i91tools.com/tools/audio-trim