26、K歌与实时耳返场景:耳返通路设计、混音与监听、低延时耳返实现(<20ms)

K歌和实时耳返,这俩场景其实是一回事的两面。你想想看,用户唱歌时如果听不到自己的声音,或者听到的声音比嘴巴慢了半拍,那基本就唱不下去了。我最早做K歌App时,产品经理丢给我一句话:「延迟超过30ms,用户就骂娘。」嗯,后来我们硬是压到了15ms以内,用户才说「还行」。

今天我们就聊聊,怎么把耳返延迟做到20ms以下。说白了,就是跟时间赛跑。

耳返通路设计:信号怎么走?

先搞清楚一条基本链路。麦克风采集到声音,经过处理,再送到耳机。这个路径上每一个环节都在消耗时间。

核心通路:

Mic → ADC → 音频回调 → 混音处理 → 播放回调 → DAC → 耳机

我在项目中遇到过一种情况:明明代码写得没问题,延迟就是降不下来。后来发现是音频回调的buffer size设得太大了。你想想看,一个buffer 1024帧,采样率44100,光这一个buffer就占了23ms。再加上其他环节,妥妥超30ms。

所以耳返通路设计的第一原则:路径要短,buffer要小

耳返信号通路设计 Mic采集 ADC转换 混音处理 DAC转换 耳机输出 ~2ms ~1ms ~5-10ms ~1ms ~2ms 总延迟 = 采集 + 转换 + 处理 + 转换 + 输出 混音处理是最大变量,优化空间最大

混音与监听:怎么把声音「叠」在一起?

K歌场景下,用户听到的不能只是自己的干声。得有伴奏、有效果、还得有自己加了混响后的声音。这就是混音要做的事。

我建议把混音拆成两步:

  1. 本地混音:把麦克风采集的干声和伴奏混在一起,直接送耳机。这一步要快,不能等网络。
  2. 效果混音:把干声加上混响、回声等效果,再混入监听通路。这一步可以稍微慢一点,但也不能超过20ms。

我的经验:本地混音用浮点运算,效果混音用定点运算。浮点精度高,但定点速度快。两者结合,既保证音质又控制延迟。

混音时有个坑:音量平衡。伴奏太响盖过人声,用户会觉得自己唱得不好。人声太响盖过伴奏,又像清唱。我一般建议人声比伴奏高3-6dB,具体看用户喜好。

低延时耳返实现:20ms怎么啃下来?

好,到了最硬核的部分。20ms以内,怎么做到?

我直接给结论:用AAudio,别用AudioTrack。AAudio是Android 8.0引入的,专门为低延迟场景设计的。它绕过了AudioFlinger的混音线程,直接跟HAL层通信。

代码示例:

// 创建AAudio流
AAudioStreamBuilder *builder;
AAudio_createStreamBuilder(&builder);
AAudioStreamBuilder_setDirection(builder, AAUDIO_DIRECTION_OUTPUT);
AAudioStreamBuilder_setSampleRate(builder, 48000);
AAudioStreamBuilder_setFramesPerDataCallback(builder, 192); // 4ms @48kHz
AAudioStreamBuilder_setPerformanceMode(builder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudioStreamBuilder_setSharingMode(builder, AAUDIO_SHARING_MODE_EXCLUSIVE);

AAudioStream *stream;
AAudioStreamBuilder_openStream(builder, &stream);
AAudioStream_requestStart(stream);

这里有个关键参数:framesPerDataCallback。我设了192帧,采样率48000,算下来就是4ms一个回调。为什么是192?因为再小的话CPU扛不住,再大的话延迟就上去了。192是我试出来的平衡点。

注意:AAudio的独占模式(EXCLUSIVE)不是所有设备都支持。如果设备不支持,会回退到共享模式,延迟会高一些。建议在代码里做fallback处理。

另外,麦克风采集也要用AAudio。采集和播放用同一个回调线程,这样能省掉一次线程切换的开销。

// 采集+播放双工模式
AAudioStreamBuilder_setDirection(builder, AAUDIO_DIRECTION_DUPLEX);
AAudioStreamBuilder_setDataCallback(builder, myCallback, nullptr);

// 回调函数里直接做混音
aaudio_data_callback_result_t myCallback(
    AAudioStream *stream,
    void *userData,
    void *audioData,
    int32_t numFrames) {
    
    float *output = (float *)audioData;
    // 从麦克风读取数据
    // 混入伴奏
    // 加上效果
    // 直接写入output
    return AAUDIO_CALLBACK_RESULT_CONTINUE;
}

我曾经在一个项目里,用这套方案把延迟压到了12ms。用户反馈说「跟有线耳机差不多」。嗯,那是我最开心的一次上线。

避坑指南

做低延时耳返,坑不少。我列几个常见的:

  • Buffer太小导致Xrun:回调频率太高,CPU来不及处理,就会出现断音。解决办法是适当增大buffer,或者优化处理逻辑。
  • 采样率不匹配:采集和播放的采样率不一致,会导致音调变化。强制统一到48000Hz。
  • 蓝牙耳机延迟高:蓝牙耳机的延迟天然高,40-100ms很正常。如果用户用蓝牙耳机,20ms基本不可能。建议提示用户换有线耳机。
  • 系统音频策略干扰:有些手机会在通话模式下强制走电话音频通路,延迟暴增。需要检测并处理。

总结一下:

耳返延迟要压到20ms以下,核心就三点:

  • 用AAudio,设小buffer(192帧左右)
  • 采集和播放走同一个回调线程
  • 混音逻辑要轻量,别做重处理

做到这三点,15ms不是梦。

好了,K歌耳返这块就聊到这儿。下一节我们聊聊更复杂的场景——多人实时合唱,那才是真正的挑战。


公众号:蓝海资料掘金营,微信deep3321