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

第 16 课:混音与"对齐" —— 多路混合、音视频同步

# 第 16 课:混音与"对齐" —— 多路混合、音视频同步

嘿,我是小方。 上一课(第 15 课),每个音频源都把自己的采样,重采样成统一格式、按时间戳拼进了各自的深环形缓冲。可一场直播里有好几路声音(游戏声、麦克风、背景音乐……),它们最终要混成一路、还要和画面严丝合缝地对齐。 这一课就干这两件事,也是音频链路最硬核的一课。好消息是:混音的核心,朴素到你可能不敢信 —— 就是"把几路声音的采样值加起来"。难的从来不是"加",而是"对齐":多个源怎么对到同一时刻?声音又怎么跟画面同步?我们把这三块(相加、多源对齐、音画同步)一块块拆开,并且尽量用你熟悉的画面(一张调音台)和真实的数字来讲。


# 16.1 背景:混音,就是把声波"加起来"

📍接上一节:上一课每个源都把采样拼进了自己的深缓冲,这一节开始把多路合成一路——先建立最朴素的直觉:混音就是把采样加起来。

先建立最重要的直觉。

混音概念

混音(mixing)= 把每一路声音,在"同一时刻"的采样值,相加起来。 就这么朴素。

为什么能直接相加?回到第 4 课:声音的物理本质,是空气气压的起伏。几个声源同时响(你一边打游戏一边说话,音箱还放着歌),它们在你耳朵处引起的气压变化,物理上本来就是叠加的。数字世界只是照抄这个物理事实:把每一路在同一时刻的采样值加在一起,就是混音后的那一路。

这里立刻带出两个后续要解决的点:

  • 相加可能"爆表":两路都很响时,0.8 + 0.7 = 1.5,超过了满量程 ±1.0。这正是第 15 课说的"采集/处理阶段用 float(留余量)"的原因 —— float 存得下 1.5,不会立刻失真;等全部混完,再统一压回 −1~1(这一步叫 clamp)。
  • "同一时刻"不好凑:各路声音到达引擎的时刻参差不齐、采样率原本也不同。要相加,就得先让它们对齐到同一个采样位置。这就是本课后半的重头戏。

还有一件事:每一路在相加前,通常要先按音量放大或缩小。所以完整的混音是:每路 × 各自音量 → 按时刻对齐 → 逐采样相加 → clamp

📖 四个词,先记牢(后面反复出现):

  • 采样(sample):声波上的一个数值点(第 4 课)。混音处理的最小单位。
  • 帧(frame):同一时刻、所有声道各出一个采样打成的一组(立体声 1 帧 = 2 采样,第 15 课)。区别于"采样"——采样是一个数,帧是一竖列。
  • 拍(tick):混音的"一步"。引擎不是一个采样一个采样地混,而是一拍一拍地混,每拍固定 1024 个采样(16.3、16.4 详解)。
  • 总线 / 混音(bus / mix):各路声音相加汇成的那"一路"。就是调音台上的"主输出"(下一节的比喻)。

下面逐块来。但在钻代码之前,先给你一张熟悉的画面当"地图"。


# 16.2 一个熟悉的画面:混音,就是一张调音台

📍承上启下:钻代码之前,先给你一张"地图"——调音台。后面每段代码,都能对回台子上的某个部件。

如果你见过调音台(录音棚里那种一排推子的大台子),或者哪怕只是 OBS 界面下方那个"音频混音器",那你其实已经懂混音了 —— 这一课剩下的所有代码,都是在实现这张台子

调音台比喻

对应关系一一对上:

调音台上的东西 OBS 里 代码里
一条通道条(channel strip) 一路音频源(麦克风/游戏声/音乐) 一个 source
通道上的推子(fader) 那一路的音量滑块 乘数 vol
把各路 ×推子后全加起来 混音 mix_audio(逐采样相加)
主输出总线(master) 你最终听到/推出去的那一路 mixes[]

"源 = 通道条,音量滑块 = 推子,把各路 ×推子后全加起来 = 主输出总线" —— 记住这张图,后面每一段代码,你都能对回到台子上的某个部件。接下来我们就顺着"谁在混、混的时候一拍装了什么、怎么乘音量、怎么加起来、怎么对齐"一路走下去。


# 16.3 音频的心跳:固定节拍,每拍取 1024 个采样

📍我们在哪:先看"谁在混、多久混一次"——音频也有一根心跳线程,但它按固定节拍、主动去每个源那里取数。

先看"谁在混、多久混一次"。第 14 课讲过视频有一根图形线程当"心跳";音频也有一根 —— audio_thread(audio-io.c:205),但它的节奏和取数方式不一样。

音频固定节拍

它按固定节拍转,每一拍处理 1024 个采样(AUDIO_OUTPUT_FRAMES = 1024,audio-io.h:31;在 48kHz 下 ≈ 21.3 毫秒)。核心循环就几行:

// libobs/media-io/audio-io.c:223(节选)
while (os_event_try(audio->stop_event) == EAGAIN) {
	samples += AUDIO_OUTPUT_FRAMES;                              // 又推进 1024 个采样
	uint64_t audio_time = start_time + audio_frames_to_ns(rate, samples);  // 这一拍结束的绝对时刻
	os_sleepto_ns_fast(audio_time);                             // 精确睡到那一刻
	...
	input_and_output(audio, audio_time, prev_time);             // ★ 这一拍:去混音、输出
	prev_time = audio_time;
}
1
2
3
4
5
6
7
8
9

和第 14 课视频的 video_sleep一个套路:os_sleepto_ns_fast 把循环牢牢按在时间轴的网格点上,每拍准点开工。[prev_time, audio_time) 这个区间,就是这一拍要处理的时间窗,它被交给下面的混音回调。

关键区别在于取数方式:

  • 视频那边,是采集线程**推(push)**帧、渲染线程按需取;
  • 音频这边,是 audio_thread 主动来取(pull)——它踩着固定节拍,自己去每一个源的深缓冲里,取"这一拍该要的那 1024 个采样"。

两根独立的心脏,各按各的节拍跳(视频每帧一次、音频每拍一次),互不打断;最后在编码处按时间戳对齐(16.10)。为什么音频要用"主动 pull + 固定节拍"?因为混音必须严格等间隔地产出,才能拼成一条时间轴笔直的声音流(呼应第 15 课"音频最怕节奏乱")。


# 16.4 先补课:一"拍"到底装了什么(以及两个 buffer)

📍承上启下:钻进混音代码前,这一节先把"一拍"和"两个 buffer"看实了——仓库和工作台,分清这俩,后面全不抽象。

在钻进混音代码前,花两分钟把"一拍"和"两个 buffer"看实了 —— 这是新手最容易犯迷糊的地方,看清了后面全不抽象。

一拍装了什么

# 一拍 = 每声道 1024 个采样

上一节说的"这一拍的时间窗 [start, end)",具体是什么?就是一段装着 1024 个采样的时间(每个声道 1024 个),在 48kHz 下约 21.3 毫秒。混音一次,就处理这么一窗。所以你可以把"一拍"想象成一小格胶片:引擎一格一格地混,每格 1024 个采样宽。

# 别混淆:一个源身上有两个 buffer

这是关键澄清。第 15 课我们往源里灌数据,进的是 audio_input_buf;这一课混音,取的却是 audio_output_buf。它俩不是一个东西:

  • audio_input_buf —— 深环形缓冲(仓库)(obs-internal.h:867)。第 15 课灌进来的采样,排着队存在这里,能存好几秒(约 1000 拍)。采集线程往这头塞,混音线程从那头取。它,是为了兜底"来得早晚不一"(第 15 课的对齐)。
  • audio_output_buf —— 这一拍的工作副本(工作台)(obs-source.c:5419)。只装这一拍的 1024 个采样。乘音量、被加进总线,都在它身上发生。它,因为只服务当下这一拍。

一拍怎么从"仓库"搬到"工作台"?就在第 ③ 步 obs_source_audio_renderprocess_audio_source_tick(obs-source.c:5405)里:

// libobs/obs-source.c:5412(节选)
if (source->audio_input_buf[0].size < size) {   // 仓库里还不够一拍的量?
	source->audio_pending = true;            // 标记「这源这拍没准备好」
	return;
}
...
deque_peek_front(&source->audio_input_buf[ch],           // ★ 从仓库头部「看一眼」
                 source->audio_output_buf[0][ch], size); // 拷 1024 个采样到工作台
1
2
3
4
5
6
7
8

deque_peek_front 是"看一眼、拷走,但先不弹出"—— 因为这一拍还没真正混完(万一要加缓冲对齐呢)。等混完了,再由 discard_audio 把仓库里这批正式弹掉。

💡 一句话记牢:input_buf 是"仓库"(深、存好几秒);output_buf 是"工作台"(浅、只放这一拍)。 每一拍,混音线程从仓库 peek 出 1024 个采样搬到工作台,在工作台上乘音量、相加,然后仓库把这批弹掉。后面所有代码,都在这两个 buffer 之间搬运。


# 16.5 源码深读:一次混音 tick,干了什么

📍我们在哪:节拍、一拍、两个 buffer 都备齐了,这一节进那根线程每拍调一次的回调——混音的现场,五步走完。

那根线程每拍调一次的回调,是 audio_callback(obs-audio.c:552)。它就是混音的现场,五步走完:

一次混音 tick

// libobs/obs-audio.c:552(抽骨架)
bool audio_callback(void *param, uint64_t start_ts_in, uint64_t end_ts_in, uint64_t *out_ts,
                    uint32_t mixers, struct audio_output_data *mixes)
{
	struct ts_info ts = {start_ts_in, end_ts_in};   // ① 这一拍的时间窗
	...
	da_resize(audio->render_order, 0);               // ② 收集所有活跃音频源(下面填进这张表)
	da_resize(audio->root_nodes, 0);
	...
	for (size_t i = 0; i < audio->render_order.num; i++) {
		obs_source_t *source = audio->render_order.array[i];
		obs_source_audio_render(source, mixers, channels, sample_rate, audio_size);  // ③ 逐源:取采样 + 乘音量
	}
	...
	for (size_t i = 0; i < audio->root_nodes.num; i++) {          // ④ 逐源:加进混音总线
		obs_source_t *source = audio->root_nodes.array[i];
		if (source->audio_output_buf[0][0] && source->audio_ts)
			mix_audio(mixes, source, channels, sample_rate, &ts);
	}
	...
	while (source) {                                             // ⑤ 逐源:丢掉已消费的采样
		discard_audio(audio, source, channels, sample_rate, &ts);
		source = source->next_audio_source;
	}
	*out_ts = ts.start;
	...
}
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
26
27

五步(对回 16.2 那张调音台:③ 是"每条通道 ×推子",④ 是"全加进主输出"):

  1. ① 圈定时间窗(:560):把 [start, end) 打包成 ts —— 这一拍要覆盖的时间范围。
  2. ② 收集活跃音频源(:564):把当前所有出声的源(通道条)收集成一张表。
  3. ③ 逐源渲染(obs_source_audio_render,obs-source.c:5455):对每个源,从仓库 peek 出这一拍的 1024 个采样搬到工作台(16.4),并乘上音量(16.7)。
  4. ④ 逐源混音(mix_audio,调用点 :687;函数定义在 :90,16.6 细看):把每个源的工作台采样 加进 6 条混音总线(16.6)。
  5. ⑤ 逐源丢弃(discard_audio,调用点 :700;定义在 :223):把这一拍已经混完、用掉的采样,从环形缓冲里弹出去,腾地方给后面的。

最后 clamp 回 −1~1、把这一拍的混音结果输出给编码。

💡 为什么是 6 条混音总线? MAX_AUDIO_MIXES = 6(audio-io.h:28)。相当于调音台有 6 组"主输出",不同输出可以要不同的混音:比如你推流那一路带麦克风,本地录制那一路不带(或把麦克风单独存一条轨,方便后期)。所以每个源会同时混进 6 条总线,各个输出按需挑一条用。这一课我们只关注"怎么混进一条",6 条只是同一动作重复 6 遍。


# 16.6 求和的核心:一句 *(mix++) += *(aud++)

📍承上启下:五步里的第 ④ 步"加进总线",剥到最里面就是这一节这句——两个数组逐元素相加,混音真正落到 CPU 上的样子。

第 ④ 步的 mix_audio(obs-audio.c:90),是混音真正落到 CPU 上发生的地方。剥到最里面,就是两个数组逐元素相加:

mix_audio 求和

// libobs/obs-audio.c:90(节选核心)
static inline void mix_audio(struct audio_output_data *mixes, obs_source_t *source,
                             size_t channels, size_t sample_rate, struct ts_info *ts)
{
	size_t total_floats = AUDIO_OUTPUT_FRAMES;   // 1024
	size_t start_point = 0;

	if (source->audio_ts < ts->start || ts->end <= source->audio_ts)
		return;                              // 这个源的采样不在本拍窗内,跳过

	if (source->audio_ts != ts->start) {         // 起始时刻比窗口起点晚一点?
		start_point = convert_time_to_frames(sample_rate, source->audio_ts - ts->start);  // 算出偏移
		if (start_point == AUDIO_OUTPUT_FRAMES)
			return;
		total_floats -= start_point;
	}

	for (size_t mix_idx = 0; mix_idx < MAX_AUDIO_MIXES; mix_idx++) {
		for (size_t ch = 0; ch < channels; ch++) {
			register float *mix = mixes[mix_idx].data[ch];        // 混音总线(目的地)
			register float *aud = source->audio_output_buf[mix_idx][ch];  // 这个源的采样(工作台)
			register float *end;

			mix += start_point;               // 按时间戳对齐:从窗里第 start_point 个位置开始
			end = aud + total_floats;

			while (aud < end)
				*(mix++) += *(aud++);     // ★★ 逐采样:把源加进混音总线
		}
	}
}
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
26
27
28
29
30
31

看那句 *(mix++) += *(aud++) —— 这就是"混音"这个听起来很玄的词,在 CPU 里真正干的事:把这个源的每一个采样,加到混音总线对应位置上。有几个源,就把这个函数调几遍,一层层叠加上去,最后混音总线里就是所有源之和。一秒钟、一个源、一个声道,这句就要执行 48000 次;几个源几个声道叠起来,一秒几百万次加法。

那个 start_point 是理解"对齐"的第一把钥匙,下一节(16.8)专门讲它,16.9 还会给一个具体数字的例子。


# 16.7 音量:相加之前,先乘个系数

📍接上一节:退回到第 ③ 步——每个源加进总线之前先按音量缩放过,这一节看那个"推子"在代码里就是一个乘数 vol

回到第 ③ 步:每个源在被混进总线之前,先按自己的音量缩放过了(就是拖那个"推子")。"音量"在代码里,就是一个乘数

音量

真正乘的地方在 multiply_output_audio(obs-source.c:5215),又是一个逐采样的循环:

// libobs/obs-source.c:5215
static inline void multiply_output_audio(obs_source_t *source, size_t mix, size_t channels, float vol)
{
	register float *out = source->audio_output_buf[mix][0];
	register float *end = out + AUDIO_OUTPUT_FRAMES * channels;

	while (out < end)
		*(out++) *= vol;    // ★ 每个采样都乘 vol
}
1
2
3
4
5
6
7
8
9

每个采样 × vol:vol = 0.5 波形幅度减半(变轻),vol = 2.0 幅度翻倍(变响)。就这么直接。

vol 从哪来?get_source_volume(obs-source.c:5193)先算出这一路该用多大音量:

// libobs/obs-source.c:5193(节选)
static float get_source_volume(obs_source_t *source, uint64_t os_time)
{
	...
	bool muted = !source->enabled || source->muted || ...;   // 静音?按住静音?没开启?
	if (muted || close_float(source->volume, 0.0f, 0.0001f))
		return 0.0f;                                     // → 0:整段清零(这一路等于没声)
	if (close_float(source->volume, 1.0f, 0.0001f))
		return 1.0f;                                     // → 1:原样,省一次乘法
	return source->volume;                                   // → 其它:每个采样都 ×它
}
1
2
3
4
5
6
7
8
9
10
11

三条分支:静音/音量≈0 → 返回 0.0(直接把整段清零);音量≈1.0 → 返回 1.0(原样不动,省掉一整趟乘法);其它 → 返回 source->volume,拿去逐采样相乘。它还顺带处理了"按住说话(push-to-talk)""按住静音(push-to-mute)"这些开关(:5195-5205)。

这个 vol,就是 16.2 调音台上那个"推子(fader)" —— 你在 OBS 混音器里拖动的那个音量滑块,最终就变成了这里的 vol。(推子的滑块位置到 vol 之间还有一条"不是线性的"换算曲线,那是第 17 课的主角,这里先按下不表。)


# 16.8 对齐(一):多个源,怎么对到"同一刻"

📍转折:相加和乘音量都讲完了,现在正面啃这一课真正难的部分——对齐。先解决多个源怎么对到同一时刻。

现在正面回答第一个"对齐"难题。混音要"把同一时刻的采样相加",可各个源到达引擎的时刻天生参差:麦克风这一包刚到,游戏声那一包还在路上。怎么办?

多源对齐

分三种情况(都发生在一次 audio_callback 里):

  • 准时的源:它的采样正好落在这一拍的时间窗 [start, end) 内 → 直接混
  • 稍晚一点的源:起始时刻比 start 晚 → 用 16.6 那个 start_point,从窗里对应位置开始加,时间上仍然对得准。

🔢 start_point 到底怎么算?一个具体例子。 假设采样率 48000 Hz,某个源这包的采样,起始时刻比窗口 start 晚了 0.5 毫秒。那偏移多少个采样?0.5ms × 48000 = 0.0005 × 48000 = 24 个采样。于是 start_point = 24,代码里 mix += 24 —— 这个源就从混音总线的第 24 格开始往里加,而不是第 0 格。一句话:时间差 × 采样率 = 差几个采样,convert_time_to_frames(obs-audio.c:85)干的就是这个换算。

  • 还没来的源:数据还没凑齐一整拍 → 先标记 pending(audio_buffer_insufficient,obs-audio.c:424:发现 audio_input_buf 里的量不够一拍就置 pending),这一拍先不混它

关键是最后这种"迟到"的处理。OBS 的选择很有讲究 —— 不丢,而是"整体加缓冲"等它:

// libobs/obs-audio.c:667(节选)
if (audio->fixed_buffer) {
	if (!audio_buffering_maxed(audio))
		set_fixed_audio_buffering(audio, sample_rate, &ts);
} else if (min_ts < ts.start) {              // 有源的最早时间戳,比当前窗起点还早 → 数据滞后了
	add_audio_buffering(audio, sample_rate, &ts, min_ts, buffering_name);   // ★ 加缓冲
}
1
2
3
4
5
6
7

add_audio_buffering(obs-audio.c:361)做的,是把整个混音窗往后挪几拍,给迟到的源留出补齐的时间:

// libobs/obs-audio.c:377(节选)
offset = ts->start - min_ts;                 // 落后了多少
frames = ns_to_audio_frames(sample_rate, offset);
ticks  = (frames + AUDIO_OUTPUT_FRAMES - 1) / AUDIO_OUTPUT_FRAMES;   // 换算成多少「拍」
audio->total_buffering_ticks += ticks;       // 累加到当前总缓冲

if (audio->total_buffering_ticks >= audio->max_buffering_ticks) {   // 到上限了
	ticks -= audio->total_buffering_ticks - audio->max_buffering_ticks;
	audio->total_buffering_ticks = audio->max_buffering_ticks;
	blog(LOG_WARNING, "Max audio buffering reached!");               // 就是日志里那句警告
}
1
2
3
4
5
6
7
8
9
10
11

翻译一下:

  • 加缓冲 = 把混音整体延后几拍(每拍 ≈ 21ms),让所有源都有时间到齐。代价是多一点点延迟
  • 有上限:max_buffering_ticks(默认 45 拍 ≈ 960ms,obs.c:1617)。缓冲一旦加到顶还等不来,才不得已丢弃(ignore_audio,obs-audio.c:634)。日志里 Max audio buffering reached! 就是这个上限被撞到了。
  • 核心取舍:音频宁可加一点延迟把大家等齐,也不轻易丢一个采样 —— 正是第 15 课那句"丢一个采样就是爆音"的直接体现。视频迟到就丢帧(第 14 课),音频迟到先等一等,这就是两条链路"性格"的根本差别。

# 16.9 走一遍:跟着一路声音,走完一拍

📍收束:机制一个个拆开了,这一节用真实数字把它们串成一个故事——跟着麦克风这一路,走完完整的一拍。

前面把机制一个个拆开了,现在用真实数字把它们串成一个故事 —— 跟着"麦克风"这一路,走完一拍。假设采样率 48kHz,你把麦克风音量拖到了 0.8

走一遍

  • ① 从仓库取一拍:从 audio_input_buf(仓库)peek 出这一拍的 1024 个采样,搬到 audio_output_buf(工作台)。假设开头几个是 [0.50, -0.40, 0.30, …]
  • ② 乘音量 ×0.8:get_source_volume 返回 0.8,multiply_output_audio 让每个采样 ×0.8:0.50×0.8=0.40-0.40×0.8=-0.320.30×0.8=0.24……(推子拖到 0.8,就是这一步)。
  • ③ 按时间戳对齐:这包的时间戳比窗口 start 晚了 0.5ms0.5ms × 48000 = 24 个采样 → start_point = 24,从总线第 24 格开始加。
  • ④ 加进主总线:*(mix++) += *(aud++),把这 1024 个(已乘过音量的)采样,加到 mix 对应位置上。游戏声、音乐那两路,也这样各自加进来 —— 一层层叠上去。
  • ⑤ clamp 收尾:全部源加完,某些位置可能超过了 ±1.0(几路都很响时)。最后统一夹回范围(clamp_audio_output,audio-io.c:198):1.30 → 1.00-1.20 → -1.000.75 → 0.75(在范围内的不变)。这一步防止"爆音"(削顶失真)。

五步走完,这一路(麦克风)就正确地叠进了这一拍的混音。其它源同理,一层层加上去 —— 这就是完整的一拍混音。回头再看 16.5 那五步、16.6 那句 +=、16.7 那句 *= vol,是不是全串起来了?


# 16.10 对齐(二):声音,怎么跟画面同步

📍承上启下:多源对齐解决了,还剩第二个对齐难题——混好的声音怎么跟视频画面同步?秘密还是那把纳秒时间尺。

第二个"对齐"难题:混好的这一路声音,怎么跟视频画面同步?音画不同步(嘴动了半秒声音才到)是直播大忌。

音画同步

秘密其实朴素:音频和视频,从头到尾用的是同一把"纳秒时间尺"。每一帧画面、每一包声音,采集时都在这把尺上打了 timestamp(第 10、15 课)。既然大家的时刻都标在同一把尺上,对齐就有了共同基准。在此之上,有两个具体机制:

# ① 用户微调:sync_offset

现实里总有固定延迟:蓝牙耳机的声音慢半拍、采集卡的画面慢几帧。OBS 给每个源一个"音频同步偏移"滑块,你填的毫秒数,会直接加到这个源的音频时间戳上(source_output_audio_data,obs-source.c:1638):

// libobs/obs-source.c:1638
sync_offset = source->sync_offset;
in.timestamp += sync_offset;                 // ★ 用户设的偏移,直接加进时间戳
in.timestamp -= source->resample_offset;     // 再补偿重采样带来的零头(第 15 课)
1
2
3
4

时间戳一改,这个源的声音在时间轴上就整体提前或延后了 —— 于是能手动"推"着声音去追上画面。

# ② 编码时:锚定到视频起点

真正让两条流"从同一刻开始"的,在编码那一步。音频编码器会等它配对的视频编码器先定下"第一帧的时刻"作为零点,再据此裁掉自己起点之前的采样(buffer_audio,obs-encoder.c:1580):

// libobs/obs-encoder.c:1596(节选)
uint64_t v_start_ts = paired_encoder->start_ts;   // 视频编码器的起点时刻
if (!v_start_ts) {                                 // 视频还没起来?
	success = false;                           // → 音频先等着,别急着开始
	goto fail;
}
...
if (data->timestamp < v_start_ts)                  // 音频里早于视频起点的部分
	offset_size = calc_offset_size(encoder, v_start_ts, data->timestamp);  // 算出要裁掉多少采样
1
2
3
4
5
6
7
8
9

视频定"零点",音频对齐到这个零点、裁掉之前多余的采样 —— 于是两条流从同一时刻开始,之后各自按自己的时间戳往前走,始终不飘。这就是"音画同步"落到代码里的样子。


# 16.11 串起来:音频链路的前半程

📍收束:第 15、16 两课把声音从采集到交给编码的前半程走完了,这一节回头把它串成一条线,主线是贯穿始终的时间戳。

第 15、16 两课,把声音"从采集到交给编码"的前半程走完了:

音频链路前半程

  • 采集(第 15 课):每个源把声音填进 obs_source_audio,推给引擎;
  • 每源深缓冲(第 15 课):重采样成统一格式,按时间戳拼进环形缓冲;
  • 固定节拍混音(第 16 课):audio_thread 每拍取窗 → ×音量 → 按时间戳对齐 → 逐采样求和;
  • 一路混音(第 16 课):混进 6 条总线、clamp 回 −1~1;
  • 交给编码(模块 5):音频锚定视频起点,音画同步。

而贯穿这一整程的,是同一根线 —— 时间戳(timestamp):

采集时打上时间戳 → 入缓冲时按它落位 → 混音时按它对齐多个源 → 编码时按它锚定到视频。音频和视频用的是同一把纳秒尺,所以最终能严丝合缝地同步。

这就是"音画同步"的全部秘密:从头到尾,大家都老老实实按同一个时间戳办事。没有什么魔法,只有一以贯之的纪律。


# 16.12 本课小结

  • 混音 = 逐采样相加:声音物理上就是气压叠加,数字上就是把各路同一时刻的采样值相加。核心一句 *(mix++) += *(aud++)(obs-audio.c:117);相加可能超 ±1.0,靠 float 留余量、最后 clamp。
  • 一张调音台:源 = 通道条,音量滑块 = 推子(vol),把各路 ×推子后全加起来 = 主输出总线(mixes[])。所有代码都在实现这张台子。
  • 固定节拍:audio_thread(audio-io.c:205)每拍处理 AUDIO_OUTPUT_FRAMES = 1024 个采样(audio-io.h:31),os_sleepto_ns_fast 卡点、主动 pull。视频/音频是两根独立心跳。
  • 一拍 & 两个 buffer:一拍 = 1024 采样 ≈ 21.3ms。audio_input_buf(仓库,深、存好几秒,obs-internal.h:867)vs audio_output_buf(工作台,浅、只放这一拍,obs-source.c:5419);每拍用 deque_peek_front 从仓库拷一拍到工作台(process_audio_source_tick,:5405)。
  • 一次 tick 五步(audio_callback,obs-audio.c:552):圈时间窗 → 收集活跃源 → 逐源渲染(取采样 + ×音量)→ mix_audio 求和进 6 条总线 → discard_audio 清理 → clamp 输出。
  • 音量 = 乘数:multiply_output_audio 每采样 *= vol(obs-source.c:5220);get_source_volume(:5193)算 vol,这就是"推子"。
  • 对齐(一)多源:按时间戳落位 —— 晚 t 的源偏移 start_point = t × 采样率(晚 0.5ms → 24 个采样),mix += start_point;还没来的源标 pending,用 add_audio_buffering(:361)整体加缓冲等它(上限默认 45 拍≈960ms,到顶才 ignore_audio 丢)。宁等不丢。
  • 对齐(二)音画同步:音视频共用同一把纳秒时间尺;sync_offset(:1638)手动平移某路音频;编码时音频锚定到配对视频编码器的 start_ts(obs-encoder.c:1596),裁掉起点前采样,两流同刻开始。
  • 一根主线:时间戳贯穿采集 → 缓冲 → 混音 → 编码。音画同步的秘密 = 全程按同一个时间戳办事。

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

  1. 看混音器 = 看调音台:OBS 主界面下方的"音频混音器",每一条就是 16.2 的一条通道条,每个推子就是 16.7 那个 vol。拖动它,想象每个采样正在被 × vol
  2. 自己走一遍:仿照 16.9,假设采样率 48kHz、某源音量 0.5、时间戳晚 1ms,算一算:①乘音量后 0.6 变成多少?②start_point 是多少个采样?(答:0.3;1ms×48000=48。)
  3. 制造并修复不同步:给一个音频源设一个夸张的"音频同步偏移"(比如 +500ms,右键源 →"高级音频属性"),感受声音明显滞后画面;再调回 0。你改的就是 source->sync_offset(obs-source.c:1638)。
  4. 翻源码确认"相加":打开 libobs/obs-audio.c,找到 mix_audio(:90),确认那个 *(mix++) += *(aud++)(:117)双重循环;再找到 discard_audio(:223),看混完的采样是怎么 deque_pop_front 弹出去的(对应 16.4 的"仓库把这批弹掉")。
  5. 对照视频:用一句话说清"迟到时",视频(第 14 课)和音频(本课)分别怎么处理,以及为什么不同。

# 下一课预告

这一课我们把多路声音混成了一路,还给每一路乘了音量。可那个音量滑块,拖到"一半"到底是响度的一半吗?怎么用**电平表(volmeter)**实时看每一路有多响、有没有爆音(削顶)?"推子(fader)"从滑块到 vol 之间那条曲线又是怎么回事?

第 17 课:音量、电平表与监听 —— fader / volmeter —— 我们会看 OBS 怎么把"你拖的滑块位置"换算成实际增益、怎么实时算出电平(峰值 / RMS)显示在那根跳动的条上,以及"监听(自己听一路声音)"是怎么接出去的。音频链路的收尾一课,下节课见。


📁 本课配图:imgs/16-01 ~ imgs/16-11 📌 源码锚点: libobs/media-io/audio-io.h:31(AUDIO_OUTPUT_FRAMES=1024)、:28(MAX_AUDIO_MIXES=6);libobs/media-io/audio-io.c:205(audio_thread 固定节拍)、:223(节拍循环)、:160(input_and_output)、:198(clamp_audio_output); libobs/obs-audio.c:552(audio_callback 混音回调)、:85(convert_time_to_frames)、:90(mix_audio)、:117(逐采样相加)、:361(add_audio_buffering)、:315(audio_buffering_maxed)、:424(audio_buffer_insufficient)、:634(ignore_audio 丢弃)、:223(discard_audio)、:667(缓冲决策); libobs/obs-source.c:5455(obs_source_audio_render)、:5405(process_audio_source_tick)、:5419(deque_peek_front 拷一拍)、:5193(get_source_volume)、:5215(multiply_output_audio 逐采样×vol)、:1638(sync_offset 应用)、:1508(source_output_audio_place); libobs/obs-encoder.c:1580(buffer_audio)、:1596(锚定视频 start_ts)、:1552(calc_offset_size);libobs/obs-internal.h:867(audio_input_buf)、libobs/obs.c:1617(max_buffering_ticks 默认 45)。

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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