第 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;
}
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_render → process_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 个采样到工作台
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)。它就是混音的现场,五步走完:

// 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;
...
}
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 那张调音台:③ 是"每条通道 ×推子",④ 是"全加进主输出"):
- ① 圈定时间窗(
:560):把[start, end)打包成ts—— 这一拍要覆盖的时间范围。 - ② 收集活跃音频源(
:564):把当前所有出声的源(通道条)收集成一张表。 - ③ 逐源渲染(
obs_source_audio_render,obs-source.c:5455):对每个源,从仓库 peek 出这一拍的 1024 个采样搬到工作台(16.4),并乘上音量(16.7)。 - ④ 逐源混音(
mix_audio,调用点:687;函数定义在:90,16.6 细看):把每个源的工作台采样 加进 6 条混音总线(16.6)。 - ⑤ 逐源丢弃(
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 上发生的地方。剥到最里面,就是两个数组逐元素相加:

// 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++); // ★★ 逐采样:把源加进混音总线
}
}
}
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
}
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; // → 其它:每个采样都 ×它
}
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); // ★ 加缓冲
}
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!"); // 就是日志里那句警告
}
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.32、0.30×0.8=0.24……(推子拖到 0.8,就是这一步)。 - ③ 按时间戳对齐:这包的时间戳比窗口
start晚了0.5ms→0.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.00、0.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 课)
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); // 算出要裁掉多少采样
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)vsaudio_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 动手 / 观察(本课作业)
- 看混音器 = 看调音台:OBS 主界面下方的"音频混音器",每一条就是 16.2 的一条通道条,每个推子就是 16.7 那个
vol。拖动它,想象每个采样正在被× vol。 - 自己走一遍:仿照 16.9,假设采样率 48kHz、某源音量 0.5、时间戳晚 1ms,算一算:①乘音量后
0.6变成多少?②start_point是多少个采样?(答:0.3;1ms×48000=48。) - 制造并修复不同步:给一个音频源设一个夸张的"音频同步偏移"(比如 +500ms,右键源 →"高级音频属性"),感受声音明显滞后画面;再调回 0。你改的就是
source->sync_offset(obs-source.c:1638)。 - 翻源码确认"相加":打开
libobs/obs-audio.c,找到mix_audio(:90),确认那个*(mix++) += *(aud++)(:117)双重循环;再找到discard_audio(:223),看混完的采样是怎么deque_pop_front弹出去的(对应 16.4 的"仓库把这批弹掉")。 - 对照视频:用一句话说清"迟到时",视频(第 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)。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言