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

第 17 课:音量、电平表与监听 —— fader / volmeter

# 第 17 课:音量、电平表与监听 —— fader / volmeter

嘿,我是小方。 第 16 课我们把多路声音混成了一路,还给每一路乘了个音量 vol。这一课收尾模块 4,把音量这件事讲透,再补上两个你天天在 OBS 界面上看到、却没细想过的东西: —— 那个音量滑块(推子 fader),你拖到"一半",声音真的是一半吗?(剧透:默认曲线下,只有 1/8。) —— 那根随声音跳动的绿黄红条(电平表 volmeter),它量的是什么?为什么跳得快、落得慢? —— 还有"监听":怎么让一路声音只进你的耳机、不进直播?它的代码,居然是第 15 课采集的镜像。 讲完这三样,声音这条线就和视频一样,走完了"前半程"。这一课有点数字,但都很好懂,跟着走。


# 17.1 背景:先搞懂"分贝(dB)"

📍接上一节:上一课那个乘数 vol,界面上却是用 dB 标的——收尾模块 4 之前,这一节先把 dB 和 mul 的关系理清。

要聊音量和电平,绕不开一个词:分贝(dB)。OBS 里音量滑块旁边、电平表刻度上,标的全是 dB。先花两分钟把它和第 16 课那个乘数 vol(代码里叫 mul)的关系理清。

分贝刻度尺

dB 和 mul,是同一件事的两种说法,可以来回换算。 换算公式就在 media-io/audio-math.h(第 15、16 课那个数学头文件):

// libobs/media-io/audio-math.h:31
static inline float mul_to_db(const float mul)
{
	return (mul == 0.0f) ? -INFINITY : (20.0f * log10f(mul));   // 乘数 → 分贝
}

static inline float db_to_mul(const float db)                      // :36
{
	return isfinite((double)db) ? powf(10.0f, db / 20.0f) : 0.0f;  // 分贝 → 乘数
}
1
2
3
4
5
6
7
8
9
10

记几个关键刻度就够了:

mul(乘数) dB 含义
1.0 0 dB 满量程(最大,再大就削顶爆音)
0.5 ≈ -6 dB 振幅减半
0.1 -20 dB
0.01 -40 dB 很轻
0 负无穷 dB 静音

为什么音频要用 dB,不直接用 mul? 因为人耳对响度是"对数"感知的:从 0.0010.01、和从 0.11.0,乘数上差了 100 倍 vs 10 倍,可你听起来"都差不多一档"。dB 正是把这种对数感受,拉成了等间距的刻度——每 -6dB 振幅减半、每 -10dB 大约"响度减半"。于是"耳语到轰鸣"这种天差地别的动态范围,用一根几十档的尺子就能舒服地标出来、也好精细地调。

记住这把尺子:接下来推子是往里"写" dB,电平表是把声音"量"成 dB 显示出来。


# 17.2 推子(fader):为什么滑块拖到一半,不是响一半

📍我们在哪:dB 这把尺子备好了,先看往里"写"音量的那头——推子,以及"拖到一半只有 1/8"这个反直觉的曲线。

先说音量滑块。OBS 里管它叫推子(fader),就是调音台上那种上下推的滑杆。这里藏着一个新手几乎都会想错的点。

推子曲线

你拖的是"滑块位置"(0~1,代码里叫 deflection),它和真正的音量 mul 之间,不是一条直线,而是一条曲线。 OBS 默认用立方(cubic)曲线,关系简单到你会心一笑:

vol = 滑块位置³(位置的三次方)

举几个例子:

  • 位置 1.01.0³ = 1.0(满音量)
  • 位置 0.750.75³ ≈ 0.42
  • 位置 0.50.5³ = 0.125 —— 拖到一半,音量只有 1/8!
  • 位置 0.10.1³ = 0.001(几乎无声)

为什么要这么"弯"一下,而不是老老实实线性? 因为 17.1 说的:人耳是对数感知的。这条曲线把滑块的大半行程,都留给了"安静那一端"——于是你在小音量区能精细微调(轻轻推一点,音量一点点变),而不是滑一丁点就从"没声"跳到"震天响"。这才叫"跟手"。除了默认的立方,OBS 还提供 IEC(广播行业标准曲线)和对数两种备选(obs-audio-controls.h:36enum obs_fader_type)。

所以:你拖动的是滑块位置,引擎按曲线把它换算成真正的音量。 下面看这条换算链的代码。


# 17.3 源码深读:从"滑块位置"到第 16 课那个 vol

📍承上启下:曲线的道理讲完了,这一节走一遍它的代码链——滑块位置怎么一步步变成第 16 课逐采样乘进去的那个 vol

推子的入口是 obs_fader_set_deflection(obs-audio-controls.c:621)—— UI 拖动滑块,就调它,传进去一个 0~1 的位置:

推子源码链

// libobs/obs-audio-controls.c:621
bool obs_fader_set_deflection(obs_fader_t *fader, const float def)
{
	if (!fader)
		return false;
	return obs_fader_set_db(fader, fader->def_to_db(def));   // ① 滑块位置 → dB(按曲线)
}
1
2
3
4
5
6
7

那句 fader->def_to_db(def) 是个函数指针,指向当前曲线的换算函数。默认立方曲线,它指向 cubic_def_to_db(obs-audio-controls.c:81):

// libobs/obs-audio-controls.c:81
static float cubic_def_to_db(const float def)
{
	if (def == 1.0f)
		return 0.0f;
	else if (def <= 0.0f)
		return -INFINITY;

	return mul_to_db(def * def * def);   // ★ 位置³ → 再转成 dB
}
1
2
3
4
5
6
7
8
9
10

看到没,def * def * def 就是"位置的立方",再用 17.1 的 mul_to_db 转成 dB。接着 obs_fader_set_db(:578)把这个 dB 夹到范围内、再转回乘数、最后写进源:

// libobs/obs-audio-controls.c:578(节选)
bool obs_fader_set_db(obs_fader_t *fader, const float db)
{
	...
	fader->cur_db = db;
	if (fader->cur_db > fader->max_db) fader->cur_db = fader->max_db;      // ② 夹上限(立方曲线 max=0dB)
	if (fader->cur_db < fader->min_db) fader->cur_db = -INFINITY;          //    夹下限
	...
	const float mul = db_to_mul(fader->cur_db);   // ③ dB → 乘数
	...
	if (src)
		obs_source_set_volume(src, mul);      // ④ ★ 写进 source->volume
	return !clamped;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

跟着数字走一遍(位置 0.5):def_to_db(0.5) = mul_to_db(0.125)-18 dB → 夹范围(在 -∞~0 内,不动)→ db_to_mul(-18)0.125obs_source_set_volume(src, 0.125)

终点那个 0.125,正是第 16 课 get_source_volume 读出来、逐采样乘进去的那个 vol!(绕了一圈 dB,对立方曲线来说净效果就是 vol = def³;但走 dB 这条路,是为了让 IEC、对数那些曲线也能用同一套框架。)推子和混音,在这里严丝合缝地接上了:你拖滑块 → 按曲线算出 vol → 存进源 → 混音时每个采样 ×它


# 17.4 电平表(volmeter):峰值与 RMS,量的是两种"响"

📍转折:推子是往里写音量,这一节掉头看"读"的那头——电平表,它把声音量成两种"响":峰值和 RMS。

再看那根随声音跳动的条 —— 电平表(volmeter)。它在实时告诉你"这一路现在有多响"。但"多响"其实有两种量法,OBS 都给你:

峰值与 RMS

  • 峰值(peak):这一小段采样里,最大的那个绝对值。它是"防爆表"——峰值一冲到 0 dB,就意味着采样超过了满量程、削顶爆音(clipping)。你盯着它,是为了别让声音爆掉。
  • RMS(均方根,代码里叫 magnitude):所有采样先平方、求平均、再开根号,得到"能量的平均值"。它更贴近你主观觉得有多响(人耳感受的是平均能量,不是某个瞬间的尖峰)。

举个直觉例子:一段大部分很轻、偶尔"啪"一下的声音,峰值会很高(那一下尖峰),但RMS很低(整体很轻)。两个数一起看,你才知道"整体不吵,但有瞬间尖峰要小心"。


# 17.5 源码深读:libobs 怎么把声音"量"成两个数

📍我们在哪:知道要量峰值和 RMS 了,这一节进代码看它俩怎么算出来,再乘音量、转成 dB,一次给界面三个值。

电平计算的入口,是挂在源上的一个音频回调 volmeter_source_data_received(obs-audio-controls.c:492)——每来一批采样就被调一次(第 16 课讲过源会把音频"广播"给所有注册的回调,这就是其中一个)。它调 volmeter_process_audio_data(:484),里面就两步:算峰值、算 RMS。

电平表源码

峰值 —— 找最大绝对值(get_sample_peak,:366,真实代码用 SIMD 一次比 4 个,但意思就是):

peak = 0;
for (每个采样 s)
    peak = max(peak, fabsf(s));   // 绝对值,取最大
1
2
3

RMS —— 平方、平均、开根(volmeter_process_magnitude,obs-audio-controls.c:462):

// libobs/obs-audio-controls.c:473(节选)
float sum = 0.0;
for (size_t i = 0; i < nr_samples; i++) {
	float sample = samples[i];
	sum += sample * sample;                          // 平方累加
}
volmeter->magnitude[channel_nr] = sqrtf(sum / nr_samples);   // 均方根 = RMS
1
2
3
4
5
6
7

算完这两个"乘数域"的值,再乘上用户音量、转成 dB,一次给 UI 三个 dB 值(volmeter_source_data_received,:506):

// libobs/obs-audio-controls.c:506(节选)
mul = muted ? 0.0f : db_to_mul(volmeter->cur_db);        // 用户音量(推子后)
for (每个声道) {
	magnitude[ch]  = mul_to_db(volmeter->magnitude[ch] * mul);  // 推子后的 RMS
	peak[ch]       = mul_to_db(volmeter->peak[ch] * mul);       // 推子后的峰值
	input_peak[ch] = mul_to_db(volmeter->peak[ch]);            // 推子前的峰值(看输入增益)
}
...
signal_levels_updated(volmeter, magnitude, peak, input_peak);   // :518 交给 UI
1
2
3
4
5
6
7
8
9

那个 input_peak(推子前的峰值)很贴心:它不受你的音量滑块影响,让你能判断"是不是输入端(麦克风增益)本身就录爆了"。

这里有个关键分工要记住:libobs 只算出"这一瞬间"的三个 dB 值,然后 signal_levels_updated 发给界面。 至于那根条怎么"冲上去、慢慢掉、还留个小标记"——那是界面(UI) 拿到这些瞬时值后自己做出来的,叫弹道(ballistics)。下一节专讲。


# 17.6 弹道(ballistics):为什么表针跳得快、落得慢

📍承上启下:libobs 只给一个个瞬时点,可你看到的条会"冲得快、落得慢"——这一节看界面怎么把这些点整形成好读的动条。

你盯着电平表会发现:声音一大,条唰地就冲上去;声音一停,条却慢慢地往下掉,顶端还常留一个短横标记一下再退。这个"跳得快、落得慢"的行为,叫弹道

弹道

它不在 libobs 里,而在界面代码 frontend/components/VolumeMeter.cpp(第 26、27 课会细讲前端)。 libobs 每次只给一个瞬时点,UI 把这些点"整形"成好看又好读的动条,遵三条规矩:

// frontend/components/VolumeMeter.cpp:512(calculateBallisticsForChannel,节选)
if (currentPeak[channelNr] >= displayPeak[channelNr] || isnan(displayPeak[channelNr])) {
	displayPeak[channelNr] = currentPeak[channelNr];   // ① 冲顶:立即(峰值一来,条马上顶上去)
} else {
	float decay = float(peakDecayRate * timeSinceLastRedraw);   // ② 回落:匀速慢慢掉
	displayPeak[channelNr] = std::clamp(displayPeak[channelNr] - decay,
	                                    std::min(currentPeak[channelNr], 0.f), 0.f);
}
1
2
3
4
5
6
7
8

三条规矩:

  • ① 冲顶立即:新峰值一来,条马上跳到那个高度(attack 是即时的)。
  • ② 回落匀速慢:没有新峰值时,条按固定速率往下掉。速率是 peakDecayRate = 11.76(VolumeMeter.cpp:322),即 约 20 dB / 1.7 秒
  • ③ 峰值保持:顶端那个小标记,会在最高处停 20 秒(peakHoldDuration,:324)再退——方便你回头看"刚才最高冲到过哪"。

为什么要这么设计?给人眼时间看清。 声音的瞬时峰值每秒变几十上百次,如果条也跟着这么快闪,你的眼睛根本读不出数;"冲得快"保证你不错过尖峰,"落得慢"让你看得清。颜色也是 UI 定的(VolumeMeter.cpp:317 一带):-20dB 以下绿-20~-9 、逼近 0dB ——看到条冲红,就是快削顶爆音了,赶紧拉推子。

💡 一次漂亮的分层:libobs(引擎)负责"量得准"(瞬时 peak/RMS),frontend(界面)负责"显示得好看、看得懂"(弹道 + 配色)。各司其职 —— 这正是"引擎 / 界面"分层的一个缩影(第 26、27 课会从界面视角再看)。


# 17.7 监听(monitoring):在自己耳机里听一路声音

📍转折:推子、电平表之后的最后一件旁路功能——监听,让一路声音只进你耳机、不进直播。它的代码正好是第 15 课采集的镜像。

最后一件:监听。有时你想自己听某一路声音(检查麦克风、给自己垫个提示音),但不一定想让观众听到。这就是监听要解决的,它区分了两件事:"进不进推流"和"进不进你的耳机"。

监听

OBS 用一个三值枚举表达(obs.h:1360):

enum obs_monitoring_type {
	OBS_MONITORING_TYPE_NONE,               // 不监听:正常推流,但你耳机里不单独听它
	OBS_MONITORING_TYPE_MONITOR_ONLY,       // 只监听:你听得到,但【不进推流】
	OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT, // 监听并输出:你听得到,也进推流
};
1
2
3
4
5
类型 进推流/录制? 进你的耳机?
NONE
MONITOR_ONLY
MONITOR_AND_OUTPUT

"只监听"怎么做到不进推流?就靠第 16 课混音路径里的一句 if(obs-source.c:1650):if (source->monitoring_type != OBS_MONITORING_TYPE_MONITOR_ONLY) 才把采样放进混音总线——是 MONITOR_ONLY 就跳过,于是它进不了推流,却仍会被"广播"给监听回调,照样送你耳机。

# 监听的代码,是第 15 课采集的镜像

设监听类型走 obs_source_set_monitoring_type(obs-source.c:5559),从"无"变"有"时创建一个监听输出:

// libobs/obs-source.c:5580(节选)
if (was_on != now_on) {
	if (!was_on)
		source->monitor = audio_monitor_create(source);   // 建监听输出
	else
		audio_monitor_destroy(source->monitor);
}
1
2
3
4
5
6
7

这个 audio_monitor(Windows 上在 audio-monitoring/win32/wasapi-output.c)是什么?它就是第 15 课那个 WASAPI 采集的镜像 —— 一个 WASAPI 播放流。 第 15 课我们用 WASAPI 麦克风;监听则用 WASAPI 扬声器/耳机。同样六步调用,方向相反:

第 15 课:采集(读进来) 本课:监听(播出去)
eCapture 端点(麦克风) eRender 端点(扬声器/耳机)
GetService(IAudioCaptureClient) GetService(IAudioRenderClient)(:235)
GetBuffer读走设备采样 GetBuffer写进设备(:323)
ReleaseBuffer 还缓冲 ReleaseBuffer 提交播放(:340)

播放的那一下,在 on_audio_playback(wasapi-output.c:279)——源每来一批采样就调它:先重采样成监听设备的采样率(设备可能 48kHz、也可能别的),再 GetBuffer 拿到设备写缓冲、memcpy 进去、ReleaseBuffer 提交:

// libobs/audio-monitoring/win32/wasapi-output.c:322(节选)
hr = render->lpVtbl->GetBuffer(render, resample_frames, &output);   // 拿设备写缓冲
...
memcpy(output, resample_data[0], resample_frames * monitor->channels * sizeof(float));  // 写入
hr = render->lpVtbl->ReleaseBuffer(render, resample_frames, muted ? AUDCLNT_BUFFERFLAGS_SILENT : 0);  // 提交播放
1
2
3
4
5

看懂第 15 课,你就自动看懂了监听 —— 这就是"一切对称"的妙处:采集是把设备的声音读进 OBS,监听是把 OBS 的声音写回设备,两段代码像照镜子。(监听设备选哪个由 obs->audio.monitoring_device_id 决定,obs.c:2981obs_set_audio_monitoring_device 设置。)


# 17.8 模块 4 收官:声音的完整前半生

📍收束:声音这条线和视频一样走完前半程了,这一节回头把采集→缓冲→混音对齐→推子/电平表/监听串成一条线,交棒给编码。

到这一课,声音这条线,和视频一样走完了前半程:

模块 4 收官

  • 采集(第 15 课):麦克风/系统声 → obs_source_audio;
  • 每源缓冲(第 15 课):重采样统一格式、按时间戳入环形缓冲;
  • 混音 + 对齐(第 16 课):固定节拍每拍 ×音量、逐采样求和、多源对齐、音画同步;
  • 交给编码(模块 5):锚定视频起点、音画同步。

本课这三件套,是挂在每个源"旁边"的:

  • 推子(fader):改音量,作用在"进混音之前"(改的是 source->volume,第 16 课乘它);
  • 电平表(volmeter):旁路观测 —— 不改声音,只是把它量成 dB 显示给你看;
  • 监听(monitor):旁路播放 —— 把这一路额外送一份到你耳机。

一根时间戳贯穿采集到编码,让音频和视频最终严丝合缝地同步(第 16 课)。声音的前半程,到此打通。

下一站:视频和音频两条流,一起进入"编码"—— 模块 5。 还记得第 5 课那个吓人的裸数据体积吗?接下来就是把它压缩成能推、能存的码流的故事。


# 17.9 本课小结

  • 分贝(dB):音量/电平的通用刻度,和乘数 mul 可互换 —— dB = 20·log10(mul)mul = 10^(dB/20)(audio-math.h:31/36)。mul=1→0dB(满)、0.5→-6dB0→-∞(静音)。用 dB 是因为人耳对响度对数感知,dB 把它拉成等间距刻度。
  • 推子(fader):你拖的是"滑块位置"(deflection 0~1),它按曲线换算成音量。默认立方曲线 vol = 位置³ —— 拖到一半 → 音量只有 0.125(1/8),把大半行程留给小音量段以便精细调。还有 IEC / 对数曲线(obs_fader_type,obs-audio-controls.h:36)。
  • 推子源码链:obs_fader_set_deflection(:621)→ cubic_def_to_db = mul_to_db(def³)(:88)→ 夹范围 → db_to_mul(:599)→ obs_source_set_volume(:603)= 第 16 课那个 vol
  • 电平表(volmeter):量两种"响" —— 峰值(peak)= max(|采样|)(防爆表,冲 0dB 就削顶);RMS(magnitude)= sqrt(采样²的平均)(贴近主观响度)。计算在 volmeter_source_data_received(obs-audio-controls.c:492)→ peak(:366)+ magnitude(:462),再 ×音量、mul_to_db,一次给 UI 三个值(magnitude/peak/input_peak,:518)。
  • 弹道(ballistics):libobs 只给瞬时值;界面(VolumeMeter.cpp:512)做"冲顶立即、回落匀速慢(peakDecayRate=11.76≈20dB/1.7s)、峰值保持 20s"+ 绿黄红配色。分工:引擎量得准,界面显示得好懂。
  • 监听(monitoring):三态(obs.h:1360)—— NONE(进推流不进耳机)/ MONITOR_ONLY(进耳机不进推流,靠 obs-source.c:1650 一句 if 跳过混音)/ MONITOR_AND_OUTPUT(都进)。Windows 上监听 = WASAPI 播放流,第 15 课采集的镜像:eRender 端点 + IAudioRenderClient + GetBuffer/ReleaseBuffer 设备(wasapi-output.c:235/:323/:340)。
  • 模块 4 收官:采集 → 缓冲 → 混音对齐 → (推子调音量 / 电平表观测 / 监听旁听)→ 编码。时间戳贯穿全程保证音画同步。

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

  1. 验证"一半不是一半":OBS 混音器里,把某路音量拖到大约一半,右键 →"高级音频属性"看它的百分比/dB。想想 vol = 位置³:一半位置对应约 0.125 ≈ -18dB,和显示对得上吗?
  2. 看峰值 vs RMS:对着麦克风先持续"啊——"(稳定),再"啪"地拍一下手(尖峰)。观察电平表:哪种声音让峰值(条的最高点/顶端标记)冲得高、哪种让整条(RMS)亮得高?
  3. 玩监听三态:右键一个音频源 →"高级音频属性"→"音频监听",在"监听关闭 / 仅监听(输出静音)/ 监听并输出"之间切换,戴上耳机感受区别。对照 17.7 那张表,仅监听对应哪个枚举?
  4. 翻源码找那句 if:打开 libobs/obs-source.c:1650,确认"是 MONITOR_ONLY 就不进混音总线"的那句判断;再到 obs-audio-controls.c:88,确认立方曲线就是 mul_to_db(def*def*def)
  5. 对照采集与监听:把第 15 课的 WASAPI 采集六步,和本课 wasapi-output.c 的监听六步并排列出来,标出每一步"读 vs 写"的方向差异。

# 下一课预告

音频、视频两条前半程都走完了,交出来的是一串没压缩的裸数据(第 5 课算过:1080p60 的裸视频每秒好几个 GB!)。这么大的体积,根本没法推流、也存不起。怎么办?压缩

第 18 课:视频为什么能被压缩这么狠 —— I/P/B 帧、关键帧、GOP —— 我们进入模块 5,先把"视频凭什么能压到原来的百分之一还看不出损失"这件事的原理讲透:相邻帧的冗余、帧内 vs 帧间压缩、关键帧和 GOP。这是理解编码器的地基。下节课见,模块 5 开始!


📁 本课配图:imgs/17-01 ~ imgs/17-08 📌 源码锚点: libobs/media-io/audio-math.h:31/36(mul_to_db / db_to_mul); libobs/obs-audio-controls.c:44(struct obs_fader)、:81/:88(cubic_def_to_db=mul_to_db(def³))、:101(iec 曲线)、:578(obs_fader_set_db 夹范围+db_to_mul)、:599/:603(→ obs_source_set_volume)、:621(obs_fader_set_deflection);libobs/obs-audio-controls.h:36(enum obs_fader_type); libobs/obs-audio-controls.c:64(struct obs_volmeter)、:366(峰值 get_sample_peak)、:462(RMS volmeter_process_magnitude)、:492(volmeter_source_data_received)、:506(×音量+mul_to_db)、:518(signal_levels_updated)、:841(add_callback); frontend/components/VolumeMeter.cpp:512(弹道 calculateBallisticsForChannel)、:322(peakDecayRate=11.76)、:317(配色/阈值); libobs/obs.h:1360(enum obs_monitoring_type)、libobs/obs-source.c:5559(set_monitoring_type)、:1650(MONITOR_ONLY 跳过混音);libobs/audio-monitoring/win32/wasapi-output.c:148(init 打开 render 客户端)、:235(GetService IAudioRenderClient)、:279(on_audio_playback)、:323/:340(GetBuffer/ReleaseBuffer 播放);libobs/obs.c:2981(obs_set_audio_monitoring_device)。

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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