普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月16日首页

媒体采集标准草案 与 Chromium 音频采集实现简介

2026年1月16日 10:55
  • 简要介绍 W3C Media Capture and Streams 草案中采集部分的约定
  • 粗略介绍 Chromium 的多进程架构以及音频采集功能的实现

Media Capture and Streams

什么是 Media Capture and Streams TR

Media Capture and Streams 是由 W3C WebRTC Working Group 提出的规范草案,主要定义了获取本地媒体的 JavaScript API。目前该草案处于 CRD (Candidate Recommend Draft) 状态,也可以被称为技术报告(Technical Report, TR)

www.w3.org/TR/mediacap…

虽然 W3C 强调,W3C CRD 不能(MUST NOT)以 W3C 标准的名义引用。并且,W3C CRD 可能成为 W3C 标准,也可能不会成为 W3C 标准。但截至目前,主流浏览器都参考了这份草案进行了实现。各位 Web 开发者大可放心使用相关 API 开发自己的网页应用。

草案包含的 API

这份草案包含了两大类 API:MediaStream、MediaDevices。

  • MediaStream: 主要包含 MediaStream、MediaStreamTrack 两大类。

    • MediaStreamTrack: 从一个媒体源获取的、单一类型的媒体。(例如:来自摄像头的视频)
    • MediaStream: 由多个 MediaStreamTrack 构成的一个单元,可以被录制或者在 Media Element 上渲染。
  • MediaDevices: 扩展自 Navigator 接口,主要包含枚举媒体设备、获取媒体流两类。

    • enumerateDevices: 收集浏览器可用的媒体输入设备和媒体输出设备。
    • getUserMedia: 向用户申请权限,获取用户的摄像头或者其他的音视频输入信息。

怎样使用这些 API

下面的实例代码简单展示了如何采集摄像头流,并将流渲染到媒体元素上。

const startBtn = document.getElementById("startBtn"); // <button>
const videoPlayer = document.getElementById("videoPlayer"); // <video>

async function captureCamera() {
  try {
    // 约束
    const constraints = {
      video: {
        width: 640,
        height: 480,
      },
      audio: true,
    };

    // 采集
    const stream = await navigator.mediaDevices.getUserMedia(constraints);

    // 播放
    videoPlayer.srcObject = stream;
    videoPlayer.play();
  } catch (err) {
    console.error(err);
  }
}

startBtn.addEventListener("click", captureCamera);

上述代码中包含了三个重要的步骤:

  1. 约束:向浏览器提供所需要的媒体流的信息。

    1. 每种媒体都可以指定其约束
    2. 只设置为 true 表示需要这个媒体流,但没有任何限制

JS:浏览器,请给我一个分辨率 640x480 的视频流,再来一个音频流。哪个设备的都可以。

  1. 采集:将约束传入 getUserMedia 接口。该接口将以 Promise 形式返回一个 MediaStream。

[等了一会]

浏览器:好嘞,你要的两个流。打包放一起了。

  1. 播放:该草案中也提供了在 HTMLMediaElement 上渲染这些流的接口。将 MediaElement 的 srcObject 设置为想要播放的流。然后调用 play 即可。

主要注意的是,传给 getUserMedia 接口的参数被称作“约束”。这是因为该接口最终返回的流可能与约束不一致。这就涉及到设置、能力与约束的区别了。

设置、能力与约束

Setting 设置

设置表示一个媒体源当前所应用的参数。显然,这个参数需要是只读的。

这里以上文采集到的音视频轨道为例,分别调用 getSettings() 接口。可以发现接口返回了如下内容。

// audio
{
    autoGainControl: true,
    channelCount: 1,
    deviceId: "cc0e809ba21cc1e8a1403b835d13ea3a4e8541f438d0b31989294cace5f43ec2",
    echoCancellation: true,
    groupId: "075cbccd30ba3337cf5d990a77525475153112df8066833c3213706ceeab2b42",
    latency: 0.01,
    noiseSuppression: true,
    sampleRate: 48000,
    sampleSize: 16,
    voiceIsolation: false
}
// video
{
    aspectRatio: 1.3333333333333333,
    deviceId: "d1216494e164337d39248c6d9610a0e7038ec774998f758665270dcaaae29093",
    frameRate: 30,
    groupId: "f9b73b0dac3ff31397c5bb01df58e1256aa1acc930d2634f7d090b24da06a513",
    height: 480,
    resizeMode: "none",
    width: 640
}

这两个对象详细描述了音视频源正在应用的参数,也可以看到对于视频的分辨率约束也生效了。

一些常用的字段含义如下所示,不做赘述。

音频

  • deviceId:设备 ID
  • channelCount: 声道数
  • autoGainControl:自动增益控制
  • echoCancellation:回声消除
  • noiseSuppression:噪声抑制

视频

  • deviceId: 设备 ID
  • width:画面宽度
  • height:画面高度
  • frameRate:帧率

Capability 能力

能力是指媒体源支持哪些参数,每个参数的范围如何。

例如:一个摄像头可能支持 640x480@60, 1920x1080@30 等多种分辨率、帧率组合。一个麦克风可能支持 48000Hz 采样率,也支持 44100Hz 采样率。

然而通过上面两个例子,就可以发现完整列举设备能力是几乎不可能的。

  1. 参数种类很多,每个参数又有不同的取值范围。使用约束列表进行描述,将获得非常多的组合,无法完整列举。
  2. 过于详细的能力集合很容易用来作为设备指纹。

因此 getCapability() 只能获取已经采集到的流所对应的媒体源的参数。

还是以上面的采集到的音视频轨道为例。分别调用 getCapability() 接口,可以看到这两个设备的能力列表。

// audio
{
    autoGainControl: [true, false],
    channelCount: {max: 1, min: 1},
    deviceId: "cc0e809ba21cc1e8a1403b835d13ea3a4e8541f438d0b31989294cace5f43ec2",
    echoCancellation:  [true, false],
    groupId: "6190ef675de1872a2190edd82ee43c58899d1d6b0c6a613113de98cf3650c1c8",
    latency: {max: 0.085333, min: 0.002666},
    noiseSuppression:  [true, false],
    sampleRate: {max: 48000, min: 48000},
    sampleSize: {max: 16, min: 16},
    voiceIsolation:  [true, false],
}
// video
{
    aspectRatio: {max: 1920, min: 0.0005208333333333333},
    deviceId: "d1216494e164337d39248c6d9610a0e7038ec774998f758665270dcaaae29093",
    facingMode: [],
    frameRate: {max: 30, min: 0},
    groupId: "ebfbdd93ec4d8fc55a6e98ba59961767621761d27110e22c64a2033d26e19246",
    height: {max: 1920, min: 1},
    resizeMode: ['none', 'crop-and-scale'],
    width: {max: 1920, min: 1}
}

可以发现,能力有以下几种描述形式。

  • 列表:表示可以使用列表中的其中一项。
  • 最大值与最小值:表示可以使用这个范围内的值。

Constraints 约束

约束这个概念比较抽象。可以理解为这是一种描述网页应用所需媒体流的特征的接口。向媒体源提供约束,将影响媒体源如何提供尽可能符合要求的媒体流。

正如前面的例子,网页应用的需求是一个宽 640px、高 480px 的视频流。这个需求是约束。媒体源在应用这个约束时,会根据媒体源的能力选择最接近的设置,生成媒体流。

除了上文约束可以通过单一值来设置约束以外,还可以有如下形式的约束。

  • {max, mix}:表示一个范围。

    • 例如:width: {max: 1080, min: 720}
  • {exact}:表示精确值,不满足约束时报错。

    • 例如:deviceId: {exact: 'd1216494'}
  • {ideal}:表示理想值,不满足时浏览器可能会应用其他接近的设置。

    • 例如:deviceId: {ideal: 'd1216494'}
    •   此外,还支持进阶约束(advanced constraint)。这些约束的优先级将小于普通约束。

那么浏览器应该如何实现这个 通过约束获得设置 的算法呢?这份草案中定义了 SelectSetting 算法。

SelectSetting 算法

SelectSetting 算法的目标就是在多个候选设置中,选出一个符合约束的设置。当然,如果没办法满足要求的话需要报错。

SelectSetting算法的输入分别是一组候选设置 Candidates 和一个约束 ConstraintSet (CS)。

  1. 选择 CS 中的普通约束,与所有候选设置进行匹配。如果匹配失败直接返回空值。
  2. 遍历 CS 中的进阶约束组,如果匹配失败则删除这个约束。
  3. 选择最终保留下来的候选设置,这个候选设置必须(MUST)是匹配距离最小的候选设置之一。

W3C 草案也定义了计算匹配距离(fitness distance)的算法,具体如下图。

算法的输入是一个候选设置 Setting 与一个约束 CS。

  1. 遍历约束中的约束名称(ConstraintName, name)与约束值(ConstraintValue, value)。
  2. 对每一个 name,做图中的各种判断,计算出每个 name 对应的分数。
  3. 最终求和得到最终分数。

W3C 草案中的 getUserMedia 方法

结合上文我们了解到,getUserMedia 需要处理约束和能力的匹配,生成一套符合要求的设置,根据设置生成媒体流。因此 getUserMedia 方法的核心为上文提到的匹配约束的算法。

但 W3C 草案考虑到隐私与安全问题,定义了多种需要抛出错误的情况,详情可以参考下图。主要包含以下几个特点:

  1. 采集流程需要得到用户的授权。当没有被拒绝时,会在完成约束匹配后再弹窗申请权限。而在被拒绝时会直接跳过约束匹配流程。
  2. 采集流程需要页面处于 active / in view / focued 等活跃状态。从而限制了恶意代码在未经用户同意时静默打开摄像头或者麦克风。

Chromium 多进程架构

以下内容来自个人阅读 Chromium 文档和代码的总结,可能存在错误和缺漏,欢迎斧正。

在上文中介绍了 W3C Media Capture and Streams 草案的一些重要概念。也介绍了草案中对 getUserMedia 方法的描述。理想情况下,所有浏览器都应该严格遵守这个标准草案。但在实践中,部分浏览器可能处于多方权衡,总会与这份草案存在一定的偏差。本文以 Chrome 的开源项目:Chromium 为例,简单了解浏览器是如何实现 getUserMedia 接口的。

在阅读源码之前,需要对 Chromium 项目有一些整体认知。这些知识有助于降低阅读难度。

多进程架构

正如现代操作系统一样,使用多个进程将应用分离,从而提高健壮性。Chromium 架构的目标也是为了这种更健壮的设计。

Chromium 使用多进程结构,主要有以下两个好处。

  1. 可以防止单个进程的意外错误破坏整个程序。
  2. 可以隔离单个进程,控制其访问范围。

  • Browser 进程:也被视为主进程。Browser 进程负责渲染 UI、管理其他 renderer 进程、接管 renderer 进程中对于操作系统的调用等功能。
  • Renderer 进程:用来处理网页内容,这部分通常包含 Blink、V8 等引擎。

此外,随着浏览器复杂程度的提高,browser 进程的所负责的工作将会愈发臃肿。因此当前很多工作从 browser 进程中分离,以 service 的形式提供给 browser 进程或 renderer 进程。主要的 service 都列举在最顶层的 //services 目录下。

  • audio: 主要用于处理音频采集相关功能
  • video_capture: 主要用于处理视频采集相关功能
  • webnn: 用于实现 Web Neural Network API 的服务。主要通过调用操作系统的硬件加速机器学习 API 来实现对应功能。

跨进程通信 - Mojo

Mojo 是一个跨平台的 IPC 框架,它诞生于 chromium ,用来实现 chromium 进程内/进程间的通信。主要提供三个 IPC 通信机制:MessagePipe,DataPipe 和 SharedBuffer。

  • MessagePipe: 用于进程间的双向通信。底层使用 Channel 通道。
  • DataPipe: 用于进程间的单向 数据块 传递。底层使用操作系统提供的 Shared Memory。
  • SharedBuffer: 用户进程间的双向数据块传递。底层也是 Shared Memory。

Mojo 的在使用中的最大特点是提供一套方便的绑定接口。可以通过创建 .mojom 文件完成接口定义,由 BUILD.gn 中增加对应的编译代码就可以生成源代码文件。(这些源代码文件也是使 Chromium 源码跳转较为困难的原因)。

跨线程通信 - 任务队列

Chromium 在代码中大量使用任务队列来提高并发能力,但在不同场景中对于任务队列的要求各有不同。Chromium 在这里设计了多种任务队列满足不同需求。

  • TaskRunner:普通的任务队列。使用线程池消费任务,不保证任务执行顺序。
  • SequencedTaskRunner: 额外保证任务执行顺序的队列。遵循先入先出原则,但不保证任务在同一个线程中执行。
  • SingleThreadTaskRunner: 额外保证在同一线程执行的有序队列。

如何获取、阅读 Chromium 源码

如何获取并编译 Chromium

Chromium 开源项目提供的非常完整的工具链和文档,如果你的网络情况较好、存储空间较充足、设备性能较好,可以尝试自己编译 Chromium。只需要参考官网文档即可。

www.chromium.org/developers/…

主要注意的是,Chromium 完整 git 历史+ 源码 + 一次全量编译的产物大约 200 GB。如果想减少对存储空间的占用,可以考虑 shallow fetch 等方式减少 git 历史文件的大小。

如何阅读

在线阅读

如果你是否有下载源码,都推荐选择在线阅读 Chromium 代码。在线版本 Chromium 代码不仅加载非常快,而且还有非常完善的引用调用查找功能。除了没办法调整字体外还是非常实用的。

source.chromium.org/chromium/ch…

如果你执念想使用本地代码阅读,那么你还要至少进行以下几个步骤才能愉快地开始符号跳转。

如果你使用 Visual Studio Code

chromium.googlesource.com/chromium/sr…

使用 VSCode 的话,需要依赖于 clangd 插件。主要分为以下几个步骤。

  1. 完整编译 Chromium
  2. 生成 compile_commands.json 文件。其中 out/Default 为编译产物所在位置。
tools/clang/scripts/generate_compdb.py -p out/Default > compile_commands.json

3. 开始索引。后台索引通常是自动开始的。索引耗时与设备性能高度相关,可能在 2 小时(MacBook Pro, M2 pro)至 7 小时(MacBook Air, M2)不等。

clangd 有时会因为单一文件出现大量报错,而停止分析。可以通过配置 .clangd 文件提高错误数量上线,从而得到更多文件分析结果。

CompileFlags:
  Add: -ferror-limit=100

如果你使用 JetBrains CLion

chromium.googlesource.com/chromium/sr…

CLion 依赖于 CMake 进行语法分析,所以需要正确配置 CMakeLists.txt 。CMakeLists.txt 文件需要与 src 文件夹在同一目录。文件内容如下所示。

.
├── CMakeLists.txt
└── src
cmake_minimum_required(VERSION 3.10)
project(chromium)

set(CMAKE_CXX_STANDARD 14)

include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/out/Default/gen)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/protobuf/src)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/googletest/src/googletest/include)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/googletest/src/googlemock/include)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/abseil-cpp)

# The following file used is irrelevant, it's only to improve CLion
# performance. Leaving at least 1 file is required in order for CLion
# to provide code completion, navigation, etc.
add_executable(chromium src/components/omnibox/browser/document_provider.cc)

如何获取日志

currentTime=`date "+%Y%m%d_%H%M%S"`
filePath=${HOME}/chrome_debug_${currentTime}.log
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --enable-logging --v=1 --log-file=${filePath} &
sleep 1
open -a Console ${filePath}

以上方法可以在 macOS 上获取日志,日志存放于用户目录下。通常正式发布版本的 Chrome 只能获取 VERBOSE1 级别的日志,而且不包含代码中的各项断言检查。

Chromium 中的 getUserMedia

Chromium getUserMedia 音频流程图

Chromium 的麦克风音频采集主要包含获取设备能力、检查设备约束、创建 Stream、创建 Track、启动 Track 这几个步骤。至少涉及到三个进程间的通信,包含至少四个模块。

  • renderer 进程:主要通过 UserMediaProcessor 对象流转 UserMediaRequest 中的状态,主要实现各种检查功能。
  • browser 进程:主要通过 MediaStreamManager 对象管理所有媒体流。
  • audio 进程:主要作为操作系统 API 的适配层,调用操作系统提供的音频接口。

主要模块的流转

UserMediaProcessor

位于 third_party/blink/renderer/modules/mediastream/user_media_processor.cc

该模块位于 renderer 进程中,主要用于处理 getUserMedia 请求。

  • 每次只能创建一个 MediaStream
  • 在各个函数中流转 UserMediaRequest 对象,以队列方式处理。

Chromium 中的 SelectSetting 算法

位于 third_party/blink/renderer/modules/mediastream/media_stream_constraints_util_audio.cc

可以发现这段代码基本符合 W3C 草案中的 SelectSetting 算法。包含先处理基本约束(绿色区域),再处理进阶约束(橙色区域)。但比较显著的是,Chromium 忽略了不同 setting 的匹配距离部分(紫色区域)。

    AudioCaptureSettings SelectSettingsAudioCapture(
        const AudioDeviceCaptureCapabilities& capabilities,
        const MediaConstraints& constraints,
        mojom::blink::MediaStreamType stream_type,
        bool should_disable_hardware_noise_suppression,
        bool is_reconfiguration_allowed) {
      if (capabilities.empty())
        return AudioCaptureSettings();

      std::string media_stream_source = GetMediaStreamSource(constraints);
      std::string default_device_id;
      bool is_device_capture = media_stream_source.empty();
      if (is_device_capture)
        default_device_id = capabilities.begin()->DeviceID().Utf8();

      CandidatesContainer candidates(capabilities, stream_type, media_stream_source,
                                     default_device_id, is_reconfiguration_allowed);
      DCHECK(!candidates.IsEmpty());

      auto* failed_constraint_name =
          candidates.ApplyConstraintSet(constraints.Basic());
      if (failed_constraint_name)
        return AudioCaptureSettings(failed_constraint_name);

      for (const auto& advanced_set : constraints.Advanced()) {
        CandidatesContainer copy = candidates;
        failed_constraint_name = candidates.ApplyConstraintSet(advanced_set);
        if (failed_constraint_name)
          candidates = std::move(copy);
      }
      DCHECK(!candidates.IsEmpty());

      // Score is ignored as it is no longer needed.
      AudioCaptureSettings settings;
      std::tie(std::ignore, settings) = candidates.SelectSettingsAndScore(
          constraints.Basic(),
          media_stream_source == blink::kMediaStreamSourceDesktop,
          should_disable_hardware_noise_suppression);

      return settings;
    }

观察 ApplyConstraintSet 函数,这里基本上对应 W3C 草案中的 fitness_distance 的部分。可以发现其实 Chromium 完全没有参考草案中的流程。代码将约束分为四类:deviceId、groupId、number 与 boolean、processing_based。其中 processing_based 表示需要使用 WebRTC 模块提供的音频处理功能的约束。

这种计算方式显然也没办法得出一个分数,因此在 SelectSettingsAudioCapture 函数的注释中,也明显表示因为不再需要 score,所以将 score 忽略。

    // class DeviceContainer
      const char* ApplyConstraintSet(const ConstraintSet& constraint_set) {
        const char* failed_constraint_name;

        failed_constraint_name =
            device_id_container_.ApplyConstraintSet(constraint_set.device_id);
        if (failed_constraint_name)
          return failed_constraint_name;

        failed_constraint_name =
            group_id_container_.ApplyConstraintSet(constraint_set.group_id);
        if (failed_constraint_name)
          return failed_constraint_name;

        for (size_t i = 0; i < kNumBooleanContainerIds; ++i) {
          auto& info = kBooleanPropertyContainerInfoMap[i];
          failed_constraint_name =
              boolean_containers_[info.index].ApplyConstraintSet(
                  constraint_set.*(info.constraint_member));
          if (failed_constraint_name)
            return failed_constraint_name;
        }

        // For each processing based container, apply the constraints and only fail
        // if all of them failed.
        for (auto it = processing_based_containers_.begin();
             it != processing_based_containers_.end();) {
          DCHECK(!it->IsEmpty());
          failed_constraint_name = it->ApplyConstraintSet(constraint_set);
          if (failed_constraint_name)
            it = processing_based_containers_.erase(it);
          else
            ++it;
        }
        if (processing_based_containers_.empty()) {
          DCHECK_NE(failed_constraint_name, nullptr);
          return failed_constraint_name;
        }

        return nullptr;
      }

MediaStreamManager

位于 content/browser/renderer_host/media/media_stream_manager.cc

该模块位于 browser 进程中,主要用于创建和关闭媒体设备,管理 MediaStream。

  • 在各个函数中流转 DeviceRequest 对象

使用这些 API 可以做到什么

Media Capture and Streams API 主要支持了采集用户音视频流的功能。结合其他 Web API 可以实现多种功能。

  • 结合 WebRTC API,可以将摄像头流和音频流发送其他用户,实现实时会议等功能。
  • 结合 WebAudio API,可以将音频流进行处理,实现各种混音效果。

下面以拍照功能为例,简单介绍上述 API 的使用方法。

预览视频流

这部分与上文相同,需要执行三个步骤。设置约束、采集、播放。可以直接沿用上文提供的代码。

async function previewCamera() {
  const videoPlayer = document.getElementById("videoPlayer"); // <video>
  if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
    return;
  }
  try {
    // 约束
    const constraints = {
      video: {width: 640, height: 480},
    };
    // 采集
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    // 播放
    videoPlayer.srcObject = stream;
    videoPlayer.play();
  } catch (err) {
    console.error(err);
  }
}

获取可用设备

此处可以使用枚举用户设备接口实现。首先我们需要调用这个接口,获取设备信息列表。

const devices = await navigator.mediaDevices.enumerateDevices();

设备信息列表中包含设备名称、设备 ID 等信息。设备名称可以用来作为用户展示,设备 ID 则需要传给约束更新视频流。

async function reloadDevices() {
  const selector = document.getElementById("device-select");
  if (!selector || !(selector instanceof HTMLSelectElement)) {
    return;
  }
  selector.innerHTML = "";
  // 获取设备列表
  const devices = await navigator.mediaDevices.enumerateDevices();
  // 创建选项
  devices.forEach((device) => {
    if (device.kind === "videoinput") {
      const option = document.createElement("option");
      option.value = device.deviceId;
      option.text = device.label || `Camera ${selector.length + 1}`;
      selector.appendChild(option);
    }
  });
}
async function previewCamera(deviceId) {
  const videoPlayer = document.getElementById("videoPlayer"); // <video>
  if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
    return;
  }
  // 清除正在预览的流
  const stream = videoPlayer.srcObject;
  if (stream instanceof MediaStream) {
    stream?.getTracks().forEach((track) => {
      track.stop();
    });
  }
  
  try {
    // 约束
    const constraints = {
      video: {width: 640, height: 480},
    };
    if (deviceId) {
      constraints.video.deviceId = { exact: deviceId };
    }
    // 采集
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    // 播放
    videoPlayer.srcObject = stream;
    videoPlayer.play();
  } catch (err) {
    console.error(err);
  }
}

拍照

拍照的部分可以使用 Canvas API 的 drawImage 接口。具体如下:

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
async function capture() {
  const videoPlayer = document.getElementById("videoPlayer"); // <video>
  if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
    return;
  }
  canvas.width = videoPlayer.videoWidth;
  canvas.height = videoPlayer.videoHeight;
  if (!ctx) {
    throw Error('Canvas 2d Context is not supported');
  }
  ctx.drawImage(videoPlayer, 0, 0, canvas.width, canvas.height);
  const img = document.createElement("img");
  img.src = canvas.toDataURL("image/png");
  const photoView = document.querySelector(".photo-view");
  if (photoView) {
    photoView.appendChild(img);
  }
}

总结

  1. W3C 标准通常从易用性、安全性上进行考量。很多功能都会尝试避免被用来收集设备指纹。
  2. Chromium 开源项目提供了非常完善的工具链。除去网络因素,通常很快就可以开始编译 Chromium。
  3. Chromium 开源项目中包含大量优秀的设计,非常值得参考和学习。但同时,作为历史较为悠久的开源项目,代码的组织结构和实现不可避免的存在一些瑕疵。通常项目成员也会认真对待各种类型的提交,所以也欢迎各位尝试参与进 Chromium 开源项目中来。

一些注释

  1. W3C 草案中通常使用用户代理(User Agent)这个概念。为了和 navigator.userAgent 进行区分,本文统一翻译为 “浏览器” 。但实际上浏览器只是一种用户代理,下载管理器、爬虫等也可以称为是用户代理。

参考文献与资料

  1. W3C Media Capture and Streams 草案 www.w3.org/TR/mediacap…
  2. W3C 组织关于 TR 的要求 www.w3.org/standards/t…
  3. Chromium 多进程架构 www.chromium.org/developers/…
  4. 一篇非常详细介绍 mojo 的原理和使用方式的文章 keyou.github.io/blog/2020/0…
  5. MDN 关于用户代理概念的解释 developer.mozilla.org/en-US/docs/…
昨天以前首页

播放器音频后处理实践(一)

作者 百度Geek说
2025年8月5日 10:52

一. 前言

丨1. 行业背景

在现代播放器架构中,音频后处理已不仅是锦上添花的功能,而是构建差异化听觉体验的关键组件。尤其在多样化的播放场景(手机外放、耳机、电视音响等)下,通过定制化的音效增强手段,有效提升听感表现已成为基础能力之一。

丨2. 本文概览

本系列文章将系统介绍我们在播放器音频后处理模块中的技术方案与工程实现,主要面向音视频方向的开发者。我们主要基于 FFmpeg的音频滤镜框架,结合自定义模块,构建了一套可扩展、高性能、易适配的音效处理链路。

第一期内容聚焦在两项核心基础音效:

  • 重低音:通过构建低通滤波器与动态增益控制逻辑,增强低频段表现,适配小型设备下的听感优化

  • 清晰人声:结合频段增强、人声掩码与背景音抑制技术,有效提升对白清晰度,在嘈杂或背景音复杂的场景下保持语音主干突出

我们将分享上述音效的整体处理流程、关键滤镜链搭建方式、滤波器设计细节,以及如何在保证延迟与功耗可控的前提下,通过 FFmpeg 的 af(audio filter)机制灵活插拔各类处理节点。

希望本系列文章能为你提供实用的技术参考,也欢迎有 FFmpeg 或音效处理相关实践经验的开发者交流碰撞,共同推动播放器音频后处理技术的深入发展。

二. 走进音频后处理

丨1. 技术定义和核心目标

对于视听消费场景,音频后处理指在音频信号完成解码(PCM/DSD数据生成)后,通过数字信号处理技术对原始音频进行音质增强、效果添加或缺陷修正的过程。核心目标包括:

图片

丨2. 关键技术分类

音频后处理技术主要可分为以下四大类,每类技术的特点和应用如下:

图片

三. 播放内核音频后处理框架

图片

丨1. 整体架构

音频后处理模块在播放pipeline中的位置如上图所示,位于音频解码模块和音频帧队列之间。模块是否被链接于pipeline中是业务可选的,出于性能考虑,只有真正需要用到音频后处理功能的播放任务才会去链接音频后处理模块。


丨2. 音效支持

目前,播放内核已开放了包括:音量增强、清晰人声、重低音、立体环绕、降噪等多种音效,支持业务在播放前以及播放中打开、关闭或变更任意音效。根据业务的设置,音频后处理模块承担了音效滤镜的初始化、滤镜链组装、滤镜链变更等任务。该模块目前不仅提供了FFmpeg原生滤镜的支持,而且新增了内核自研的热门音效滤镜。

四. 落地音效一:重低音

丨1. 重低音

  • 重低音效果通常指的是音响系统或音频设备中低频段的增强效果,特别是那些频率在20Hz到250Hz之间的声音。这种效果使得声音中的低频成分(如鼓声、贝斯声)更加突出和有力,从而带来一种震撼和沉浸式的听觉体验

  • 重低音效果通常应用在音乐、电影和游戏中,用以增强音效的冲击力。例如,在观看动作电影时,重低音效果能够使爆炸或撞击声更加震撼,而在听音乐时,它能让鼓点和贝斯的节奏感更强烈。一般来说,这种效果是通过音响设备中的低音炮或音频处理器来实现的,它们能够放大并优化低频声音,使得音效更加浑厚、深沉和有力量

  • 音频的频率范围大致可以划分为:

  • 次低音 (Sub-bass): 20 Hz - 60 Hz

     包括极低频率的声音,如低音炮的低频段,能够带来深沉的震撼感

  • 低音 (Bass): 60 Hz - 250 Hz

     涵盖常规低音部分,如贝斯和低音鼓,带来厚重感和力量感

  • 低中音 (Low midrange): 250 Hz - 500 Hz

     涉及一些低频乐器和男声的下半部分,增加声音的温暖感

  • 中音 (Midrange): 500 Hz - 2 kHz

     包含大部分人声和主要乐器的频段,是声音的核心部分

  • 高中音 (Upper midrange): 2 kHz - 4 kHz

     涵盖高频乐器、人声的高音部分,决定了声音的清晰度和穿透力

  • 高音 (Treble): 4 kHz - 6 kHz

     提供声音的亮度和细节,涉及高频乐器和背景环境声

  • 极高音 (Brilliance or Presence): 6 kHz - 20 kHz

     涉及非常高频的声音,如高频细节、空气感和一些环境声的尾音,影响声音的开放感和透明度

1.1 设备物理限制

图片

手机或者耳机在体积功率等物理限制下,在中低压表现较弱。相比之下,电影院音响系统具备大尺寸扬声器和高功率输出,能够产生20Hz或更低的低频效果,并且其环境设计有助于低音的增强。

  1. 扬声器尺寸小:手机和耳机的扬声器尺寸通常较小,限制了低频声波的产生

  2. 功率输出低:这些设备的放大器功率有限,无法驱动强劲的低频振动

图片

1.2 人耳听觉非线性特性

图片

在不同响度下人耳的等响曲线

  • 可以看到,不同响度下的“等响曲线”的走势都不是完全一致的。响度越小,曲线中各个频率之间的区别就越大,而响度越大,曲线就越趋于平缓,各个频率之间的区别就越小

  • 人耳对于中频的敏感度要高于低频,而这种敏感度之间的差距会随着响度的增加而减小

丨2. 移动设备重低音方案选型

2.1 基于均衡器的低频调整

  • 原理与实现:

     频段划分:通过提升均衡器(Equalizer, EQ)低频段(比如20Hz-200Hz)的增益值直接增强低频信号幅度,例如,将 50Hz-80Hz 频段提升 +3dB~+6dB 可显著增强低音冲击力

  • 技术特点:

     线性处理,仅改变幅度,不产生谐波

     需结合音频压缩器,避免出现削波失真、爆音等case

文件1(原始)

1     bobo1

文件2(低音加强)

2     bobo2

图片

图片

2.2 谐波生成与心理声学效应

核心原理:

  • 一般认为音调应该是由以最低频率为基波决定的,谐波成分则决定音色

  • 通过非线性信号处理生成低频信号的谐波成分(如60Hz基频生成120Hz、180Hz谐波),利用人耳的塔替尼效应(Tartini Effect)

  • 当多个高频谐波存在时,大脑会“脑补”出缺失的基频(如120Hz+180Hz谐波组合可让人感知到虚拟的60Hz)

音频文件示例:

下面是两首曲听起来只是音色不同,但音调是一致的

y2     y4

图片

2.3 方案对比

图片

丨3. 移动播放器重低音效果实现

3.1 现有技术局限

  1. EQ直接提升

  2. 在低频段直接增加增益,易引发共振,引发“嗡嗡声”或“轰鸣感”

  3. 移动设备喇叭尺寸和功率限制,低频段增益效果一般

  4. 动态控制缺失:缺乏压缩和限制机制,易造成信号削波

3.2 技术目标

开发一款基于FFmpeg的高性能低音增强滤镜,实现以下功能:

  1. 分频处理:精准分离低频与高频信号,避免处理干扰

  2. 谐波增强:通过可控谐波生成方案,提升低频感知

  3. 动态均衡:结合前置/后置EQ与压缩器,优化频响与动态范围

  4. 参数可调:支持分频点、激励强度、混合比等灵活配置

3.3 系统架构

图片

3.4 核心模块详解

3.4.1 分频器模块

功能:将输入信号分割为低频和高频

技术实现:

  • 滤波器类型:4阶Linkwitz-Riley分频器,由2个双二阶滤波器级联

  • 相位对齐:低通与高通采用相同极点,保证分频点处相位连续

  • 关键代码:

// 分频器系数计算(design_crossover函数)
const double w0 = 2.0 * M_PI * s->cutoff / s->sample_rate;
const double alpha = sin(w0) / (2.0 * sqrt(2.0)); // Butterworth Q值
// 低通与高通系数通过频谱反转生成

3.4.2 前置EQ模块

功能:对分频后的低频信号进行预增强

技术实现:

  • 滤波器类型:低架滤波器(Low Shelf),截止频率为cutoff * 0.8

  • 增益公式:半功率增益计算(pre_gain/40),避免过度提升

  • 代码片段:

const double A = pow(10.0, s->pre_gain / 40.0);
const double omega2 * M_PI * s->cutoff0.8 / s->sample_rate;
// 低架滤波器系数计算(包含sqrt(A)项)

3.4.3 谐波生成器

功能:通过非线性失真生成奇次谐波,增强低频感知

算法设计:

  • 三阶多项式软化:shaped = x - (x^3)/6,生成3次、5次谐波

  • 抗混叠处理:shaped *= 1/(1 + |x|*2),抑制高频噪声

  • 直流偏移消除:return shaped - input*0.15

double generate_harmonics(double input, double drive) {
    double x = input * drive;
    // ...(非线性处理)
}

3.4.4 后置EQ模块

功能:补偿谐波生成后的频响失衡

技术实现:

  • 滤波器类型:高架滤波器(High Shelf),截止频率为cutoff * 1.2

  • 增益公式:全功率增益计算(post_gain/20),直接调整整体电平

  • 代码片段:

const double A = pow(10.0, s->post_gain / 20.0);
const double omega2 * M_PI * s->cutoff1.2 / s->sample_rate;
// 高架滤波器系数计算(简化公式)

3.4.5 动态压缩器

功能:防止增强后的低频信号过载

算法设计:

  • RMS检测:跟踪信号包络,计算动态增益

  • 对数域压缩:阈值(COMP_THRESHOLD)设为-4dB,压缩比由drive参数动态调整

  • 平滑过度:一阶IIR滤波器平滑增益变化,避免“抽吸效应”

代码逻辑:

double compressor_process(...) {
    // Attack/Release系数计算
    const double coeff = (input^2 > envelope) ? attack_coeff : release_coeff;
    // 增益平滑
    s->ch_state[ch].gain0.2 * old_gain + 0.8 * target_gain;
}

3.4.6 配置参数表

图片

五. 落地音效二:清晰人声

清晰人声结合频段增强、人声掩码与背景音抑制技术,能有效提升对白清晰度,在嘈杂或背景音复杂的场景下保持语音主干突出。为了更好地了解清晰人声的实现,本节将从音效的整体处理流程、滤波器设计细节、关键滤镜链搭建方式以及实现效果进行详细的介绍。

丨1. 技术原理

为了增强音频中人声的清晰度,对音频的处理主要分为四步:

  1. 识别并隔离主要说话者的声音,并进行语音放大

  2. 识别并减少各种类型的背景噪音

  3. 通过调整频率平衡来提高语音清晰度

  4. 应用精细的压缩和均衡来提高整体音频质量

要调整音质以使人声更加清晰,通常需要在EQ中进行一些特定的频率调整。以下是一些常见的频率和增益设置及其效果,这些设置有助于提高人声(通常在300Hz~3kHz)的清晰度:

图片

丨2. Dialoguenhance

FFmpeg中有一个可以用来增强立体声对话的音频滤镜,称为dialoguenhance。接下来,将对dialoguenhance滤镜进行一下整体的介绍,随后便详细地介绍一下该滤镜的设计细节,以及播放内核关键滤镜链的搭建方式。

2.1 滤镜介绍

Dialoguenhance滤镜接受立体声输入并生成环绕声(3.0声道)的输出。新生成的前置中置声道会强化原本在两个立体声声道中都存在的语音对话,同时前置左声道和右声道的输出则与原立体声输入相同。

该滤镜提供了3个选项,分别是original、enhance、voice。下列表格中展现了这三个参数的说明、取值范围以及其作用。

图片

2.2 实现原理

filter_frame函数是FFmpeg大多数滤镜的核心处理函数,主要包含以下步骤:

  1. 第一步,接收原音频帧输入,并进行参数初始化。

  2. 第二步,对输入进行加窗处理,从而减少频谱泄露。

  3. 第三步,执行傅里叶变换,将音频输入信号从时域转化成频域。

  4. 第四步,对音频信号进行算法处理,这个是各个滤镜的核心部分。

  5. 第五步,执行傅里叶逆变换,将处理后的音频信号从频域转化为时域。

  6. 第六步,处理立体声信号,将其转换为处理后的信号,处理后的音频被组合并输出。协调整个滤镜过程,管理缓冲区并执行必要的变换和增强。

  7. 第七步,根据输入的参数和属性配置滤镜。

  8. 第八步,激活滤镜。

以dialoguenhance为例,以下展现了filter_frame函数的源代码。

static int filter_frame(AVFilterLink *inlink, AVFrame *in)
{
    AVFilterContext *ctx = inlink->dst;
    AVFilterLink *outlink = ctx->outputs[0];
    AudioDialogueEnhanceContext *s = ctx->priv;
    AVFrame *out;
    int ret;

    out = ff_get_audio_buffer(outlink, s->overlap);
    if (!out) {
        ret = AVERROR(ENOMEM);
        goto fail;
    }

    s->in = in;
    s->de_stereo(ctx, out);

    av_frame_copy_props(out, in);
    out->nb_samples = in->nb_samples;
    ret = ff_filter_frame(outlink, out);
fail:
    av_frame_free(&in);
    s->in = NULL;
    return ret < 0 ? ret : 0;
}

Dialoguenhance滤镜是通过处理立体声音频信号并提取通常包含在中心声道中的对话内容来增强音频对话的,其核心算法处理包括对音频数据进行中心声道处理、语音活动检测、语音增强等步骤。下图展现了dialoguenhance滤镜的工作原理及其对应的代码模块功能:

图片

丨3. 工程实现

对于播放内核而言,为接入dialoguenhance滤镜,主要包含以下两个重要的工程实现:

1. 第一个是dialoguenhance滤镜的初始化以及获取滤镜的上下文。在播放内核的工程实现中,确定好了dialoguenhance各个选项的具体参数。

#include "dialoguenhance_filter.h"
PLAYER_CORE_NAMESPACE_BEGIN
        AVFilterContext* DialoguenhanceFilter::getAVFilterContext() {
            return _avFilterContext;
        }
        int DialoguenhanceFilter::initFilter(AVFilterGraph* graph, AudioBaseInfo* audioBaseInfo, const JsonUtils::Value* param) {
            AUDIOSCOPEDEBUG();
            _avFilter = (AVFilter*)avfilter_get_by_name("dialoguenhance");
            if (!_avFilter) {
                LOGD("dialoguenhance filter not found");
                return -1;
            }
            _avFilterContext = avfilter_graph_alloc_filter(graph, _avFilter, "dialoguenhance");
            if (!_avFilterContext) {
                LOGD("dialoguenhance filter context alloc failed");
                return -1;
            }
            // 参数设置
            av_opt_set_double(_avFilterContext, "original", 0, AV_OPT_SEARCH_CHILDREN);
            av_opt_set_double(_avFilterContext, "enhance", 2, AV_OPT_SEARCH_CHILDREN);
            av_opt_set_double(_avFilterContext, "voice", 16, AV_OPT_SEARCH_CHILDREN);
            int result = avfilter_init_str(_avFilterContext, nullptr);
            if (result < 0) {
                LOGD("dialoguenhance filter init failed");
                return -1;
            }
            return 0;
        }
PLAYER_CORE_NAMESPACE_END

  1. 第二个是将dialoguenhance滤镜接入播放内核音频后处理模块的滤镜链中。
void AudioFrameProcessorElement2::init_filter_list(const std::string& filterType,const JsonUtils::Value* param){
    std::shared_ptr<IAudioFilter> filterPtr = nullptr;
    if (filterType == kAudioSrc){
        filterPtr = std::make_shared<SrcFilter>(kAudioSrc);
    }
    else if (filterType == kAudioSink){
        filterPtr = std::make_shared<SinkFilter>(kAudioSink);
    }
    else if (filterType == kAudioVolume){
        filterPtr = std::make_shared<VolumeFilter>(kAudioVolume);
    }
    else if (filterType == kAudioRaiseVoice){
        filterPtr = std::make_shared<DialoguenhanceFilter>(kAudioRaiseVoice);
    }

    if(filterPtr->initFilter(_filter_graph,_audio_base_info,param) == 0){
        _filter_list.push_back(filterPtr);
    }
}

丨4. 实现效果

处理前:

input1_1     input2_1

处理后:

res1_1     res2_1

大家可以对比感受上面处理前/后效果。

  • 第一组效果

     处理前:背景音乐和人声音量差不多。

     处理后:人声相对于背景音乐更加突出,对话内容更加清晰。

  • 第二组效果

     处理前:音乐和人声较平,音量差别不大。

     处理后:人声相对于处理前更加清晰和突出,明显增强。音乐沉浸感也比处理前效果更好。

六. 小结

本文围绕播放器音频后处理中的重低音与清晰人声两种典型音效处理,简要介绍了其实现逻辑、关键参数控制策略,以及在播放链路中的集成方式。这两种处理在实际应用中具备较强的通用性,适用于大多数通用内容场景。

后续文章将继续分享更多音频后处理技术实践,敬请关注。

❌
❌