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

先建立全局。视频那条线你已经走完前半程(采集 → 合成 → 取帧);音频是另一条并行的线:采集 → 混音/对齐 → 音量/电平,最后两条线在编码 / 封装处汇合。这一课是音频线的第一站。
采集这一站干的事,一句话:把现实世界的声音,变成一个个 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":

// 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; // 这一包的采集时刻(纳秒)
};
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。

# 帧(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);
}
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);
}
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开头的接口、ComPtr、HRESULT是啥?(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 采集跑起来,准备阶段是这七步(都在 WASAPISource 的 InitDevice / InitClient / InitCapture 里),第七步之后才进入不停转的抓声循环:

逐步看关键代码。
# ①② 拿到枚举器、选设备
先 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());
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); // ★ 拿到设备的采样率 / 声道 / 位深
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; // 采样率
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);
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(); // ★ 开始采集,数据开始流动
2
3
4
5
6
GetService 要来的 IAudioCaptureClient(存进成员 capture),就是之后真正拿 PCM 的那个对象。Start() 一调,声音就源源不断地来了 —— 接下来全靠那根抓声循环去接。
# 15.6 麦克风 vs 系统声音:就差一个 loopback 标志
📍我们在哪:七步配方里埋了个
loopback标志,这一节专门讲它——"录系统声音"和"录麦克风"其实只差这一处。
停下来专门说这件妙事,因为它是理解 WASAPI 的关键。
"录系统声音"是怎么做到的? 直觉上,麦克风好理解(有个输入设备,读它就行);可"电脑正在播放的声音"没有一个"输入设备"啊?WASAPI 的答案很巧:把本该播给扬声器的声音,用 loopback(回环)当成输入读回来。

对比一下两者的全部区别:
麦克风(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;
2
系统声音选的是输出端点(扬声器),本来输出端点是"往外播"的;加上 LOOPBACK 标志后,WASAPI 让你把它当输入来读——于是"正在播出去的声音"就被你抓了回来。
而除了这两处,下游代码两者完全共用:GetMixFormat 问格式、抓声循环里 GetBuffer 拿样、填 obs_source_audio、obs_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,去接下一包
}
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); // 格式已一致,直接拷
}
}
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;
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); // 按时间戳精确落位
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 视频采集:为什么音频这么"斤斤计较"
📍收束:采集这一站走完了,这一节把音频和视频并排对比,点出音频一切设计的总纲——连续、不丢、能对齐。
把两课放一起对比,你就看清了音频这条线的"性格":

| 维度 | 视频采集(第 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 系统声音,就差两处:端点(
eCapturevseRender,:597)+ 一行loopback标志(:714)。"录系统声音" = 把输出端点用AUDCLNT_STREAMFLAGS_LOOPBACK当输入读回来;下游代码完全共用。 - 抓声循环(
ProcessCaptureData,:957,事件驱动):被"叮"醒 →GetNextPacketSize→GetBuffer拿共享 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 动手 / 观察(本课作业)
- 加两个音频源:OBS →"来源"→ 分别添加"音频输入采集(麦克风)"和"音频输出采集(桌面音频)"。你加的,就是本课那两张登记表(
wasapi_input_capture/wasapi_output_capture)。 - 算一算"一小包":如果采样率是 48000 Hz、立体声、float,一个 10ms 的包有多少帧?多少字节?(答:480 帧;480 × 2 × 4 = 3840 字节。)再想想单声道会是多少。
- 理解 loopback:用自己的话讲清楚——"录系统声音"为什么要选输出端点(扬声器)、还要加
LOOPBACK标志?(提示:系统里根本没有一个叫"电脑正在播的声音"的输入设备。) - 翻源码找那一行分叉:打开
plugins/win-wasapi/win-wasapi.cpp,找到win-wasapi.cpp:714那三行flags,确认loopback只在"不是麦克风"时才加;再找到:597的GetDefaultAudioEndpoint,看端点是怎么按input分叉的。 - 看采样率对齐:在"设置 → 音频"里看 OBS 的采样率(通常 48000)。想想:如果你的麦克风是 44100,
process_audio(obs-source.c:3993)里那个reset_resampler+audio_resampler_resample就会把它重采样成 48000 —— 这一步视频为什么不需要? - 对照视频采集:把本课的抓声循环(
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 固定节拍)。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言