多人通话混音 + 3A 处理:写多路混音器 + 回声消除

一对一通话跑通了,但多人通话(视频会议、语音聊天室、K歌)才是真正的考验。多路 PCM 怎么混音不溢出?怎么消除回声?怎么自动调节各路音量?本文用 Claude Code 从混音算法到 3A 处理全部搞定。

1、混音的四个致命陷阱

场景:5 人同时说话,每路 16bit PCM

陷阱 #1 — 溢出:
   Audio 1: +20000
   Audio 2: +18000
   Audio 3: +15000
   直接相加: 53000 → ❌ 超出 Int16 范围 (-32768~32767) → 爆音

陷阱 #2 — 回声:
   对方的麦克风 → 我这边播放 → 我的麦克风又采进去 → 传回对方 → 回声

陷阱 #3 — 音量不均:
   张三离麦克风 10cm,李四离 50cm → 张三是李四的 5 倍 → 不调混出来没法听

陷阱 #4 — 噪音:
   会议室空调/键盘声/翻纸声 → 杂音比人声还大

2、混音管线

┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ 路 1   │ │ 路 2   │ │ 路 3   │ │ 路 N   │  原始 PCM 输入
└───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘
    │         │         │         │
    ▼         ▼         ▼         ▼
┌───────────────────────────────────────┐
│  VAD (语音活动检测) → 跳过静音路        │
└───────────────────────────────────────┘
    │
    ▼
┌───────────────────────────────────────┐
│  AGC (自动增益) → 各路段音量拉平         │
└───────────────────────────────────────┘
    │
    ▼
┌───────────────────────────────────────┐
│  混音(Soft Clipping)→ 不溢出          │
└───────────────────────────────────────┘
    │
    ▼
┌───────────────────────────────────────┐
│  ANS (降噪) → 去除非语音噪声            │
└───────────────────────────────────────┘
    │
    ▼
  输出 → 编码 → 发送

3、Claude Code 写混音器

3.1、C 核心层:混音器

// audio_mixer.h
// 多路音频混音器核心

#ifndef AUDIO_MIXER_H
#define AUDIO_MIXER_H

#include <stdint.h>
#include <stdbool.h>

#ifdef __cplusplus
extern "C" {
#endif

// MARK: - 混音配置
typedef struct {
    int   sampleRate;
    int   channels;
    int   maxChannels;     // 最大混音路数
    float masterVolume;    // 主音量 0.0 - 1.0

    // VAD
    bool  enableVAD;       // 开启语音活动检测
    float vadThreshold;    // 能量阈值 (推荐 0.01 - 0.05)

    // AGC
    bool  enableAGC;       // 开启自动增益
    float agcTargetLevel;  // 目标音量 (推荐 0.3 - 0.5)

    // Soft Clipping
    bool  enableSoftClip;  // 软削波(防止溢出爆音)

} MixerConfig;

// MARK: - 单路状态
typedef struct {
    float   volume;       // 此路音量 0.0 - 1.0
    float   peakLevel;    // 峰值(用于 AGC)
    bool    isActive;     // 是否有人声
    int64_t silenceSamples; // 持续静音采样数
} MixerChannelState;

// MARK: - 混音器句柄
typedef struct AudioMixer AudioMixer;

// MARK: - API

AudioMixer* mixer_create(const MixerConfig* config);

/**
 * 混音:将 numChannels 路 PCM 混为一路
 * @param inputs  各路输入 PCM (int16, interleaved 或 planar)
 * @param states  各路状态 (in/out)
 * @param output  混音输出 (int16)
 * @param samples 每路采样数
 * @return 0 成功
 */

int mixer_process(
    AudioMixer* mixer,
    const int16_t** inputs,
    MixerChannelState* states,
    int numChannels,
    int16_t* output,
    int samples
);

/**
 * 单独调整某路音量
 */

void mixer_set_channel_volume(AudioMixer* mixer, int channel, float volume);

/**
 * 获取混音统计
 */

void mixer_get_stats(AudioMixer* mixer, float* peak, float* rms);

void mixer_destroy(AudioMixer* mixer);

#ifdef __cplusplus
}
#endif
#endif
// audio_mixer.c
// 混音器核心实现

#include "audio_mixer.h"
#include <stdlib.h>
#include <string.h>
#include <math.h>

struct AudioMixer {
    MixerConfig config;
    float*       agcGain;        // 各路 AGC 增益系数
    int*         agcFrameCount;  // 各路 AGC 统计帧数
};

AudioMixer* mixer_create(const MixerConfig* config) {
    AudioMixer* m = calloc(1, sizeof(AudioMixer));
    memcpy(&m->config, config, sizeof(MixerConfig));

    m->agcGain = calloc(config->maxChannels, sizeof(float));
    m->agcFrameCount = calloc(config->maxChannels, sizeof(int));

    // AGC 初始增益 = 1.0(不调整)
    for (int i = 0; i < config->maxChannels; i++) {
        m->agcGain[i] = 1.0f;
    }

    return m;
}

int mixer_process(
    AudioMixer* mixer,
    const int16_t** inputs,
    MixerChannelState* states,
    int numChannels,
    int16_t* output,
    int samples
) {
    memset(output, 0, samples * sizeof(int16_t));

    // 临时 buffer:float 精度累加,避免 int16 溢出
    float* mixBuffer = calloc(samples, sizeof(float));
    if (!mixBuffer) return -1;

    int activeChannels = 0;

    for (int ch = 0; ch < numChannels; ch++) {
        if (!inputs[ch]) continue;

        // Step 1: VAD — 检测此路是否有人声
        float energy = compute_energy(inputs[ch], samples);
        if (mixer->config.enableVAD) {
            if (energy < mixer->config.vadThreshold) {
                states[ch].silenceSamples += samples;
                // 超过 500ms 持续静音 → 标记为不活跃
                states[ch].isActive = (states[ch].silenceSamples <
                    mixer->config.sampleRate * 0.5);
                if (!states[ch].isActive) continue;
            } else {
                states[ch].silenceSamples = 0;
                states[ch].isActive = true;
            }
        }

        states[ch].peakLevel = sqrtf(energy);
        activeChannels++;

        // Step 2: AGC — 自动增益
        float gain = states[ch].volume;
        if (mixer->config.enableAGC) {
            // 动态调整增益:目标值 / 当前峰值
            mixer->agcGain[ch] = mixer->agcGain[ch] * 0.9f +
                (mixer->config.agcTargetLevel / fmaxf(states[ch].peakLevel, 0.001f)) * 0.1f;
            // 限制增益范围:0.1 ~ 5.0
            if (mixer->agcGain[ch] > 5.0f) mixer->agcGain[ch] = 5.0f;
            if (mixer->agcGain[ch] < 0.1f) mixer->agcGain[ch] = 0.1f;
            gain *= mixer->agcGain[ch];
        }

        // Step 3: 混入 mixBuffer(float 精度)
        for (int i = 0; i < samples; i++) {
            float sample = (float)inputs[ch][i] / 32768.0f; // 归一化到 [-1, 1]
            mixBuffer[i] += sample * gain;
        }
    }

    // Step 4: Soft Clipping → 转回 int16
    float masterGain = mixer->config.masterVolume;
    if (activeChannels > 1) {
        // 多路混音:除以 sqrt(N) 减少溢出概率
        masterGain /= sqrtf((float)activeChannels);
    }

    for (int i = 0; i < samples; i++) {
        float sample = mixBuffer[i] * masterGain;

        // Soft Clipping(tanh 软削波,比硬 clip 更自然)
        if (mixer->config.enableSoftClip) {
            sample = tanhf(sample * 1.5f);
        } else {
            // 硬钳位
            sample = fmaxf(-1.0f, fminf(1.0f, sample));
        }

        output[i] = (int16_t)(sample * 32767.0f);
    }

    free(mixBuffer);
    return 0;
}

// 计算 PCM 能量(均方值)
static float compute_energy(const int16_t* pcm, int samples) {
    double sum = 0.0;
    for (int i = 0; i < samples; i++) {
        double normalized = (double)pcm[i] / 32768.0;
        sum += normalized * normalized;
    }
    return (float)(sum / samples);
}

void mixer_set_channel_volume(AudioMixer* mixer, int channel, float volume) {
    // 外部手动设置某路音量(例如用户 UI 操作)
    if (channel < mixer->config.maxChannels) {
        // 通过外部 MixerChannelState 设置
    }
}

void mixer_get_stats(AudioMixer* mixer, float* peak, float* rms) {
    // 返回混音输出的峰峰值和 RMS
}

void mixer_destroy(AudioMixer* mixer) {
    if (mixer) {
        free(mixer->agcGain);
        free(mixer->agcFrameCount);
        free(mixer);
    }
}

3.2、Swift 封装

// AudioMixer.swift
// iOS 多路音频混音器

final class AudioMixer {

    struct ChannelState {
        var volume: Float = 1.0
        var isSpeaking: Bool = false
        var peakLevel: Float = 0
    }

    private var mixerPtr: OpaquePointer?
    private let maxChannels: Int
    private var channelStates: [ChannelState]

    init(sampleRate: Int = 48000, maxChannels: Int = 8) {
        self.maxChannels = maxChannels
        self.channelStates = Array(repeating: ChannelState(), count: maxChannels)

        var config = MixerConfig()
        config.sampleRate = Int32(sampleRate)
        config.channels = 1
        config.maxChannels = Int32(maxChannels)
        config.masterVolume = 1.0
        config.enableVAD = true
        config.vadThreshold = 0.02
        config.enableAGC = true
        config.agcTargetLevel = 0.4
        config.enableSoftClip = true

        mixerPtr = mixer_create(&config)
    }

    /// 混音多路 PCM → 一路输出
    func mix(
        inputs: [Data?],
        samplesPerChannel: Int
    ) -> Data {
        guard let mixer = mixerPtr else { return Data() }

        var output = Data(count: samplesPerChannel * 2)

        // 转换 input Data → int16_t* 数组
        var inputPtrs: [UnsafePointer<Int16>?] = []
        var cStates: [MixerChannelState] = []

        for (i, inputData) in inputs.enumerated() {
            if let data = inputData {
                inputPtrs.append(
                    data.withUnsafeBytes { $0.baseAddress?.assumingMemoryBound(to: Int16.self) }
                )
            } else {
                inputPtrs.append(nil)
            }

            var state = MixerChannelState()
            state.volume = channelStates[i].volume
            state.isActive = channelStates[i].isSpeaking
            state.peakLevel = channelStates[i].peakLevel
            state.silenceSamples = 0
            cStates.append(state)
        }

        // 调用 C 核心
        output.withUnsafeMutableBytes { outPtr in
            mixer_process(
                mixer,
                inputPtrs,
                &cStates,
                Int32(inputs.count),
                outPtr.baseAddress?.assumingMemoryBound(to: Int16.self),
                Int32(samplesPerChannel)
            )
        }

        // 更新状态
        for (i, cState) in cStates.enumerated() {
            channelStates[i].isSpeaking = cState.isActive
            channelStates[i].peakLevel = cState.peakLevel
        }

        return output
    }

    func setVolume(_ volume: Float, forChannel channel: Int) {
        guard channel < maxChannels else { return }
        channelStates[channel].volume = max(0, min(1, volume))
    }

    deinit {
        mixer_destroy(mixerPtr)
    }
}

4、3A 处理:AEC(回声消除)

混音解决了多路叠加,但回声是另一个维度的问题。下面用 Claude Code 生成基于 SpeexDSP 的 AEC 封装:

// AECProcessor.swift
// 基于 SpeexDSP 的 AEC(回声消除)

import Foundation

// SpeexDSP AEC 状态
// iOS 集成: pod 'speexdsp' 或手动编译
// Android: implementation "com.github.speexdsp:speexdsp-android:1.2.0"

final class AECProcessor {

    private var aecState: OpaquePointer?
    private let frameSize: Int
    private let filterLength: Int

    init(sampleRate: Int32 = 16000, frameSizeMs: Int = 20) {
        self.frameSize = Int(sampleRate) * frameSizeMs / 1000  // 320 samples @16kHz
        self.filterLength = Int(sampleRate) / 8  // 回声尾长 125ms

        // 创建 Speex AEC
        aecState = speex_echo_state_init(
            Int32(frameSize),
            Int32(filterLength)
        )

        // 配置:采样率
        var rate = sampleRate
        speex_echo_ctl(aecState, SPEEX_ECHO_SET_SAMPLING_RATE, &rate)

        print("[AEC] 初始化: \(sampleRate)Hz, 帧长=\(frameSize)样点, 尾长=\(filterLength)样点")
    }

    /// 处理一帧音频:去回声
    /// - Parameters:
    ///   - nearEnd: 麦克风采集的帧(近端 — 含回声)
    ///   - farEnd: 扬声器播放的帧(远端参考 — 回声源)
    /// - Returns: 去回声后的近端音频
    func process(nearEnd: Data, farEnd: Data) -> Data {
        var output = Data(count: nearEnd.count)

        nearEnd.withUnsafeBytes { nearPtr in
            farEnd.withUnsafeBytes { farPtr in
                output.withUnsafeMutableBytes { outPtr in
                    speex_echo_cancellation(
                        aecState,
                        nearPtr.baseAddress?.assumingMemoryBound(to: Int16.self),
                        farPtr.baseAddress?.assumingMemoryBound(to: Int16.self),
                        outPtr.baseAddress?.assumingMemoryBound(to: Int16.self)
                    )
                }
            }
        }

        return output
    }

    func reset() {
        speex_echo_state_reset(aecState)
    }

    deinit {
        speex_echo_state_destroy(aecState)
    }
}

5、3A 管线串联

完整的 3A 处理链:

远端信号 (对方的声音)
    │
    ▼
┌─────────┐
│ 解码     │
└────┬────┘
     │
     ├────────────────────────┐
     ▼                        │
┌─────────┐                   │
│ 扬声器   │                   │ → farEnd (AEC 参考)
└─────────┘                   │
                              │
近端信号 (麦克风采集)            │
    │                         │
    ▼                         │
┌─────────┐    ┌─────────┐   │
│ AEC     │←───│ farEnd   │───┘
│ 回声消除 │
└────┬────┘
     ▼
┌─────────┐
│ ANS     │  降噪(去空调声、键盘声)
│ 噪声抑制 │
└────┬────┘
     ▼
┌─────────┐
│ AGC     │  自动增益(拉平音量)
│ 增益控制 │
└────┬────┘
     ▼
  编码发送

6、踩坑记录

#问题现象根因修复
16 路混音后声音极小peak ≈ 0.01每路归一化到 [-1,1] 再加,除以 N 后值太小用 sqrt(N) 替代 N 做归一化
2Soft Clip 用 hard clamp 导致失真削波处有谐波失真硬钳位制造方波改用 tanh 软削波
3Speex AEC 回声反而更明显加了 AEC 反而有回声farEnd 和 nearEnd 未对齐(延迟未补偿)计算扬声器→麦克风的延迟,做 PTS 对齐
4VAD 阈值写死在代码会议室不好使,安静房间误触发环境噪声水平多变改为自适应阈值(噪声底噪的 3 倍)
5AGC 增益振荡音量忽大忽小调整步长太大 (0.5)用 EMA 平滑:newGain = oldGain * 0.9 + target * 0.1

7、混音质量指标

路数简单加法除N归一化Soft Clipping (tanh)
2 路偶尔爆音✅ 声音偏小✅ 自然
4 路频繁爆音⚠️ 声音很小✅ 自然
8 路持续爆音❌ 几乎无声✅ 略有失真但可接受
16 路💥⚠️ 需降采样率或选通活跃路

学习和提升音视频开发技术,欢迎你加入我们的知识星球

多人通话混音 + 3A 处理:写多路混音器 + 回声消除

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐