AIGC标识 Android开发笔记[20]-移动音频工具箱

摘要

记录一个"移动音频工具箱"开源项目 AudioSuitZulu(音函):用一条纯 Kotlin 实时音频链路把手机变成扩音器(麦克风 → 回声消除 → 防啸叫 → 增益 → 蓝牙音箱),并实现一个实验性的消音器(主动降噪 ANC)——录制一段规律噪音,端上训练一个约 1.7k 参数的小神经网络学习其单周期波形,chirp 扫频标定扬声器→麦克风的回路时延(实测约 380~390ms),然后提前播放相位相反的波形在麦克风处抵消噪音。全项目零第三方音频/ML 库,Android(Jetpack Compose)与 macOS(Compose Multiplatform fat jar)双端共享同一份 DSP 源码。

工程仓库

  • 源码: [https://github.com/qsbye/audio-suit-zulu](分支 android,MIT License)
  • 算法参考资料整理在仓库 docs/ 目录:AEC 原理与 LMS/NLMS/ERLE、声网低延时 AEC/ANS、通讯软件回声消除实践、RNNoise 论文、ANC 算法研究译文
  • 需求规格与任务拆分在 .trae/specs/silencer-anc/(spec + tasks,含 AC-1~AC-9 验收标准)

关键信息

  • 开发语言:Kotlin(无 Java/Kotlin 混写,无任何 C/C++/JNI)
  • Gradle:distributionUrl=https://services.gradle.org/distributions/gradle-8.7-bin.zip
  • com.android.application:8.5.1 / org.jetbrains.kotlin.android:1.9.0
  • Compose BOM:2023.10.01,kotlinCompilerExtensionVersion 1.5.1,Material3
  • 桌面端:Compose Multiplatform 1.5.11 + shadow 8.3.5(fat jar)
  • jvmTarget = '1.8'(:app)/ JVM 17(:desktop)
  • minSdk 24 / targetSdk 34 / compileSdk 34
  • 音频规格:PCM 16bit / 44.1kHz / 单声道 / 512 采样块(≈11.6ms)
  • 依赖:仅 androidx + compose,无 Oboe、无系统 AudioEffect、无 TensorFlow/ONNX 等任何 ML 库
  • 版本号:年.月.日.时 按构建时间自动生成(如 26.10.3.9)
  • 模块::app(Android)+ :desktop(macOS),release APK 约 4.97MB

原理简介

扩音器:一条实时音频回路

"扩音器"本质上是零延迟尽量低的音频直通 + 处理:

麦克风 → 回声消除(NLMS) → 防啸叫(自适应陷波) → 增益 → 扬声器

难点在于扬声器放出的声音会经空气再次回到麦克风,形成声反馈环:高增益时某一频率的环路增益 ≥ 1,能量指数增长——就是刺耳的啸叫。所以扩音器必须同时解决两件事:把"自己放出去又收回来"的回声消掉(AEC),以及在啸叫形成的瞬间打断它(防啸叫)。

回声消除(AEC)简介

[https://github.com/qsbye/audio-suit-zulu/tree/android/docs]

声学回声消除是"自适应滤波"的经典问题:以实际送往扬声器的信号 x(n) 为参考,用一个自适应滤波器 Ŵ 估计"扬声器→房间→麦克风"这条路径的冲激响应,估计出回声 ŷ(n) 后从麦克风信号 d(n) 中减去,残差 e(n) 就是近端人声:

ŷ(n) = ŵ(n) · x(n)              // 滤波器输出 = 估计回声
e(n) = d(n) − ŷ(n)              // 残差 ≈ 近端人声
ŵ(n+1) = ŵ(n) + μ·e(n)·x(n)/(xᵀx + β)   // NLMS 系数更新

NLMS(归一化最小均方)相对普通 LMS 的区别是步长按参考信号能量归一化,收敛速度与信号幅度无关、更稳定。另一个必需组件是双讲检测(DTD):本人说话时(近端直达声占主导)必须冻结滤波器更新,否则滤波器会把人声也当回声"学进去"而发散。本项目用经典的 Geigel DTD。

补充:Android 其实自带 AcousticEchoCanceler(HAL 级),但部分 ROM 实现质量参差、且本项目要同时支持 macOS,因此选择自研纯软件实现。RNNoise 那类"神经网络降噪"解决的是加性噪声,不是回声,二者不能互相替代。

防啸叫(声反馈抑制)简介

啸叫的本质是闭环正反馈:某个频率上 扬声器 → 空气 → 麦克风 → 处理器 → 扬声器 一圈的总相位是 2π 整数倍且增益 ≥1,能量逐圈放大。专业扩声设备的做法是检测 + 陷波:一旦发现某个窄带频率的能量连续多帧指数增长,就在该频率放一个极窄的 IIR 陷波器(notch)把环路增益打下去。本项目实现为 256 点 FFT 检测 + 最多 6 个自适应陷波,啸叫消失约 0.5 秒后陷波自动淡出,把"被打掉的频段"还回来。

消音器(ANC):只对付"规律噪音"

传统耳机 ANC(前馈/反馈式)靠的是"扬声器离耳朵几厘米、时延微秒级"。手机做 ANC 的难点是回路时延:手机扬声器放的声音,要走空气传播 + 硬件缓冲 + 采集管线,几百毫秒后才回到麦克风——等你"听到"再抵消早就晚了。本项目的思路是只处理固定规律的噪音(风扇、电机、泵、变压器嗡鸣):

  1. 录制 2~6 秒噪音,自相关检测基频 f0 与"规律性置信度";
  2. 训练:把录音按周期折叠成单周期模板,用纯 Kotlin 的小 MLP(相位 sin/cos 特征 → 32 → 32 → 1)拟合它——周期信号的未来相位是已知的,所以网络能"预测未来";
  3. 标定:播一段 chirp 扫频,用互相关定位"扬声器→麦克风"的真实回路时延(实测约 380~390ms),同时估计通路增益;
  4. 消音:在相位 φ(n+lead) 处提前推理出反相波形送出,等它走完 390ms 回路,恰好在麦克风处与外部噪音相消。
           ┌────────── 播放反波(提前 lead 采样)──────────┐
           ▼                                              │
      扬声器 ──空气(约390ms)──▶ 麦克风 ──▶ [DelayAec 剥离反波回声] ──▶ 外部噪音
                                │
                                └──▶ PLL 相位跟踪 + MLP 预测 φ(n+lead)

关键工程问题有三个:回路时延怎么测(chirp 匹配滤波)、自己放的反波又回到麦克风怎么处理(带延迟对齐的 NLMS 剥离)、什么时候不敢消(置信度失锁淡出,防止把人声当风扇消掉)。

Jetpack Compose / Compose Multiplatform

界面全部声明式、零 XML 布局;同一套 UI 设计在 Android(app 模块)与桌面(desktop 模块,Compose Multiplatform 1.5.11 + javax.sound.sampled)各实现一份,两端的 DSP 源码目录除 package 行外逐字节一致(可用 diff 验证)。主题是固定的大地色系(Earth-Tone),不随系统明暗切换,色板直接取自仓库 assets/ 里一张配色参考图。

实现

核心代码

1. 音频采集与播放

app/src/main/java/com/example/audio_stream_app/MainActivity.kt(AudioRecord/AudioTrack 构建,节选)

private companion object {
    const val SAMPLE_RATE = 44100
    const val CHUNK_SAMPLES = 512          // 512 @44.1kHz ≈ 11.6ms
    const val MAX_SAMPLE = 32768f
}

val bufferSize = AudioRecord.getMinBufferSize(
    sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT
)
audioRecord = AudioRecord.Builder()
    .setAudioSource(MediaRecorder.AudioSource.MIC)
    .setAudioFormat(
        AudioFormat.Builder()
            .setSampleRate(sampleRate)
            .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
            .setChannelMask(AudioFormat.CHANNEL_IN_MONO)
            .build()
    )
    .setBufferSizeInBytes(bufferSize)
    .build()

audioTrack = AudioTrack.Builder()
    .setAudioAttributes(
        AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_MEDIA)
            .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
            .build()
    )
    ...
    .setTransferMode(AudioTrack.MODE_STREAM)
    .build()

关键点:

  • 不用 Oboe/AAudio,也不用系统 AcousticEchoCanceler——整条链路可控、跨平台(同一套代码能移植到 JVM 桌面),代价是要自己处理所有算法问题;
  • 音频源是 MIC(不是 VOICE_COMMUNICATION),播放走 USAGE_MEDIA,所以会跟着系统媒体音量走——这也是界面上要显示"音量: 7 / 15"并给静音警告的原因;
  • 块大小 512 采样(≈11.6ms)是实时性与开销的折中:spec 要求单块处理 ≤5ms(NFR-1),留出一倍余量。

2. 音频线程与处理管线

executorService.submit {
    val buffer = ShortArray(CHUNK_SAMPLES)
    val processed = ShortArray(CHUNK_SAMPLES)
    // DSP 使能边沿跟踪与参考信号都限定在音频线程内,无需加锁
    var aecActive = aecOn
    var howlingActive = howlingOn
    aec.reset(); howlingSuppressor.reset()
    var reference = 0f
    while (isRecording) {
        val readResult = audioRecord.read(buffer, 0, buffer.size)
        if (readResult > 0) {
            if (aecOn != aecActive) { aec.reset(); reference = 0f; aecActive = aecOn }
            if (howlingOn != howlingActive) { howlingSuppressor.reset(); howlingActive = howlingOn }
            reference = processBuffer(buffer, processed, readResult, aecActive, howlingActive, reference)
            audioTrack.write(processed, 0, readResult)
            waveformController.addSamples(buffer, processed, readResult)
        }
    }
    aec.reset(); howlingSuppressor.reset()   // 停止后清空,避免下次启动瞬态
}

处理一帧(MainActivity.kt 中的 processBuffer,注释即文档):

/**
 * 处理一帧:回声消除 → 防啸叫陷波 → 增益。
 * AEC 的参考信号是真正送往扬声器的播放信号(增益后),返回本帧最后一个参考采样。
 */
private fun processBuffer(...): Float {
    val multiplier = 10.0.pow(gainDb / 20.0).toFloat()
    var reference = referenceIn
    for (i in 0 until count) {
        var sample = raw[i] / MAX_SAMPLE
        if (aecActive) sample = aec.processSample(sample, reference)
        if (howlingActive) sample = howlingSuppressor.processSample(sample)
        val played = (sample * multiplier).coerceIn(-1f, 1f)
        out[i] = (played * 32767f).toInt().toShort()
        reference = played          // 参考 = 上一个真正播出去的采样
    }
    return reference
}

几个值得注意的设计:

  • 勾选框随时切换不爆音:开关状态在 UI 线程改 @Volatile 标志,音频线程在块边界检测到边沿才 reset() 滤波器/陷波,而不是中途换算法;
  • 参考信号取增益后的播放值:AEC 要对齐"实际发生的事",增益变化也属于回声路径的一部分;
  • 线程模型不是协程而是 Executors.newSingleThreadExecutor(),音频状态全部 @Volatile + 音频线程内独占,注释明确写了"无需加锁"。

3. 回声消除:NLMS + Geigel 双讲检测

dsp/NlmsAec.kt(完整核心逻辑,73 行)

class NlmsAec(
    private val filterLength: Int = 512,     // 512 点 @44.1kHz 约覆盖 11.6ms 回声路径
    private val stepSize: Float = 0.3f,
    private val regularization: Float = 1e-4f
) {
    private val weights = FloatArray(filterLength)
    private val referenceLine = FloatArray(filterLength)

    fun processSample(mic: Float, reference: Float): Float {
        // 参考延迟线右移一位
        System.arraycopy(referenceLine, 0, referenceLine, 1, filterLength - 1)
        referenceLine[0] = reference

        var estimatedEcho = 0f
        var energy = 0f
        var maxRef = 0f
        for (i in 0 until filterLength) {
            val xi = referenceLine[i]
            estimatedEcho += weights[i] * xi
            energy += xi * xi
            val ax = abs(xi)
            if (ax > maxRef) maxRef = ax
        }

        val error = mic - estimatedEcho

        // Geigel DTD:|d| 超过参考窗口峰值的一半时视为近端讲话,冻结更新
        val doubleTalk = maxRef > 1e-4f && abs(mic) >= DTD_THRESHOLD * maxRef
        if (!doubleTalk) {
            val gain = stepSize * error / (energy + regularization)
            for (i in 0 until filterLength) {
                weights[i] += gain * referenceLine[i]
            }
        }
        return error
    }
}

要点:

  • weights 就是估计的房间冲激响应,逐样本点积出回声、减掉;energy + regularization 归一化是 NLMS 的定义(分母趋 0 时 β 兜底);
  • 局限(README 也如实写了):512 阶只覆盖 11.6ms,而真实声学回路(直达 + 反射)通常几十到几百毫秒,所以软件 AEC 只能消掉极短的那部分,效果不及 HAL 级 AEC,收敛还要 1~2 秒。消音器页的 DelayAec 正是用"标定延迟对齐"补上了这一课(见第 10 节);
  • 实现上每个采样都 System.arraycopy 搬 512 个元素 + 两趟循环,是明显的热路径开销,理想做法是环形缓冲。

4. 防啸叫:FFT 检测 + 自适应 IIR 陷波

dsp/HowlingSuppressor.kt(类注释即原理)

* 啸叫是声反馈闭环造成的——某一频率的能量随时间指数增长。
* 检测到某频点窄带能量连续多帧正增长、且明显高出邻域与本底时即"锁定",
* 在该频率自动放置一个 IIR 陷波(notch)打断反馈环;
* 锁定后只要该频点仍保持强能量就持续抑制(覆盖啸叫饱和平台期),
* 能量消失一段时间后陷波自动淡出释放。
*
* 处理流程:信号先过陷波组,再以陷波后的信号做频谱分析——
* 反馈环被陷波打断后该频点能量下降,陷波即可自动解除。

逐样本处理(陷波链 + 攒帧),切入切出用深度混合避免咔哒声:

fun processSample(sample: Float): Float {
    var out = sample
    for (n in notches) {
        if (n.active && n.depth > DEPTH_EPS) {
            val y = n.b0 * out + n.b1 * n.x1 + n.b2 * n.x2 - n.a1 * n.y1 - n.a2 * n.y2
            n.x2 = n.x1; n.x1 = out; n.y2 = n.y1; n.y1 = y
            // 深度混合,避免陷波切入/切出时产生咔哒声
            out += (y - out) * n.depth
        }
    }
    frame[framePos++] = out
    if (framePos == fftSize) { framePos = 0; analyzeFrame() }
    return out
}

锁定判据(某 bin 需要"足够强 + 显著高出邻域 + 连续增长"三个条件同时满足):

val strong = m >= magnitudeMax * PEAK_RATIO &&
    m > neighbor * PROMINENCE_RATIO &&
    m > absoluteFloor
val growing = m > prevMagnitude[k] * GROW_RATIO

if (strong && growing) growFrames[k]++
else if (!strong) growFrames[k] = (growFrames[k] - 2).coerceAtLeast(0)  // 平台期缓慢衰减
if (growFrames[k] >= GROW_FRAME_COUNT) locked[k] = true

陷波器就是标准二阶 IIR:

/**
 * H(z) = (1 − 2cosω z⁻¹ + z⁻²) / (1 − 2r·cosω z⁻¹ + r²z⁻²)
 * r 越接近 1,陷波越窄越深。
 */
val omega = 2.0 * PI * freqHz / sampleRate
val c = cos(omega).toFloat()
b0 = 1f; b1 = -2f * c; b2 = 1f
a1 = -2f * NOTCH_R * c; a2 = NOTCH_R * NOTCH_R

关键常量(每个都写了物理含义):

const val FFT_SIZE = 256
const val MAX_NOTCHES = 6
const val MIN_BIN = 6               // 约 1kHz 起步
const val MAX_FREQ = 12000          // 手机扬声器有效上限附近
const val GROW_FRAME_COUNT = 6      // 连续 ~35ms 正增长即锁定
const val RELEASE_FRAME_COUNT = 90  // 约 0.5s 无强能量后释放
const val GROW_RATIO = 1.12f
const val PROMINENCE_RATIO = 3.5f   // 峰值需高出邻域
const val NOTCH_R = 0.985f

FFT 是手写的原地基-2 实现(含 Hann 窗),没有引入任何 FFT 库。

5. 双波形显示:Compose Canvas 节流重绘

音频线程每秒产生 86 帧数据,UI 不能跟着每帧重组。项目用了一个很实用的技巧——把"版本号"状态的读取放进 Canvas 绘制阶段:

Waveform.kt

Canvas(modifier) {
    // 在绘制阶段订阅 version,音频线程写入时节流触发重绘,无需重组
    @Suppress("UNUSED_VARIABLE")
    val version = controller.version
    ...
}

WaveformController 用两个 ArrayDeque(原始/处理后)+ synchronized 写入,写入时按 33ms 节流递增 version(约 30fps),触发 Canvas 重绘而不是整棵 Compose 树重组;波形用贝塞尔曲线平滑连接(path.cubicTo),并按 MAX_DRAW_POINTS = 360 降采样。

6. 消音器:四步 UI 与七态状态机

SilencerEngine.kt(状态机)

/**
 * 消音器页音频引擎(需求 FR-2 ~ FR-7)。
 * 独立持有 AudioRecord/AudioTrack 会话(按需打开、用完即释放),与扩音器页
 * 互斥;内部单线程执行器串行执行 录制 / 分析 / 训练 / 标定 / 实时消音。
 * 状态机:IDLE → RECORDING → RECORDED → TRAINING → READY → CALIBRATING → ACTIVE。
 */
enum class Phase { IDLE, RECORDING, RECORDED, TRAINING, READY, CALIBRATING, ACTIVE }

对应 UI 的四个步骤头:录制 → 训练 → 标定 → 消音(SilencerPage.kt)。两页音频会话严格互斥:

/**
 * 切 Tab 时保证两页音频会话互斥(FR-1):离开扩音器页按住会话立即停止,
 * 离开消音器页停止其录制/标定/实时消音并释放音频设备。
 */
private fun selectTab(index: Int) {
    if (index == selectedTab) return
    if (selectedTab == 0) stopStreaming()
    if (selectedTab == 1) silencerEngine.stopAll()
    selectedTab = index
}

几个工程细节:

  • 低延迟模式用反射调用:部分 ROM(如华为 Android 12)裁掉了 PERFORMANCE_MODE_LOW_LATENCY,代码先取官方常量 3132,字段不存在就用字面值,再失败则整体重建为普通模式;
  • 零样本排空:部分机型 startRecording() 后约 0.25 秒读到的全是 0,会污染训练模板、让首次相位捕获停在静音窗,因此启动后先丢弃无效块直到出现有效峰值;
  • 录制时长限制 2~6 秒(MIN_RECORD_SAMPLES / MAX_RECORD_SAMPLES),太短统计不稳、太长训练变慢。

7. 周期检测:FFT 自相关 + 长滞后精修

dsp/anc/PeriodDetector.kt——决定"这段噪音值不值得消":

// 用 FFT 求自相关(自相关是互相关 a=b 的特例)
val corr = Fft.crossCorrelate(win, win)
// 有偏归一 r[k]/r[0]:其三角衰减天然偏向真周期而非倍周期,
// 避免无偏归一项 n/(n-k) 抬升长滞后导致的"低八度"误判

两个精妙点:

  • 有偏归一化:自相关在滞后 k 处只有 n−k 个样本可用,用 r[k]/r[0] 而不是无偏的 n/(n−k),让三角窗衰减天然抑制倍周期,减少"锁到谐波";
  • 长滞后精修:短窗抛物线插值仍有 ~0.1 采样误差,按几百个周期折叠模板时会累积成明显相位漂移,所以在整段录音的 k·P 附近再做一次归一化互相关定位,亚采样误差随 k 缩小一个多数量级(还带 4 路循环展开与前缀和 O(1) 段能量)。

分析窗 32768 采样(≈0.74s),搜索范围 30~1200Hz,规律性置信度阈值 0.5。

8. 端上训练:纯 Kotlin MLP + 手写反向传播 + Adam

dsp/anc/WaveformMlp.kt(设计声明)

* 设计参考 docs/rnnoise-learning-noise-suppression.md 的混合思路:
* 确定性的 Fourier 相位特征由信号处理给出,网络只学"一个周期内波形形状"
* 这一件小事,因此网络可以非常小(约 1.7k 参数)并在手机上快速训练。
*
* 输入:相位 φ∈[0,1) 的 Fourier 特征 sin/cos(2πkφ), k=1..K;
* 结构:Dense(32,tanh) → Dense(32,tanh) → Dense(1,linear);
* 输出:单位 RMS 归一化后的该相位噪声波形值(真实幅度由外部 RMS 跟踪还原)。
*
* 周期信号的未来相位已知,实时消音时直接对 φ(n+lead) 推理即可"预测未来",
* 这是本方案能提前播放反向波形、绕开音频回路时延的关键。

不用任何框架:He 初始化、前向推理零堆分配、手写梯度回传、Adam 更新:

val mi = B1 * pm[offset + i] + (1f - B1) * gi
val vi = B2 * pv[offset + i] + (1f - B2) * gi * gi
val mHat = mi / (1f - powB1(t))
val vHat = vi / (1f - powB2(t))
w[i] -= lr * mHat / (sqrt(vHat) + EPS)

工程细节同样讲究:β^t 做成查表避免每步 pow;tanh 在 |x|>4.5 时直接饱和截断;还留了一条自我提醒的注释——

// phaseFeatures 返回共享工作区,必须拷贝到批内独立数组,
// 否则所有样本的输入会别名到最后一个样本,反向梯度全部算错

dsp/anc/AdamTrainer.kt 解决"训练什么":目标不是整段录音,而是按基频把数百个周期折叠平均成单周期模板(平均会压掉非周期噪声,样本量从几十万降到 ≤1470),并做了亚采样折叠去模糊:

/**
 * 真实基频周期几乎从不是整数采样(如 44100Hz 下 120Hz = 367.5 采样),
 * 若直接用 i % round(P) 折叠,每个周期残留的半采样误差会沿数百个周期
 * 累积,把模板平均模糊掉。这里在长度 p 的均匀相位网格上按浮点周期步进,
 * 每个采样按线性插值分摊到相邻两个网格点,消除亚采样折叠模糊。
 */
val inc = p / periodSamples
sums[j0] += d * (1.0 - w1); weights[j0] += 1.0 - w1
sums[j1] += d * w1;         weights[j1] += w1
pos += inc

训练循环:Fisher-Yates 打乱 + mini-batch(256) + 保存最优权重可回退 + 早停(loss 达到 2e-4 或 60 轮无新低即停),学习率三段调度 0.01 → 0.004 → 0.001,@Volatile cancelled 支持中途取消。谐波数 K = (nyquist/f0).coerceIn(1,10)。

9. 回路标定:chirp 匹配滤波 + 最小二乘增益

dsp/anc/LatencyCalibrator.kt——测量"我播的声音多久能回到麦克风":

const val CHIRP_DURATION_S = 0.25
const val CHIRP_F0 = 500.0
const val CHIRP_F1 = 3000.0
const val CHIRP_AMPLITUDE = 0.6f
const val PEAK_THRESHOLD = 0.35f

线性扫频(500→3000Hz,0.25s,5ms 淡入淡出),瞬时相位由积分给出:

// 线性扫频的瞬时相位积分:2π(f0·t + (f1-f0)/(2T)·t²)
val phase = 2.0 * PI * (CHIRP_F0 * t + (CHIRP_F1 - CHIRP_F0) / (2.0 * tDur) * t * t)

录音与参考 chirp 做互相关,逐滞后归一化(用前缀和求局部能量,避免近端/远端响度差异影响峰位),在最高峰处再做一次最小二乘估计扬声器→麦通路增益:

val score = (corr[lag].toDouble() / sqrt(refSq * localSq)).toFloat()
// 最小二乘幅度:recorded[lag+j] ≈ gain·ref[j](含外部噪声,相关峰已对齐)
val gain = (corr[bestLag].toDouble() / refSq).toFloat().coerceIn(0.02f, 2f)

实测回路时延约 380~390ms,UI 提供 ±50ms 手动微调滑条按听感修正。录制时"边写边读、块边界对齐",使播放与采集两条时间线误差远小于一个 chunk。

10. 实时消音:PLL + 提前量 + 非负投影 DelayAec

dsp/anc/AncController.kt(类注释,五步流水线)

 * 音频线程每个 chunk 调用一次 [process],内部完成:
 * 1. 回声剥离——本机反噪经空气回到麦克的回声与播放信号相差一个已知的
 *    回路时延 lead,故用「带延迟对齐的 NLMS」([DelayAec],比扩音器页
 *    NlmsAec 多了标定延迟对齐),从麦信号中减去自身反噪,得到外部噪声;
 * 2. PLL 相位/频偏跟踪——以训练得到的单周期模板做归一化互相关,
 *    允许基频缓慢漂移;输出锁定置信度;
 * 3. 幅度跟踪——最小二乘幅度系数慢自适应;
 * 4. 反波生成——在相位 φ(n+lead) 处由 [WaveformMlp] 推理(预测未来),
 *    取负、乘幅度,提前写出反向波形以抵消回路时延;
 * 5. 安全整形——增益缓升(约 1s)、失锁 0.5s 内淡出静音、输出硬限幅。
 *
 * 所有状态只允许在音频线程访问。

第 4 步是全项目的落点——提前 lead 采样生成反波:

// 4) 生成反波:在相位 φ(n+lead) 处推理取负(提前 lead 采样抵消回路时延)
//    播放量按 1/plant 补偿扬声器→麦衰减,使反噪在麦处与外部周期声等幅
val playScale = (ampSmooth * templateRms * gate / plant.coerceAtLeast(0.02f))
val step = period / instPeriod   // 每真实采样对应的模板步进(漂移补偿)
for (i in 0 until count) {
    var played = 0f
    if (gate > 1e-4f) {
        val genIdx = ((phase + (i + leadSamples) * step) % pInt + pInt) % pInt
        val predicted = net.predict((genIdx / pInt).toFloat())
        played = -predicted * playScale
        played = played.coerceIn(-maxAntiLevel, maxAntiLevel)
        if (played.isNaN()) played = 0f
    }
    out[i] = (played * 32767f).toInt().toShort()
    pushPlayed(played)
}

genIdx 里的 (i + leadSamples) 就是"预测未来":当前要播的是 lead 之后那一采样时刻的噪声反相值,等它走完 390ms 回路,正好对上噪声。step = period/instPeriod 补偿基频漂移,让模板跟着实际转速走。

DelayAec 的非负投影——全项目最有理论价值的一段注释:

/**
 * 延迟对齐 NLMS 回声估计:回声 ≈ Σ_j w[j]·played[age = lead±taps/2]。
 * 标定给出主延迟,自适应权重建延迟附近的房间响应。
 *
 * 权重做非负投影:本机反噪经紧凑声学通路(扬声器→麦,低频段波长≫间距)
 * 回来的主传递增益为正;而外部周期声源恰好是播放反噪的"未来值",若允许
 * 负权重,NLMS 会把外部声源本身误当回声整体抵消,使外部幅度不可观测、
 * 控制环坍缩。投影到 ≥0 后,权重只学习真实回声,闭环稳定。
 */
...
var nw = w[j] * (1f - AEC_LEAK) + g * ring[idx]
if (nw < 0f) nw = 0f                 // 非负投影,见类注释
if (nw > AEC_TAP_MAX) nw = AEC_TAP_MAX
w[j] = nw

同时带泄漏项(AEC_LEAK):周期参考是秩亏的,不加泄漏权重会沿相关方向无限扩散。

失锁安全链(常量区)——宁可不消,也不能把人声消了:

const val LOCK_ON_CORR = 0.35f
const val LOCK_OFF_CORR = 0.22f        // 双阈值滞回
const val UNLOCK_CHUNKS = 43           // 约 0.5s(43×11.6ms)确认失锁
const val BASELINE_CHUNKS = 26         // 约 0.3s 基线:期间禁止门控开启
const val GATE_UP_ALPHA = CHUNK_S / (1.0f + CHUNK_S)      // 缓升约 1s
const val GATE_DOWN_ALPHA = CHUNK_S / (0.15f + CHUNK_S)   // 淡出约 0.15s

另一个后来才修对的细节(commit 30b8b15):周期带能量降幅指标必须在原始麦克风信号上度量——ext 已经被 AEC 减掉了反波回声,用它算看不到真实抵消量;基线期还要冻结门控,保证 before/after 对比的"before"完全取自无反波信号。

11. 双端复用:desktop 模块

settings.gradle.kts:

rootProject.name = "audio-suppression-zulu"
include(":app")
include(":desktop")

桌面端用 javax.sound.sampled 的 TargetDataLine/SourceDataLine 替代 AudioRecord/AudioTrack(desktop/.../AudioEngine.kt),其余从 DSP 到 Compose 页面全部复用;dsp/ 与 dsp/anc/ 两端 8 个文件除 package 行外完全一致,可以 diff 验证。打包用 shadow 出 fat jar:

// desktop/build.gradle.kts
id("org.jetbrains.compose") version "1.5.11"
id("com.gradleup.shadow") version "8.3.5"
...
implementation(compose.desktop.macos_x64)
implementation(compose.desktop.macos_arm64)   // 同一 jar 同时支持 Intel 与 Apple Silicon

构建与测试

git clone https://github.com/qsbye/audio-suit-zulu.git
cd audio-suit-zulu

./gradlew :app:assembleDebug       # Android: app/build/outputs/apk/debug/app-debug.apk
./gradlew :desktop:shadowJar       # macOS: desktop/build/libs/AudioSuitZulu-desktop-1.0.0-all.jar
java -jar desktop/build/libs/AudioSuitZulu-desktop-1.0.0-all.jar

build-with-timestamp.sh 负责出 release APK 并复制成带时间戳的文件名;脚本故意不读环境 JAVA_HOME(注释说明它可能指向 JDK 8 导致 AGP 8.x 失败),默认用 Android Studio 自带 JBR 17,可用 BUILD_JAVA_HOME 覆盖。

单元测试是"验收标准驱动"的(spec 的 AC-1~AC-9 一一对应),纯 JVM 仿真、不依赖设备:

测试 验证内容
PeriodDetectorTest 60/120/400Hz 正弦检出误差 ≤1% 且置信度 ≥0.5;白噪/chirp 低于阈值
LatencyCalibratorTest 已知延迟 137/1024/4096 → 误差 ≤2 采样
WaveformMlpTrainerTest 参数 ≤3000;NMSE ≤ −15dB;提前预测相关 ≥0.99;独立解析真值校验
AncControllerTest 回路仿真 mic = 外噪 + 0.7×played[t-lead];锁定后反波与未来噪声相关 ≤ −0.9;单 chunk 耗时 < 11.6ms

使用

  1. 手机连蓝牙音箱/音响(或直接外放),安装 APK,授予麦克风权限;
  2. 扩音器页:按住 Record 说话、松开停止;拖增益滑条(-20~+20 dB),按需开关「回声消除」「防啸叫」;界面上的双波形中大地灰是原始、橄榄绿是处理后;
  3. 消音器页:对着噪音源录 2~6 秒 → 训练(通常 1 秒内完成)→ 播 chirp 标定(必要时 ±50ms 微调)→ 打开消音开关,把手机麦克风放到你想要的"静区"位置;
  4. 仓库 assets/server-fan-noise1.m4a / server-fan-noise2.m4a 可用音箱播放,作为消音器的外部测试声源(代码不引用,纯素材)。

常见问题

Q: 这和耳机的主动降噪是一回事吗?

A: 目标相同(反相抵消),路径完全不同。耳机 ANC 的扬声器离耳朵几厘米、时延微秒级,可以直接反馈控制;手机的回路时延高达数百毫秒,只能靠"预测未来"绕开——本项目靠周期性假设 + 端上训练出的波形模型来预测,因此只对固定规律的噪音有效,人声、音乐这类非周期声音会被失锁机制自动静音反波。

Q: 消音能消多深?

A: 强规律单频声源效果最好;规律性置信度 <0.5 的噪音允许强制训练,但实测周期分量降幅只有约 1~3 dB(README 已知问题一节如实记录)。多台风扇的拍频、频率漂移大的声源目前效果不佳,还存在自相关锁到倍频(如 445Hz 锁成 904Hz)的误锁问题,重录一次通常能落回基频。

Q: 为什么不直接用系统的 AcousticEchoCanceler / NoiseSuppressor?

A: 系统 HAL 级 AEC 质量参差(且部分 ROM 根本没有),更要命的是它不可跨平台——同一套 DSP 还要跑在 macOS 桌面端。RNNoise 那类神经降噪处理的是加性噪声,与回声/啸叫是不同问题。所以项目选择纯 Kotlin 自研,代价是效果确实不及 HAL AEC(README 有明确的局限说明)。

Q: 为什么不用 Oboe/AAudio?

A: 同样是跨平台与可控性取舍:AudioRecord/AudioTrack + javax.sound 两端模型对等,DSP 可以逐字节复用。代价是拿不到真正的低延迟路径——部分 ROM 还裁掉了 PERFORMANCE_MODE_LOW_LATENCY,代码只能反射调用、失败回退普通模式(回路时延 380~390ms,由标定补偿)。

Q: 扩音器开大音量就啸叫,这两个开关够用吗?

A: 「回声消除」消的是已经放出去又收回来的线性回声,「防啸叫」是在啸叫形成瞬间打断它,两者默认开启、高增益扩音时建议同时开。但软件方案有极限:啸叫本质是环路增益失控,最有效的办法仍是让音箱离麦克风远一点、或戴耳机测试。

Q: 录音后训练卡住/消音没反应?

A: 按 README 已知问题排查:部分机型采集启动后约 0.25 秒全是零样本(引擎已自动排空);偶发首次相位捕获偏慢时,关掉再打开消音开关即可恢复;倍频误锁则重新录一次。

Q: 手机放在哪里才有"静区"?

A: 静区在手机自身麦克风处(消音器只保证自己采集点的相消),所以要尽量把手机放到你希望安静的位置。受扬声器指向性与波长限制,离手机越远消音效果衰减越快。

效果

扩音器页 消音器页 关于页
Screenshot_20261006_113210 Screenshot_20261006_113212 Screenshot_20261006_113215

仓库 screenshots/ 下三张截图为历史版本(紫色 Material3 主题、两 Tab、无 AEC/防啸叫勾选框),现行版本为大地色系固定主题、三 Tab(扩音器 / 消音器 / 关于),发布前需重截。界面特征:按住 Record 时按钮变赤陶土色并显示 Recording...;Android 显示"音量: x / 15"进度条,静音时赤陶土色警告;消音器页有「● 已锁定 / ○ 搜索中」徽章、录音与网络拟合叠加图、标定延时数值与 ±50ms 微调滑条,以及大地灰(麦克风采音)+ 湖水蓝(反相波)双波形。

posted @ 2026-10-06 11:39  qsBye  阅读(5)  评论(0)    收藏  举报