Appearance
实现 音频剪辑 工具
分类: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-input、el-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 效果截图
