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 的难点是回路时延:手机扬声器放的声音,要走空气传播 + 硬件缓冲 + 采集管线,几百毫秒后才回到麦克风——等你"听到"再抵消早就晚了。本项目的思路是只处理固定规律的噪音(风扇、电机、泵、变压器嗡鸣):
- 录制 2~6 秒噪音,自相关检测基频 f0 与"规律性置信度";
- 训练:把录音按周期折叠成单周期模板,用纯 Kotlin 的小 MLP(相位 sin/cos 特征 → 32 → 32 → 1)拟合它——周期信号的未来相位是已知的,所以网络能"预测未来";
- 标定:播一段 chirp 扫频,用互相关定位"扬声器→麦克风"的真实回路时延(实测约 380~390ms),同时估计通路增益;
- 消音:在相位 φ(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 |
使用
- 手机连蓝牙音箱/音响(或直接外放),安装 APK,授予麦克风权限;
- 扩音器页:按住 Record 说话、松开停止;拖增益滑条(-20~+20 dB),按需开关「回声消除」「防啸叫」;界面上的双波形中大地灰是原始、橄榄绿是处理后;
- 消音器页:对着噪音源录 2~6 秒 → 训练(通常 1 秒内完成)→ 播 chirp 标定(必要时 ±50ms 微调)→ 打开消音开关,把手机麦克风放到你想要的"静区"位置;
- 仓库
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: 静区在手机自身麦克风处(消音器只保证自己采集点的相消),所以要尽量把手机放到你希望安静的位置。受扬声器指向性与波长限制,离手机越远消音效果衰减越快。
效果
| 扩音器页 | 消音器页 | 关于页 |
|---|---|---|
![]() |
![]() |
![]() |
仓库 screenshots/ 下三张截图为历史版本(紫色 Material3 主题、两 Tab、无 AEC/防啸叫勾选框),现行版本为大地色系固定主题、三 Tab(扩音器 / 消音器 / 关于),发布前需重截。界面特征:按住 Record 时按钮变赤陶土色并显示 Recording...;Android 显示"音量: x / 15"进度条,静音时赤陶土色警告;消音器页有「● 已锁定 / ○ 搜索中」徽章、录音与网络拟合叠加图、标定延时数值与 ±50ms 微调滑条,以及大地灰(麦克风采音)+ 湖水蓝(反相波)双波形。

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



浙公网安备 33010602011771号