16 / 32 音频采集、混音与同步

第 15 课:采集声音 —— 系统声音与麦克风

# 第 15 课:采集声音 —— 系统声音与麦克风

嘿,我是小方。 上一课(第 14 课),画面这条线交棒了。从这一课起,进入 模块 4:声音是怎么处理的(音频链路)。 声音这条流水线,和视频并行,但脾气不太一样 —— 它连续、不能丢、还必须和画面严丝合缝地对齐。我们照旧从第一站:采集讲起,而且用和第 10 课一样的方式:把一个真实音频采集插件的每一步都摊开,代码、用到的系统 API、每行在干嘛,全讲清楚。这一课开刀的对象,是 Windows 上的 win-wasapi 插件 —— 它同时负责"录麦克风"和"录系统声音"两件事,而这两件事的代码,几乎是同一段。 这一课我特意为新手多铺了点底:声音在内存里到底长什么样、COM 接口是什么、WASAPI 怎么"一小包一小包"喂给你、"重采样"又是在干嘛 —— 都会掰开揉碎讲。


# 15.1 回到地图:音频,是另一条并行的流水线

📍接上一节:视频那条线交棒了,这一节先把音频这条并行线的地图铺开——它和视频镜像,第一站也是采集。

模块 4 开场

先建立全局。视频那条线你已经走完前半程(采集 → 合成 → 取帧);音频是另一条并行的线:采集 → 混音/对齐 → 音量/电平,最后两条线在编码 / 封装处汇合。这一课是音频线的第一站。

采集这一站干的事,一句话:把现实世界的声音,变成一个个 struct obs_source_audio,交给引擎。 这和第 10 课视频采集"把光变成 obs_source_frame"是镜像的一件事。

但有个贯穿整个模块 4 的关键区别,先记在心里:声音是"连续的、一个采样都不能丢"的。视频丢一帧,你多半没察觉;音频丢一个采样,就是"咔哒"一声爆音,人耳立刻抓包。这个区别,会让音频的处理方式和视频有微妙又关键的不同(15.9 见分晓)。


# 15.2 声音的"一包":obs_source_audio

📍我们在哪:采集这一站要填的,就是"声音版的一帧"——这一节先认识那个结构体 obs_source_audio

采集源要填的那个结构体,是 obs_source_audio(obs.h:251)。它就是"声音版的 obs_source_frame":

obs_source_audio 结构

// libobs/obs.h:251
struct obs_source_audio {
	const uint8_t *data[MAX_AV_PLANES];  // PCM 采样的字节(指针)
	uint32_t frames;                     // 这一包有多少个采样(每声道)

	enum speaker_layout speakers;        // 声道布局(单声道?立体声?)
	enum audio_format   format;          // 采样格式(位深:16 位?float?)
	uint32_t samples_per_sec;            // 采样率(44100?48000?)

	uint64_t timestamp;                  // 这一包的采集时刻(纳秒)
};
1
2
3
4
5
6
7
8
9
10
11

如果你还记得第 4 课,这几个字段其实就是PCM 的三要素换了个地方住:

  • samples_per_sec —— 采样率。每秒采多少个点,常见 44100 或 48000 Hz(第 4 课讲过,这决定了能还原多高的频率)。
  • format —— 采样格式,本质是位深。是 AUDIO_FORMAT_16BIT(每采样 2 字节整数)还是 AUDIO_FORMAT_FLOAT(每采样 4 字节浮点)?枚举在 audio-io.h:43,引擎内部统一用 AUDIO_FORMAT_FLOAT_PLANAR(浮点、分平面)。
  • speakers —— 声道布局。SPEAKERS_MONO(单声道 1 条)、SPEAKERS_STEREO(立体声 2 条)…… 枚举在 audio-io.h:66
  • frames —— 这一包装了多少个采样(每声道)。这个词有点坑,下一节专门澄清。
  • timestamp —— 这一包的采集时刻(纳秒)。这是音画同步的命根子,和视频用的是同一把时间尺。

对照第 10 课的 obs_source_frame(width/height/format/data/timestamp):视频那包记的是"一幅画多大",音频这包记的是"一段声多快采、几个声道"。结构几乎一一对应 —— 因为在引擎眼里,采集视频和采集音频,是同一件事的两个版本。

不过,那个 data[MAX_AV_PLANES](一个指针数组)和后面代码里的 data.data[0] = buffer,新手第一次看多半会犯嘀咕:声音不就是一串数吗,怎么还搞个数组?别急,下一节先把"声音在内存里长什么样"看清楚,这些就全通了。


# 15.3 先补课:一段声音在内存里,到底长什么样

📍承上启下:上一节那个 data[] 数组为什么是数组?钻进 WASAPI 之前,这一节先把"声音在内存里怎么摆"看清楚。

在钻进 WASAPI 之前,花两分钟把这件事看清楚 —— 它直接决定你能不能读懂 15.7 那句 data.data[0] = buffer

PCM 在内存里

# 帧(frame)vs 采样(sample):别混了

  • 一个采样(sample) = 一个声道在某一时刻的一个数值。就是第 4 课那个"波形上的一个点"。
  • 一个帧(frame) = 同一时刻、所有声道各出一个采样打成的一组。

所以立体声(2 声道)里,1 帧 = 2 个采样(左 L 一个、右 R 一个);单声道则 1 帧 = 1 采样。OBS 结构体里的 frames 字段,数的是,不是采样 —— 也就是"这一包覆盖了多少个时间点"。记住这个区分,后面算字节、算时长都靠它。

# 交错(interleaved)vs 平面(planar):同一段声音的两种摆法

同样是"左右两个声道的一段声音",在内存里有两种摆法,这是新手最容易懵的地方:

  • 交错(interleaved):左右交替塞在一整块连续内存里 —— L0 R0 L1 R1 L2 R2 …WASAPI 给你的就是这种。因为全挤在一块,所以代码里只用得上 data.data[0](把整块的首地址塞进第 0 个平面),data[1]data[2] 都空着。
  • 平面(planar):每个声道单独放一条 —— data[0] = L0 L1 L2…data[1] = R0 R1 R2…引擎内部转成的就是这种。因为分开放,才需要 data[] 是个数组(几个声道就占几个平面)。

这下 data[MAX_AV_PLANES] 为什么是数组、而 WASAPI 代码为什么只填 data[0],就都对上了:采集插件按设备给的"交错"格式填 data[0],引擎收下后会转成自己爱用的"平面浮点"格式(这个转换就发生在 15.8)。

# 算笔账,建立"一小包有多大"的手感

拿最常见的配置:48000 Hz、立体声、float(每采样 4 字节)。

  • 一秒的声音 = 48000 帧 × 2 声道 × 4 字节 ≈ 375 KB
  • WASAPI 一次给你的一小包,通常约 10 毫秒 = 480 帧 × 2 × 4 ≈ 3.75 KB

记住"一小包 ≈ 10ms"这个手感 —— WASAPI 就是攒够这么一小包、叮你一下(15.7 详解)。

🔧 背景:为什么是 float,不是第 4 课的 16 位整数? 第 4 课我们学的 PCM 是"16 位整数"(每采样一个 −32768~32767 的整数)。可这里 WASAPI 一律给 32 位浮点(float),引擎内部也用 float。为什么?因为混音(第 16 课)要把好几路声音相加。整数相加很容易溢出爆表(两个大数一加超过 32767 就"削顶"失真);而 float 用 −1.0~+1.0 表示满量程,相加超过 1.0 也不会立刻失真,留了处理余量(headroom),混完再统一压回范围、到编码时才转回整数。所以"采集/处理阶段用 float,存储/传输阶段用整数"是音频工程的惯例。


# 15.4 音频采集源 = INPUT + OBS_SOURCE_AUDIO

📍我们在哪:内存模型补完,回到 OBS——看一个真实音频采集源的登记表长什么样,和第 10 课视频源对上号。

好,回到 OBS。翻开 win-wasapi.cpp 末尾的注册代码,你会看到 WASAPI 插件注册了两张几乎一样的登记表:一张录麦克风,一张录系统声音。

两张登记表

// plugins/win-wasapi/win-wasapi.cpp:1541
void RegisterWASAPIInput()   // 麦克风 / 线路输入
{
	obs_source_info info = {};
	info.id           = "wasapi_input_capture";
	info.type         = OBS_SOURCE_TYPE_INPUT;                      // 对 OBS 来说是「输入」
	info.output_flags = OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE;  // ★ 我会推音频
	info.create       = CreateWASAPIInput;                         // 创建实例 = new WASAPISource
	...
	info.icon_type    = OBS_ICON_TYPE_AUDIO_INPUT;
	obs_register_source(&info);
}
1
2
3
4
5
6
7
8
9
10
11
12
// plugins/win-wasapi/win-wasapi.cpp:1559
void RegisterWASAPIDeviceOutput()   // 系统声音 / 桌面音频
{
	obs_source_info info = {};
	info.id           = "wasapi_output_capture";
	info.type         = OBS_SOURCE_TYPE_INPUT;
	info.output_flags = OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE
	                    | OBS_SOURCE_DO_NOT_SELF_MONITOR;          // 多一个:别自己监听自己
	...
	info.icon_type    = OBS_ICON_TYPE_AUDIO_OUTPUT;
	obs_register_source(&info);
}
1
2
3
4
5
6
7
8
9
10
11
12

两张表几乎一模一样:

  • 都是 OBS_SOURCE_TYPE_INPUT —— 麦克风也好、系统声音也好,对 OBS 来说都是"喂进来的输入源"(回顾第 6 课的四类型)。
  • 都带 OBS_SOURCE_AUDIO 这个标志(定义在 obs-source.h:97)。它就等于第 10 课那个 OBS_SOURCE_ASYNC_VIDEO音频版:告诉引擎"我会自己采样、主动把 PCM 推给你,别每帧来问我要"。带了这个标志,引擎才会把这个源当音频源对待。
  • 系统声音那张多了个 OBS_SOURCE_DO_NOT_SELF_MONITOR(别把自己的输出又监听回来,否则回声套娃)。

那"自己采样"具体在哪采?和第 10 课一样,.create(new WASAPISource(...))里会把设备初始化好、起一个采集线程。下面我们把它从头拆开。


# 15.5 WASAPI 是什么,以及七步配方

📍承上启下:登记表的 create 里要把设备初始化好,这一节就拆这套准备工作——Windows 录音的 WASAPI 是什么、准备阶段哪七步。

先说"用到什么库"。Windows 上录音的标准接口叫 WASAPI(Windows Audio Session API),是系统自带的音频 API。这类比第 10 课 Linux 用 V4L2:都是"跟操作系统要设备数据"的官方途径,只是平台不同。

🔧 背景:那些 I 开头的接口、ComPtrHRESULT 是啥?(COM 速成) WASAPI 全套是 COM(Component Object Model,组件对象模型) 接口 —— 这是 Windows 把系统功能打包成"一个个接口对象"的老传统。你不用深究,只要抓住三点:

  • IAudioClient / IAudioCaptureClient 这些 I 开头的,是接口(interface)。你拿到的是指向某个系统对象的指针,通过它调方法。
  • ComPtr<T> 是个智能指针(和 C++ 的 shared_ptr 类似):自动帮你管这个 COM 对象的引用计数(用完自动释放),省得手动 Release.Assign() 就是"把它内部的指针地址交出去,好让系统往里塞一个新对象"。
  • HRESULT 是几乎每个 COM 调用的返回码;用 FAILED(res) 宏判断成没成。 一句话翻译:Activate / GetService = 问系统要一个接口对象;GetMixFormat / GetBuffer = 调这个对象上的方法。看代码时这么读就顺了。

把一个 WASAPI 采集跑起来,准备阶段是这七步(都在 WASAPISourceInitDevice / InitClient / InitCapture 里),第七步之后才进入不停转的抓声循环:

WASAPI 配方

逐步看关键代码。

# ①② 拿到枚举器、选设备

CoCreateInstance 创建一个设备枚举器(构造函数里,win-wasapi.cpp:342),再用它挑设备(InitDevice,win-wasapi.cpp:590):

// plugins/win-wasapi/win-wasapi.cpp:596
const bool input = type == SourceType::Input;
HRESULT res = enumerator->GetDefaultAudioEndpoint(
	input ? eCapture : eRender,               // 麦克风取「输入端点」,系统声音取「输出端点」
	input ? eCommunications : eConsole, device.Assign());
1
2
3
4
5

💡 "端点(endpoint)"是什么? 就是一个具体的音频收发口:一个麦克风是一个"输入端点(eCapture)",一个扬声器是一个"输出端点(eRender)"。eCommunications / eConsole 是"角色"(通讯用?还是普通播放用?),因为 Windows 允许你给"打电话"和"听音乐"设不同的默认设备。GetDefaultAudioEndpoint = "给我当前默认的那个口"。

这里就是麦克风和系统声音的第一处分叉:麦克风传 eCapture(默认输入设备 = 麦克风),系统声音传 eRender(默认输出设备 = 扬声器)。先记住这个 eRender,下一节要用它做一件"反常"的事。

# ③④ 拿到音频客户端、问出设备格式

InitClient(win-wasapi.cpp:640)里,Activate 出一个 IAudioClient,然后问设备:你想用什么格式?

// plugins/win-wasapi/win-wasapi.cpp:701
res = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, (void **)client.Assign());
...
res = client->GetMixFormat(&wfex);   // ★ 拿到设备的采样率 / 声道 / 位深
1
2
3
4

GetMixFormat 返回一个 WAVEFORMATEX,告诉你这台设备当前跑在什么采样率、几个声道。InitFormat(win-wasapi.cpp:784)把它翻译成 OBS 的字段:

// plugins/win-wasapi/win-wasapi.cpp:794
/* WASAPI is always float */
speakers    = ConvertSpeakerLayout(layout, wfex->nChannels);  // 声道
format      = AUDIO_FORMAT_FLOAT;                             // 位深:一律 float
sampleRate  = wfex->nSamplesPerSec;                           // 采样率
1
2
3
4
5

注意那句注释 "WASAPI is always float":WASAPI 共享模式下,拿到的采样一律是 32 位浮点(就是 15.3 说的那个道理),所以 format 直接写死 AUDIO_FORMAT_FLOAT,连位深都不用猜。采样率则随设备(你系统设成 48000,这里就是 48000)—— 这个"设备采样率可能和引擎不一样"的事实,会在 15.8 引出"重采样"。

# ⑤ 初始化 —— LOOPBACK 标志登场

// plugins/win-wasapi/win-wasapi.cpp:714
DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK;              // 事件驱动:有数据就通知我
if (type != SourceType::Input)
	flags |= AUDCLNT_STREAMFLAGS_LOOPBACK;               // ★ 不是麦克风,就加「回环」
res = client->Initialize(AUDCLNT_SHAREMODE_SHARED, flags, BUFFER_TIME_100NS, 0, pFormat, nullptr);
1
2
3
4
5

这一行 flags |= AUDCLNT_STREAMFLAGS_LOOPBACK,就是整个"录系统声音"的魔法所在。下一节专门讲它。AUDCLNT_STREAMFLAGS_EVENTCALLBACK 则表示"事件驱动"——不用轮询,WASAPI 攒够一小包就"叮"一下通知你(配合后面的 SetEventHandle,15.7 细讲这个模型)。AUDCLNT_SHAREMODE_SHARED 是"共享模式"——和别的程序共用声卡,而不是独占。

# ⑥⑦ 拿到取样对象、开始

InitCapture(win-wasapi.cpp:800):

// plugins/win-wasapi/win-wasapi.cpp:802
HRESULT res = client->GetService(IID_PPV_ARGS(capture.Assign()));  // 拿到 IAudioCaptureClient
...
res = client->SetEventHandle(receiveSignal);  // 把「有数据」事件绑到我的信号量
...
res = client->Start();                        // ★ 开始采集,数据开始流动
1
2
3
4
5
6

GetService 要来的 IAudioCaptureClient(存进成员 capture),就是之后真正拿 PCM 的那个对象Start() 一调,声音就源源不断地来了 —— 接下来全靠那根抓声循环去接。


# 15.6 麦克风 vs 系统声音:就差一个 loopback 标志

📍我们在哪:七步配方里埋了个 loopback 标志,这一节专门讲它——"录系统声音"和"录麦克风"其实只差这一处。

停下来专门说这件妙事,因为它是理解 WASAPI 的关键。

"录系统声音"是怎么做到的? 直觉上,麦克风好理解(有个输入设备,读它就行);可"电脑正在播放的声音"没有一个"输入设备"啊?WASAPI 的答案很巧:把本该播给扬声器的声音,用 loopback(回环)当成输入读回来

麦克风 vs 系统声音

对比一下两者的全部区别:

麦克风(SourceType::Input) 系统声音(SourceType::DeviceOutput)
选的端点 eCapture(输入设备) eRender(输出设备,即扬声器)
loopback 标志 不设 AUDCLNT_STREAMFLAGS_LOOPBACK
源 id "wasapi_input_capture" "wasapi_output_capture"

代码上,分叉就那么两处 —— 端点选择(win-wasapi.cpp:597)那个三元表达式,和这一行(win-wasapi.cpp:714):

DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK;
if (type != SourceType::Input) flags |= AUDCLNT_STREAMFLAGS_LOOPBACK;
1
2

系统声音选的是输出端点(扬声器),本来输出端点是"往外播"的;加上 LOOPBACK 标志后,WASAPI 让你把它当输入来读——于是"正在播出去的声音"就被你抓了回来。

而除了这两处,下游代码两者完全共用:GetMixFormat 问格式、抓声循环里 GetBuffer 拿样、填 obs_source_audioobs_source_output_audio 交给引擎…… 一模一样。这就是为什么在 OBS 里加"桌面音频"和加"麦克风"体验几乎没差别 —— 底层真的是同一段代码,只差一个标志位。

小提示:各平台"录系统声音"的实现细节不同(macOS 需要 ScreenCaptureKit 或虚拟声卡,Linux 靠 PulseAudio/PipeWire 的 monitor 源),但思路都是"把输出当输入读回来"。WASAPI 的 loopback 是其中最干净利落的一种。


# 15.7 源码深读:不停转的"抓声循环"

📍承上启下:设备准备好了、开始采集了,这一节进入真正不停转的那段——WASAPI 一小包一小包喂给你,你一包包取走。

# 先理解模型:WASAPI 怎么"一小包一小包"喂给你

在看循环代码前,先建立正确的心智模型 —— 这是新手最容易想岔的地方。你不是"自己去设备里一个采样一个采样地读";而是 WASAPI 自己在后台攒,攒够一小包(约 10ms,回顾 15.3)就触发一个事件("叮"一下),把你的采集线程唤醒,你再去把这一包整取走。

一小包 + 事件模型

为什么用这种"事件驱动"而不是自己 while 死循环读?

  • 死循环轮询(一直问"有数据没")会空转烧 CPU;事件驱动是"有货才叫你",没货就睡,省电省 CPU。
  • 因为是共享模式,声卡是 OBS、播放器、语音软件一起用的,系统统一混好再按包发给各家 —— 这也是你拿到的为什么是 float 流。
  • 48kHz 下每包约 10ms,一秒钟大约"叮"100 次,每次处理一小包,延迟低又不至于太碎。

# 循环代码

理解了模型,代码就顺了。核心在 ProcessCaptureData(win-wasapi.cpp:957),每次被"叮"醒就把攒着的包都取干净(抽骨架):

抓声循环

// plugins/win-wasapi/win-wasapi.cpp:966(抽骨架)
while (true) {
	res = capture->GetNextPacketSize(&captureSize);  // 还有多少数据?
	if (!captureSize)
		break;                                   // 为 0,这轮取完了,回去等下一次「叮」

	BYTE *buffer; UINT32 frames; DWORD flags; UINT64 pos, ts;
	res = capture->GetBuffer(&buffer, &frames, &flags, &pos, &ts);  // ★ 拿到这一小包 PCM

	obs_source_audio data = {};
	data.data[0]         = buffer;       // PCM 字节指针(交错存放,全塞第 0 个平面)
	data.frames          = frames;       // 这包多少个采样(帧)
	data.speakers        = speakers;     // 声道(③④ 问出来的)
	data.samples_per_sec = sampleRate;   // 采样率
	data.format          = format;       // 一律 float

	// 时间戳:用设备时钟(ts,单位 100ns),或用 OS 时钟减去「这包的时长」
	data.timestamp = useDeviceTiming ? ts * 100 : os_gettime_ns();
	if (!useDeviceTiming)
		data.timestamp -= util_mul_div64(frames, UINT64_C(1000000000), sampleRate);

	obs_source_output_audio(source, &data);   // ★★ 把这包交给引擎(本课主角)

	capture->ReleaseBuffer(frames);            // 把缓冲还给 WASAPI,去接下一包
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

逐句过:

  • GetNextPacketSize / break —— 问"还有没有攒好的包",没有(为 0)就跳出,回去等下一次事件。不空转烧 CPU。
  • GetBuffer(&buffer, &frames, ...) —— 关键一步。WASAPI 把它内部缓冲里的一小包 PCM 直接给你一个指针 buffer(以及这包有多少 frames、设备位置 pos、设备时间戳 ts)。和第 10 课 V4L2 的 mmap/DQBUF 一样,是"共享、不拷贝":你只是拿到指针,读完得赶紧 ReleaseBuffer 还回去。
  • obs_source_audio —— 把指针、采样数、格式、采样率填进去。这里 data.data[0] = buffer 你现在秒懂了:WASAPI 给的是交错格式,全在一块内存里,所以只填第 0 个平面(15.3 那张图)。(还有个细节:如果这包是静音,flags 会带 AUDCLNT_BUFFERFLAGS_SILENT,代码会换成一段填零的缓冲,见 win-wasapi.cpp:1003;这样"没声音"也照样按节奏产出带时间戳的包,时间轴不断。)
  • 时间戳 —— 两种算法:用设备自己的时钟(ts * 100,把 100ns 单位换成纳秒),或用系统单调时钟 os_gettime_ns() 减去这包的时长(frames / sampleRate 换算成纳秒),让时间戳标在这包的起点。默认麦克风用后者、系统声音用前者。无论哪种,目的都一样:给这包声音打一个能和视频对齐的准确时刻。
  • obs_source_output_audio —— 把填好的一包交给引擎。这就是第 6 课音频那条"异步推数据"路的源头,和视频的 obs_source_output_video 一一对应。
  • ReleaseBuffer —— 把共享缓冲还给 WASAPI,让它继续填。然后 while 转回去。

整个循环就是 等事件 → 取一包 → 填 obs_source_audio → 交引擎 → 还缓冲,和第 10 课视频抓帧循环是一个骨架。区别只是:视频取的是"一帧画",音频取的是"一小包连续采样"。


# 15.8 源码深读:引擎"收下"一包声音时做了什么

📍我们在哪:采集侧把一包声音交出去了,这一节跟进引擎这边——它比视频讲究得多,因为要为"一个采样都不能丢"负责。

现在进引擎这边。视频的 obs_source_output_video 做的事很简单(复制一帧、入队);音频的 obs_source_output_audio(obs-source.c:4029)要做的更讲究,因为它得为"一个采样都不能丢"负责。

引擎收声

它内部是三步:统一格式 → 校准时间 → 拼进深缓冲

# 第一步:process_audio —— 重采样到引擎的统一格式

// libobs/obs-source.c:3993(节选)
static void process_audio(obs_source_t *source, const struct obs_source_audio *audio)
{
	// 来的采样率 / 格式 / 声道,和上次不一样?→ 重建重采样器
	if (source->sample_info.samples_per_sec != audio->samples_per_sec ||
	    source->sample_info.format != audio->format ||
	    source->sample_info.speakers != audio->speakers)
		reset_resampler(source, audio);
	...
	if (source->resampler) {
		audio_resampler_resample(source->resampler, output, &frames,
		                         &source->resample_offset, audio->data, audio->frames);  // ★ 重采样
		copy_audio_data(source, output, frames, audio->timestamp);
	} else {
		copy_audio_data(source, audio->data, audio->frames, audio->timestamp);  // 格式已一致,直接拷
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

为什么要重采样? 你的麦克风可能跑 44100 Hz,系统声音跑 48000 Hz,但引擎内部必须用统一的采样率和格式。所以每一包进来,如果它的采样率/格式/声道和引擎的不一致,就用 audio_resampler_resample 把它转成引擎的目标格式(统一采样率、float 平面)。

重采样直觉

那"重采样"具体是在干嘛?直觉:同一条声波,换一套"每秒多少个点"来记录。 原来 44100 Hz 是每秒 44100 个采样点,现在要变成每秒 48000 个 —— 声波没变,只是采样点的位置变密了。在每个新时间格的位置,拿旁边的原始采样点插值算出该处的值,就得到新一套采样。这活儿 OBS 没自己写,而是交给 FFmpeg 的 swresample 库(audio-resampler-ffmpeg.c 就是对它的封装)。

为什么非统一不可? 因为第 16 课要把多路声音逐采样相加混成一路;两路采样率不一样,"第 100 个采样"对应的时刻都不同,根本对不齐、没法加。所以必须先在入口处把所有源"对齐到同一个数字节拍"。这一步视频没有 —— 视频不同源可以各是各的分辨率,合成时才缩放;音频不行,必须先对齐。

# 第二步:source_output_audio_data —— 校准时间戳

// libobs/obs-source.c:1572(节选思路)
// 1) 和「预期的下一个时间戳」next_audio_ts_min 比一比
if (diff > MAX_TS_VAR && !using_direct_ts)
	handle_ts_jump(source, source->next_audio_ts_min, in.timestamp, diff, os_time);  // 跳变太大 → 重置
else if (diff < TS_SMOOTHING_THRESHOLD)
	in.timestamp = source->next_audio_ts_min;   // 小偏差 → 吸附到预期值,抹平抖动

// 2) 记下「下一个采样应该在什么时刻」
source->next_audio_ts_min = in.timestamp + conv_frames_to_time(sample_rate, in.frames);
// 3) 叠加音画同步微调
in.timestamp += source->sync_offset;
1
2
3
4
5
6
7
8
9
10
11

音频对时间戳极其敏感。设备给的时间戳可能有轻微抖动(每包差个几十微秒),如果照单全收,声音就会忽快忽慢。所以引擎:小抖动就吸附到"预期的下一个时刻"(next_audio_ts_min)把它抹平;真的跳变太大(设备卡了、掉了)才 handle_ts_jump 重置。还会叠加你在 OBS 里设的 sync_offset(音画同步微调,就是那个"音频同步偏移"滑块)。这一整套,都是为了让声音的时间轴平滑又准确——因为它最终要和视频对齐。

# 第三步:拼进每声道的深环形缓冲

// libobs/obs-source.c:1650(节选)
if (push_back && source->audio_ts)
	source_output_audio_push_back(source, &in);   // 连续:接在后面
else
	source_output_audio_place(source, &in);       // 按时间戳精确落位
1
2
3
4
5

最后,把这包采样拼进 source->audio_input_buf[] —— 每个声道一条环形缓冲(obs-internal.h:867)。要么直接接在后面(push_back),要么按时间戳精确落位(place)。注意它是深缓冲:上限 MAX_BUF_SIZE = 1000 * AUDIO_OUTPUT_FRAMES * sizeof(float)(obs-source.c:1448)—— 能存约 1000 个"处理拍"的量。

对比第 10 课:视频是"复制一帧、入一个浅队列"(基本就一个当前帧);音频是"重采样成统一格式、按时间戳拼进一条深流"。 为什么这么大费周章?下一节说透。


# 15.9 音频 vs 视频采集:为什么音频这么"斤斤计较"

📍收束:采集这一站走完了,这一节把音频和视频并排对比,点出音频一切设计的总纲——连续、不丢、能对齐。

把两课放一起对比,你就看清了音频这条线的"性格":

音频 vs 视频采集

维度 视频采集(第 10 课) 音频采集(本课)
数据单元 一帧画(可替换、可丢) 连续的采样流(一个都不能丢)
缓冲深度 基本就一个"当前帧" 每声道一条深环形缓冲(~1000 拍)
统一格式 渲染时才转成纹理 进来就重采样到引擎采样率 / float
节奏 push:采集推,渲染线程按时间戳取 固定节拍 pull:每拍取 1024 个采样
追不上时 丢帧(状态栏"跳过的帧") 尽量"加缓冲"兜底,而不是丢

最后那两行,是音频区别于视频的灵魂:

  • 节奏:视频是渲染线程"想画的时候来取一帧";音频有一根专门的线程 audio_thread(audio-io.c:205),按固定节拍 pull——每一拍精确地取走 AUDIO_OUTPUT_FRAMES = 1024(audio-io.h:31)个采样,用 os_sleepto_ns_fast 卡时刻(和第 14 课视频 os_sleepto_ns 一个套路)。混音就发生在这个节拍上(第 16 课)。
  • 追不上时:视频追不上就丢帧(第 14 课那个 skipped);音频追不上,引擎宁可加缓冲(obs-audio.c 里的 add_audio_buffering),也尽量不丢一个采样

为什么差别这么大?一句话:丢一帧画面你多半没察觉;丢一个采样,就是"咔哒"一声爆音,人耳立刻抓包。 所以音频宁可加一点延迟、加一层缓冲,也要"一个采样都不少"。理解了这一点,你就抓住了理解整条音频链路(采集 → 混音 → 编码)的总纲:音频的一切设计,都在为"连续、不丢、能对齐"服务。


# 15.10 本课小结

  • 音频采集 = 把声音填进 obs_source_audio(obs.h:251),交给引擎;字段就是第 4 课 PCM 三要素:samples_per_sec(采样率)、format(位深)、speakers(声道)+ frames(这包帧数)+ timestamp(同步命根子)。
  • 内存里的声音:一个采样 = 一个声道某一刻的值;一帧 = 同一刻各声道各一个采样(立体声 1 帧 = 2 采样)。交错(LRLR…,WASAPI 给的,全在 data[0])vs 平面(每声道一条,引擎内部用,所以 data[] 是数组)。处理阶段用 float(为混音留余量)。
  • 采集源是 INPUT + OBS_SOURCE_AUDIO:WASAPI 注册两张表 —— wasapi_input_capture(麦克风,:1541)、wasapi_output_capture(系统声音,:1559)。OBS_SOURCE_AUDIO = 第 10 课 ASYNC_VIDEO 的音频版。
  • 用到的接口:Windows 的 WASAPI(COM 接口 IMMDeviceEnumerator / IAudioClient / IAudioCaptureClient,ComPtr 智能指针管生命周期)。七步配方:枚举器 → GetDefaultAudioEndpoint 选端点 → Activate 拿客户端 → GetMixFormat 问格式 → Initialize(±LOOPBACK)→ GetService 拿取样对象 → Start
  • 麦克风 vs 系统声音,就差两处:端点(eCapture vs eRender,:597)+ 一行 loopback 标志(:714)。"录系统声音" = 把输出端点用 AUDCLNT_STREAMFLAGS_LOOPBACK 当输入读回来;下游代码完全共用。
  • 抓声循环(ProcessCaptureData,:957,事件驱动):被"叮"醒 → GetNextPacketSizeGetBuffer 拿共享 PCM(约 10ms 一包)→ 填 obs_source_audio + 算时间戳 → obs_source_output_audio(:1037)→ ReleaseBuffer。骨架和第 10 课视频抓帧循环一致。
  • 引擎收声(obs_source_output_audio,obs-source.c:4029)三步:① process_audio(:3993)重采样到引擎统一格式(换一套采样率,FFmpeg swresample);② source_output_audio_data(:1572)校准/平滑时间戳(吸附 next_audio_ts_min、叠 sync_offset);③ 拼进每声道深环形缓冲 audio_input_buf[](obs-internal.h:867)。
  • 音频 vs 视频:连续流不能丢一个采样 vs 可丢帧;深缓冲 vs 浅队列;进来就重采样 vs 渲染时才转;固定节拍 pull 1024 样本/拍 vs push。根源:人耳对声音瑕疵极其敏感。

# 15.11 动手 / 观察(本课作业)

  1. 加两个音频源:OBS →"来源"→ 分别添加"音频输入采集(麦克风)"和"音频输出采集(桌面音频)"。你加的,就是本课那两张登记表(wasapi_input_capture / wasapi_output_capture)。
  2. 算一算"一小包":如果采样率是 48000 Hz、立体声、float,一个 10ms 的包有多少?多少字节?(答:480 帧;480 × 2 × 4 = 3840 字节。)再想想单声道会是多少。
  3. 理解 loopback:用自己的话讲清楚——"录系统声音"为什么要选输出端点(扬声器)、还要加 LOOPBACK 标志?(提示:系统里根本没有一个叫"电脑正在播的声音"的输入设备。)
  4. 翻源码找那一行分叉:打开 plugins/win-wasapi/win-wasapi.cpp,找到 win-wasapi.cpp:714 那三行 flags,确认 loopback 只在"不是麦克风"时才加;再找到 :597GetDefaultAudioEndpoint,看端点是怎么按 input 分叉的。
  5. 看采样率对齐:在"设置 → 音频"里看 OBS 的采样率(通常 48000)。想想:如果你的麦克风是 44100,process_audio(obs-source.c:3993)里那个 reset_resampler + audio_resampler_resample 就会把它重采样成 48000 —— 这一步视频为什么不需要?
  6. 对照视频采集:把本课的抓声循环(ProcessCaptureData)和第 10 课的抓帧循环(v4l2_thread)并排看,列出"骨架相同的 5 步"和"因为音频连续而不同的地方"。

# 下一课预告

这一课,每个音频源都把自己的采样,拼进了各自的深缓冲。可一场直播里有好几路声音(游戏声、麦克风、音乐……),它们采样率可能不同、到达时刻可能参差,最终却要混成一路、还要和画面对齐。这活儿怎么干?

第 16 课:混音与"对齐" —— 多路混合、音视频同步 —— 我们会看那根固定节拍的 audio_thread 怎么每拍从各个源的环形缓冲里取 1024 个采样、按音量加起来混成一路,以及 OBS 到底怎么让"声音"和"画面"严丝合缝地同步。音频链路最硬核的一课,下节课见。


📁 本课配图:imgs/15-01 ~ imgs/15-11 📌 源码锚点: libobs/obs.h:251(obs_source_audio)、libobs/media-io/audio-io.h:31(AUDIO_OUTPUT_FRAMES=1024)、:43(enum audio_format)、:66(enum speaker_layout)、libobs/obs-source.h:97(OBS_SOURCE_AUDIO); plugins/win-wasapi/win-wasapi.cpp:342(创建枚举器)、:590/:597(InitDevice / GetDefaultAudioEndpoint 端点分叉)、:701/:705(Activate / GetMixFormat)、:714(LOOPBACK 标志)、:784/:794(InitFormat / "always float")、:800/:811(InitCapture / Start)、:957(ProcessCaptureData)、:986(GetBuffer)、:1014(填 obs_source_audio)、:1037(obs_source_output_audio)、:1541/:1559(两张注册表); libobs/obs-source.c:4029(obs_source_output_audio)、:3993(process_audio 重采样)、:1572(source_output_audio_data 时间戳)、:1448(MAX_BUF_SIZE)、libobs/obs-internal.h:867(audio_input_buf); libobs/media-io/audio-io.c:205(audio_thread 固定节拍)。

上次更新: 2026/07/14, 19:37:40

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>