1. 项目概述:为什么要在浏览器里“干掉”人声?

最近几年,音乐制作、内容创作和音频处理的门槛越来越低,一个很常见的需求就是“提取伴奏”。无论是想跟着原版伴奏练习唱歌,还是想为视频剪辑寻找无干扰的背景音乐,亦或是想对某段音乐进行采样再创作,都需要将人声从完整的歌曲中剥离出来。传统的解决方案要么依赖专业的桌面软件(如 Audacity 配合复杂的插件链),要么需要将音频上传到云端服务器处理,前者对普通用户不友好,后者则存在隐私和延迟问题。

这个项目,就是要在浏览器里,利用现代 Web 技术和人工智能,实现一个功能强大、操作便捷的“人声消除器”。它的核心魅力在于“本地化”和“即时性”:所有计算都在你电脑的浏览器里完成,音频文件无需离开你的设备,处理过程几乎是实时的。这对于注重隐私的音乐爱好者、需要快速处理素材的短视频创作者,或者只是想随便玩玩的好奇者来说,都是一个极具吸引力的方案。

我们将要构建的,不仅仅是一个简单的滤波器。它是一个基于深度学习的、能够理解音乐和语音复杂结构的 AI 模型在浏览器中的推理应用。接下来,我会带你从零开始,深入技术细节,看看如何把这样一个听起来很“黑科技”的功能,变成每个人都能在网页上点击即用的工具。

2. 核心架构与技术选型解析

2.1 为什么是 WebAssembly + ONNX Runtime Web?

要在浏览器里跑 AI 模型,我们面临几个核心挑战:性能、兼容性和模型生态。JavaScript 本身对于密集的数值计算(如大型矩阵运算)效率不高。因此,我们需要一个接近原生性能的解决方案。

WebAssembly 是我们的基石。它是一种低级的、类汇编的二进制指令格式,可以在现代浏览器中近乎原生速度运行。我们可以将用 C++、Rust 等语言编写的高性能计算库编译成 .wasm 文件,在浏览器中调用。

但直接手写神经网络推理的 Wasm 模块是极其复杂的。这时, ONNX Runtime 登场了。ONNX 是一种开放的神经网络交换格式,几乎所有主流深度学习框架(PyTorch, TensorFlow 等)训练的模型都可以导出为 .onnx 格式。ONNX Runtime 是一个高性能的推理引擎,专门用于运行 ONNX 模型。而 ONNX Runtime Web 则是它的浏览器版本,其核心计算后端正是通过 WebAssembly 实现的。

这个组合的优势非常明显:

  1. 性能优异 :利用 Wasm 和经过高度优化的算子库,推理速度远超纯 JavaScript 实现。
  2. 模型通用 :我们可以使用在 Python 环境中用 PyTorch 等框架训练好的、效果最好的开源人声分离模型(如 UVR、Demucs 等),将其转换为 ONNX 格式后,直接用于我们的网页应用。
  3. 开箱即用 :ONNX Runtime Web 封装了复杂的底层细节,提供了简洁的 JavaScript API,让我们可以专注于应用逻辑。

2.2 前端框架与音频处理流水线

对于用户界面,一个轻量级、响应式的框架是首选,比如 Vue 3 React 。它们能帮助我们快速构建直观的文件上传、参数调整、进度展示和结果播放/下载界面。

更关键的是浏览器内置的 Web Audio API 。它是我们处理音频的瑞士军刀。整个处理流水线大致如下:

  1. 输入 :用户通过 <input type=“file”> 上传音频文件(MP3, WAV, FLAC 等)。
  2. 解码 :使用 AudioContext decodeAudioData 方法,将文件解码为原始的 PCM(脉冲编码调制)音频数据,即一个浮点数数组,代表了音频波形。
  3. 预处理 :对 PCM 数据进行预处理,以匹配 AI 模型的输入要求。这通常包括:
    • 重采样 :将音频统一采样率(如 44.1kHz)。
    • 分帧 :将长音频切割成重叠的短片段(例如 2-3 秒)。因为模型通常处理固定长度的输入,且长音频一次性处理内存压力大。
    • 归一化 :将音频数据缩放到模型训练时使用的范围(如 [-1, 1])。
  4. 推理 :将预处理后的数据(通常是 Float32Array)送入加载好的 ONNX 模型进行推理。模型会输出两个或多个 Float32Array,分别对应分离出的“人声”和“伴奏”轨道。
  5. 后处理 :对模型输出的数据进行处理,例如应用重叠-相加法来平滑分帧带来的边界效应,确保最终音频连贯无瑕疵。
  6. 编码与输出 :将处理后的 PCM 数据重新编码为可播放、可下载的格式。这里可以使用诸如 audiobuffer-to-wav 这样的库,将 PCM 数据封装成 WAV 文件,或者使用更复杂的库生成 MP3。

注意 :Web Audio API 在处理大音频文件时,内存管理至关重要。不当的引用持有会导致内存泄漏。务必在音频处理完成后,及时断开音频节点连接并释放对 AudioBuffer 的引用。

2.3 模型选择:从 Spleeter 到 MDX-Net

项目的核心在于模型。过去几年,音乐源分离领域出现了多个优秀的开源模型。

  • Spleeter :由 Deezer 公司开源,是让这项技术走入大众视野的功臣。它基于 U-Net 架构,模型小巧(4轨模型约 100MB),分离效果在当时令人惊艳。它是一个很好的起点,其 ONNX 模型也相对容易获取。
  • Demucs :Facebook Research 的作品,后续迭代版本(如 Demucs v3, v4)在效果上普遍被认为超越了 Spleeter。它采用了更复杂的时域卷积网络架构。不过,模型体积通常更大,对计算资源要求更高。
  • UV R :这是一个强大的开源项目,它本身是一个桌面 GUI 应用,但集成了多种分离算法和模型,包括 Spleeter、Demucs 以及更专业的 MDX-Net 系列模型。MDX-Net 模型在不少专业评测和社区反馈中,对人声和伴奏的分离清晰度、残留度方面表现非常出色。

我们的选择策略 : 对于浏览器环境,我们需要在 模型效果 模型大小 推理速度 之间做权衡。一个高质量的 4-stem(人声、鼓、贝斯、其他)模型可能超过 500MB,这对于网页加载是不可接受的。

因此,一个实用的方案是:

  1. 优先选择专为“人声-伴奏”二分离优化的模型,而不是多轨分离模型,以减小体积。
  2. 使用模型量化技术。将训练用的 FP32(单精度浮点数)模型量化为 INT8 (8位整数)格式,可以大幅减少模型体积(通常减少 75%)并提升推理速度,而精度损失在可接受范围内。ONNX Runtime 对量化模型有很好的支持。
  3. 提供多个模型选项。例如,一个“快速-标准”小模型用于实时预览或处理简单歌曲,一个“高精度-大模型”用于最终的精处理。让用户根据需求和设备性能选择。

假设我们最终选择了一个经过量化、大小约 40-60MB 的 MDX-Net 人声分离模型作为核心引擎。

3. 详细实现步骤与核心代码剖析

3.1 环境搭建与项目初始化

我们使用 Vite + Vue 3 来快速搭建开发环境,因为它启动快、热更新灵敏。

npm create vite@latest ai-vocal-remover -- --template vue
cd ai-vocal-remover
npm install

然后安装核心依赖:

npm install onnxruntime-web
npm install wavesurfer.js # 用于音频波形可视化,可选但推荐
npm install audiobuffer-to-wav # 用于PCM转WAV

项目结构大致如下:

src/
├── assets/
│   └── model/          # 存放 .onnx 模型文件
├── components/         # Vue 组件
│   ├── FileUploader.vue
│   ├── WaveformViewer.vue
│   └── Controls.vue
├── utils/             # 工具函数
│   ├── audioProcessor.js # 音频解码、重采样、分帧
│   └── modelRunner.js    # ONNX 模型加载与推理
├── App.vue
└── main.js

3.2 核心音频处理工具函数实现

utils/audioProcessor.js 中,我们需要实现几个关键函数。

1. 解码与重采样:

/**
 * 将File对象解码为指定采样率的AudioBuffer
 * @param {File} audioFile
 * @param {number} targetSampleRate 目标采样率,如44100
 * @returns {Promise<AudioBuffer>}
 */
export async function decodeAudioFile(audioFile, targetSampleRate) {
  const arrayBuffer = await audioFile.arrayBuffer();
  const audioContext = new (window.AudioContext || window.webkitAudioContext)();
  // 先按文件原采样率解码
  const originalBuffer = await audioContext.decodeAudioData(arrayBuffer);

  // 如果原采样率与目标一致,直接返回
  if (originalBuffer.sampleRate === targetSampleRate) {
    return originalBuffer;
  }

  // 否则进行离线重采样
  const offlineCtx = new OfflineAudioContext(
    originalBuffer.numberOfChannels,
    originalBuffer.duration * targetSampleRate,
    targetSampleRate
  );
  const source = offlineCtx.createBufferSource();
  source.buffer = originalBuffer;
  source.connect(offlineCtx.destination);
  source.start();
  return await offlineCtx.startRendering();
}

2. 音频分帧(重叠-相加法关键):

/**
 * 将AudioBuffer按固定长度分帧,带有重叠部分
 * @param {AudioBuffer} audioBuffer
 * @param {number} frameLengthSamples 帧长度(采样点数)
 * @param {number} hopLengthSamples 跳跃长度(采样点数),重叠部分 = frameLength - hopLength
 * @returns {Float32Array[]} 分帧后的数组(每个元素是一帧的PCM数据)
 */
export function frameAudio(audioBuffer, frameLengthSamples, hopLengthSamples) {
  const channels = audioBuffer.numberOfChannels;
  const totalSamples = audioBuffer.length;
  const frames = [];

  // 这里以单声道或取左声道为例,实际模型可能支持多声道输入
  const data = audioBuffer.getChannelData(0);

  for (let i = 0; i + frameLengthSamples <= totalSamples; i += hopLengthSamples) {
    const frame = data.slice(i, i + frameLengthSamples);
    // 如果帧长度不足,可以用零填充,这里简单处理为跳过最后一帧不完整的部分
    frames.push(new Float32Array(frame));
  }
  return frames;
}

3.3 ONNX 模型推理引擎封装

这是最核心的部分,在 utils/modelRunner.js 中。

import * as ort from ‘onnxruntime-web’;

export class VocalRemoverModel {
  constructor(modelPath) {
    this.modelPath = modelPath;
    this.session = null;
    this.isLoaded = false;
  }

  async loadModel() {
    try {
      // 注意:ONNX Runtime Web 需要正确配置 wasm 文件路径
      ort.env.wasm.wasmPaths = ‘/node_modules/onnxruntime-web/dist/’;
      // 加载模型,创建推理会话
      this.session = await ort.InferenceSession.create(this.modelPath);
      this.isLoaded = true;
      console.log(‘模型加载成功’);
    } catch (error) {
      console.error(‘模型加载失败:’, error);
      throw error;
    }
  }

  /**
   * 对单帧音频进行分离推理
   * @param {Float32Array} frameData - 一帧音频数据,形状为 [1, 1, frameLength]
   * @returns {Promise<{vocals: Float32Array, accompaniment: Float32Array}>}
   */
  async processFrame(frameData) {
    if (!this.isLoaded || !this.session) {
      throw new Error(‘模型未加载’);
    }

    // 1. 准备输入Tensor
    // 假设模型输入名为 ‘input’,形状为 [1, 1, N]
    const tensor = new ort.Tensor(‘float32’, frameData, [1, 1, frameData.length]);
    const feeds = { input: tensor };

    // 2. 运行推理
    const results = await this.session.run(feeds);

    // 3. 获取输出
    // 假设模型输出名为 ‘vocals’ 和 ‘accompaniment’
    const vocalsTensor = results.vocals;
    const accompTensor = results.accompaniment;

    // 4. 将Tensor数据转为Float32Array
    const vocalsData = vocalsTensor.data;
    const accompData = accompTensor.data;

    return {
      vocals: new Float32Array(vocalsData),
      accompaniment: new Float32Array(accompData)
    };
  }

  // 后续可以添加一个 `processFullAudio` 方法,来组织分帧、批量推理、重叠相加的整体流程
}

3.4 重叠-相加法重构完整音频

模型处理的是短帧,我们需要将它们无缝拼接回去。这就是 重叠-相加法

/**
 * 使用重叠-相加法将分帧处理后的结果重构为完整音频
 * @param {Float32Array[]} processedFrames - 处理后的帧数组
 * @param {number} hopLengthSamples - 跳跃长度
 * @param {number} totalSamples - 原始音频总采样点数
 * @returns {Float32Array} 重构后的完整PCM数据
 */
export function overlapAdd(processedFrames, hopLengthSamples, totalSamples) {
  const frameLength = processedFrames[0].length;
  const reconstructed = new Float32Array(totalSamples).fill(0);
  const window = hanningWindow(frameLength); // 应用汉宁窗减少边界效应

  for (let i = 0; i < processedFrames.length; i++) {
    const startIdx = i * hopLengthSamples;
    const frame = processedFrames[i];

    // 将窗函数应用到帧上
    const windowedFrame = frame.map((sample, idx) => sample * window[idx]);

    // 叠加到输出数组
    for (let j = 0; j < frameLength && startIdx + j < totalSamples; j++) {
      reconstructed[startIdx + j] += windowedFrame[j];
    }
  }
  return reconstructed;
}

// 汉宁窗函数
function hanningWindow(length) {
  const window = new Float32Array(length);
  for (let i = 0; i < length; i++) {
    window[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (length - 1)));
  }
  return window;
}

3.5 主应用逻辑与状态管理

App.vue 或一个专门的 Composition API 函数中,我们将所有模块串联起来。

<script setup>
import { ref, reactive } from ‘vue’;
import { decodeAudioFile, frameAudio, overlapAdd } from ‘./utils/audioProcessor’;
import { VocalRemoverModel } from ‘./utils/modelRunner’;
import { audioBufferToWav } from ‘audiobuffer-to-wav’;

const audioFile = ref(null);
const isProcessing = ref(false);
const progress = ref(0);
const resultAudioUrl = ref(‘’);

const model = new VocalRemoverModel(‘/src/assets/model/vocal_remover.onnx’);

// 初始化加载模型
onMounted(async () => {
  try {
    await model.loadModel();
  } catch (e) {
    alert(‘模型加载失败,请刷新页面重试’);
  }
});

const handleFileUpload = async (event) => {
  const file = event.target.files[0];
  if (!file || !file.type.includes(‘audio’)) return;

  audioFile.value = file;
  isProcessing.value = true;
  progress.value = 0;

  try {
    // 1. 解码并重采样到模型需要的采样率(例如44100)
    const TARGET_SR = 44100;
    const audioBuffer = await decodeAudioFile(file, TARGET_SR);

    // 2. 分帧参数设置:例如,模型处理2.5秒的帧,跳跃1.5秒(重叠1秒)
    const FRAME_LENGTH = TARGET_SR * 2.5; // 2.5秒
    const HOP_LENGTH = TARGET_SR * 1.5;   // 1.5秒
    const frames = frameAudio(audioBuffer, FRAME_LENGTH, HOP_LENGTH);

    // 3. 逐帧处理
    const processedVocalsFrames = [];
    const processedAccompFrames = [];

    for (let i = 0; i < frames.length; i++) {
      const frame = frames[i];
      const result = await model.processFrame(frame);
      processedVocalsFrames.push(result.vocals);
      processedAccompFrames.push(result.accompaniment);

      // 更新进度
      progress.value = Math.round(((i + 1) / frames.length) * 100);
    }

    // 4. 重叠相加,重构完整音频
    const vocalsPCM = overlapAdd(processedVocalsFrames, HOP_LENGTH, audioBuffer.length);
    const accompPCM = overlapAdd(processedAccompFrames, HOP_LENGTH, audioBuffer.length);

    // 5. 将PCM转为WAV并生成URL
    const wavBuffer = audioBufferToWav({
      sampleRate: TARGET_SR,
      channelData: [vocalsPCM] // 这里以人声轨道为例
    }, { float32: true });
    const blob = new Blob([wavBuffer], { type: ‘audio/wav’ });
    resultAudioUrl.value = URL.createObjectURL(blob);

  } catch (error) {
    console.error(‘处理失败:’, error);
    alert(‘音频处理过程中发生错误: ‘ + error.message);
  } finally {
    isProcessing.value = false;
  }
};
</script>

4. 性能优化与用户体验打磨

4.1 内存与计算优化策略

在浏览器中处理音频,尤其是长音频,内存和计算优化是重中之重。

  1. 流式处理与 Worker :对于超过3分钟的歌曲,一次性加载所有帧到内存进行推理可能导致标签页崩溃。解决方案是使用 Web Worker 进行后台处理,并采用流式(分块)处理逻辑。将音频分成更大的块(例如每30秒一块),逐块进行解码、分帧、推理、重构。这样能保持较低的内存占用和更平滑的UI响应。
  2. 模型量化与缓存 :如前所述,务必使用量化后的 INT8 模型。首次加载模型后,可以将模型数据缓存到 IndexedDB 中,下次访问时直接从本地加载,极大缩短启动时间。
  3. 推理批处理 :ONNX Runtime 支持批量推理。如果硬件允许,可以尝试将2-4帧数据组合成一个批次进行推理,这通常能利用并行计算提升整体吞吐量。但需要平衡延迟和内存消耗。
  4. 动态精度调整 :可以为用户提供“速度优先”和“质量优先”选项。“速度优先”模式下,可以降低重采样的精度,或者使用更小的跳跃长度(减少帧数,但可能影响质量)。

4.2 实时预览与渐进式输出

为了提升用户体验,可以实现“边处理边播放”的预览功能。

  • 在处理完一定数量的帧(例如前10%)后,就可以将已处理部分重构出来,生成一个临时的音频 Blob URL,供用户试听初步效果。这能让用户提前感知处理方向是否正确,无需等待全部完成。
  • 结合 WaveSurfer.js 等库,可以实时绘制出分离后人声和伴奏的波形图,提供视觉反馈。

4.3 处理效果的后处理增强

原始模型输出的音频可能在某些频段有残留或失真,我们可以加入一些可选的后期处理步骤作为“甜点”功能:

  • 均衡器调整 :提供一个简单的图形均衡器,让用户可以手动削弱某些疑似残留人声的频段(通常是中频,1kHz-4kHz),或提升伴奏的力度。
  • 噪声门限 :对人声轨道应用一个轻微的噪声门,将非常微弱的残留信号视为噪声滤除。
  • 混响衰减 :有些模型分离后,伴奏中可能残留人声的混响。可以尝试使用专门的门限或算法来衰减这种固定的混响尾音。

5. 常见问题、排查与实战心得

5.1 模型推理出错或输出异常

  • 问题 session.run() 抛出错误,提示输入维度不匹配。

  • 排查

    1. 确认模型预期的输入形状。使用 Netron 等工具打开 .onnx 模型文件,查看输入节点 input 的维度,通常是 [batch, channel, length] 。确保你传入的 Tensor 形状完全一致。
    2. 检查音频预处理。模型通常要求输入是归一化到 [-1, 1] 的浮点数。确认你的 PCM 数据是否在此范围。
    3. 检查采样率和帧长。模型是在特定采样率(如 44.1kHz)和固定长度(如 110250 个采样点,即2.5秒)上训练的。你的预处理必须严格匹配。
  • 问题 :分离结果有严重的“嗡嗡”声或金属音。

  • 排查

    1. 重叠不足 :这是最常见原因。如果跳跃长度 ( hop_length ) 太大,导致帧间重叠部分太少,重叠相加后就会产生相位抵消问题,引入 artifacts。尝试减少 hop_length ,增加重叠区域(例如从50%重叠增加到75%)。
    2. 未应用窗函数 :在重叠相加时,必须对每一帧应用窗函数(如汉宁窗),以实现帧间的平滑过渡。忘记加窗会导致接缝处产生爆音。
    3. 模型不匹配 :你使用的模型可能不适合当前歌曲的风格(如古典、重金属)。可以尝试换一个更通用的模型,或针对该风格训练的专用模型。

5.2 性能瓶颈分析与优化

  • 现象 :处理速度非常慢,进度条卡顿。
  • 排查与优化
    1. 使用性能分析器 :打开浏览器开发者工具的 Performance 面板,录制处理过程。查看是 JavaScript 执行时间长,还是 Wasm 计算耗时,或者是频繁的 GC(垃圾回收)。通常瓶颈在模型推理。
    2. 启用 WebAssembly SIMD :ONNX Runtime Web 支持 SIMD(单指令多数据)指令集,能大幅加速线性代数运算。确保你的部署环境支持并启用了 SIMD。在 Chrome 中,可以在 chrome://flags 中搜索 “WebAssembly SIMD” 并启用。
    3. 减少主线程阻塞 :将分帧、重叠相加等 CPU 密集型计算也放到 Web Worker 中,保持主线程响应,避免页面“假死”。
    4. 检查内存泄漏 :在每次处理完成后,手动将大的数组(如原始的 AudioBuffer 、所有帧数组)引用设为 null ,并调用 URL.revokeObjectURL() 释放生成的音频 URL,触发垃圾回收。

5.3 实战心得与技巧

  1. “干声”与“湿声”的处理差异 :对于混响很大的人声(湿声),分离难度剧增。效果好的模型(如某些 MDX-Net 变体)通常在这方面表现更好。在 UI 上可以提示用户“干声较多的流行歌曲分离效果更佳”。
  2. 立体声处理 :上述示例简化为了单声道处理。高质量分离应处理立体声。通常有两种策略:一是将左右声道分别送入模型,然后合并;二是使用支持双声道输入的模型。前者计算量翻倍,但更通用。
  3. 模型融合 :一种进阶技巧是使用两个不同的模型对同一音频进行处理,然后对结果进行加权平均或选择性地融合(例如,在频域进行融合)。这有时能结合不同模型的优点,得到更干净的结果。但这会显著增加计算成本。
  4. 提供“强度”调节 :不是简单的滑块,而是可以调节模型输出的人声和伴奏轨道的混合比例。有时用户想要的是“人声减弱”而不是“完全消除”,一个混合滑块能提供更大的灵活性。
  5. 离线使用的可能性 :得益于 Service Worker 和 Cache API,你可以将这个应用打造成一个 PWA ,允许用户安装到桌面,并在没有网络连接的情况下使用,因为模型和核心代码都已缓存。

构建一个浏览器端的 AI 人声消除器,是一次将前沿 AI 研究与现代 Web 能力相结合的绝佳实践。它涉及了音频信号处理、机器学习部署、前端性能优化和用户体验设计等多个层面。从选择一个合适的轻量级模型开始,一步步搭建起完整的处理流水线,过程中遇到的每一个坑——从内存泄漏到重叠相加的 artifacts——都会让你对“浏览器作为计算平台”的潜力有更深的理解。最终,当你看到用户上传一首歌,几分钟后就能下载到清晰的伴奏时,那种成就感是实实在在的。这个项目不仅是一个工具,更是一个展示 Web 技术前沿性的绝佳案例。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐