☰
Android实时分贝检测:AudioRecord采集、校准与实时呈现
2026/10/29 13:30:15 网站建设 项目流程

1. 先想清楚:分贝检测应用到底在解决什么问题

安卓平台实时声音分贝检测应用,听着像个周末练手的小工具,实际上手后才发现它把音频采集、信号处理、数值校准、实时渲染这几条本来不相干的链路硬生生拧在了一起。我最早做这类应用是在一个模拟项目里,需求方只说了一句“实时显示当前环境噪声多少分贝,再记个曲线”,结果光是“多少分贝才算准”这一问,就来回折腾了三轮。所以这篇东西不讲那些“三步搞定”的速成套路,讲我在实际开发里踩出来的路径:从麦克风拿到什么样的数据、这些数据怎么换算成有物理意义的声压级、界面怎么在几十毫秒内稳住不跳、以及为什么同一间屋子两台手机能差出 8 dB。

这类应用的核心价值很实在。装修验收想粗略看下现场噪声、空调外机有没有异响、孩子练琴音量是不是过了、夜里卧室到底吵不吵、设备运行时的机械噪声有没有异常升高,这些场景都不需要计量级设备,一个随手能开、能连续记录、能导出数据的手机应用就够用。适合读这篇的人分两类:一类是有一定 Android 基础、想搞清楚“录音到分贝”这条完整链路的开发者;另一类是做硬件测试、现场巡检的工程人员,想知道手机测出来的数字能信到什么程度、误差从哪来、怎么把误差压下去。

关键要说在前面:手机麦克风不是测量传声器。它的频响曲线不平、有 AGC、有降噪算法介入、不同厂商的腔体结构差异巨大。所以做这个应用的技术目标不是“做到实验室精度”,而是做到可重复、可校准、可比较——同一台设备在同一位置前后两次测量的偏差控制在 1~2 dB 以内,经过单点校准后与参考声级计的绝对误差压到 3 dB 左右,这个水平已经能满足绝大多数非计量场景。整篇文章的所有设计取舍,都是围绕这个目标展开的。

2. 需求拆解与技术选型:为什么不用 MediaRecorder

2.1 三层需求,别一上来就写界面

我把这个应用拆成三层,每一层的失败模式完全不同,混在一起写会非常痛苦。第一层是采集层,负责从麦克风拿到连续的 PCM 数据流,这层的坑是丢帧、缓冲溢出、音频源被系统加了处理。第二层是计算层,把 PCM 转成 dBFS,再映射到 dB SPL,这层的坑是校准和计权。第三层是呈现层,把数值、曲线、峰值推给界面,这层的坑是刷新节奏和主线程压力。

三层之间用一条很窄的通道连接:采集线程只负责填一个环形缓冲,计算线程从环形缓冲取帧算出结果写进一个@Volatile的结果对象,UI 线程按固定节奏读结果对象。这样任何一层卡顿都不会直接拖垮另外两层。我见过不少初版代码把AudioRecord.read()直接写在 UI 回调里,跑起来数值一顿一顿的,还以为是算法问题,其实是主线程被 IO 阻塞导致的采样空洞。

先明确一个概念分层,后面所有公式都建立在这上面:

层级数值物理含义是否可校准
原始 PCM-32768 ~ 32767麦克风 ADC 输出否
归一化幅度-1.0 ~ 1.0相对满量程否
dBFS-100 ~ 0相对数字满量程否
dB SPL30 ~ 130实际声压级是,靠偏移量

很多人卡在第三步和第四步之间,以为 dBFS 加个 100 就是分贝了。加 100 只是个起点,真正的偏移量因机型和增益设置而异,必须靠校准确定。

2.2 MediaRecorder 为什么不够用

Android 上能拿到音频的 API 就那么几个,选型其实没太多余地,但值得说清楚为什么排除了两个看起来更省事的方案。

MediaRecorder是最容易想到的,几行代码就能录音。问题在于它面向的是“产出文件”,不是“产出数据流”。你拿不到实时的 PCM 帧,只能等录完去读文件,实时性直接归零。即使勉强用setMaxFileSize配合轮询,也存在文件写入延迟和编码损失(AAC 是有损压缩,会削掉峰值)。做实时分贝显示,这条路走不通。

AudioRecord是正解,它提供read()直接读 PCM,延迟可控。缺点是参数要自己算、线程要自己管、异常要自己兜。但这些恰恰是这个应用的核心工作量,绕不开。

还有一个容易被忽略的选项是AudioRecord的音频源参数。这个参数决定了系统在把麦克风数据交给你之前做了多少“加工”:

// 优先追求未处理原始数据 val source = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { MediaRecorder.AudioSource.UNPROCESSED } else { MediaRecorder.AudioSource.MIC }

UNPROCESSED从 Android 7.0 开始提供,理论上绕过 AGC、降噪、回声消除。但不是所有设备都真的支持,有些机型传这个值会创建失败或者悄悄回退到MIC。所以创建AudioRecord后必须检查state,失败就换MIC重试:

fun createRecord(source: Int, sampleRate: Int, bufSize: Int): AudioRecord? { return try { val r = AudioRecord(source, sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufSize) if (r.state == AudioRecord.STATE_INITIALIZED) r else { r.release(); null } } catch (e: Exception) { null } }

还有一个音频源值得试:VOICE_RECOGNITION。它在部分机型上会关闭 AGC 但保留轻微降噪,实测在某些设备上比MIC更“干净”,底噪更低。我的做法是在设置页留一个“音频源”高级选项,默认UNPROCESSED,失败回退MIC,让用户自己对比。

2.3 模块划分与线程模型

工程结构上我习惯这么切:

  • AudioCapture:封装AudioRecord的生命周期,输出定长帧到阻塞队列。
  • DbCalculator:消费帧,算 RMS、dBFS、A 计权、时间计权、Leq 累加。
  • CalibrationStore:保存偏移量、音频源、计权模式等配置。
  • MeterView:自定义 View,画表盘、柱状条、历史曲线。
  • SessionRecorder:把每秒的结果落盘,供后续导出。

线程上只用两个常驻线程:采集线程和计算线程。采集线程优先级拉到音频级,这一句很关键:

Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO)

THREAD_PRIORITY_AUDIO是 -16,比默认优先级高很多。不加这句,后台有个 GC 或者别的线程抢 CPU,read()就会返回不足一帧的数据甚至阻塞,表现出来就是数值偶尔跳到很低再弹回来。这个细节我在第一次做的时候完全没意识到,排查了两天才发现是线程优先级的问题。

3. 采集层:采样参数怎么定,缓冲怎么算

3.1 采样率、声道、位深的取舍

采样率选 44100 还是 48000?从测量角度,环境噪声的主要能量集中在 20 Hz ~ 8 kHz,48 kHz 采样率对应 24 kHz 奈奎斯特频率,完全够用,而且能完整覆盖 A 计权关心的频段。44100 也够,但 48000 在部分设备上是麦克风的原生采样率,省掉了系统重采样这一步,理论上更“原汁原味”。我的默认值给 48000,在设置里允许降到 16000 做低功耗模式。

位深选ENCODING_PCM_16BIT,不用FLOAT。原因有两个:一是 16 位在多数设备上是硬件原生格式,转 float 反而可能引入额外处理;二是 16 位动态范围约 96 dB,对于测量 30~130 dB 的场景绰绰有余,环境安静时底噪大概在 25~35 dB 区间,16 位完全覆盖得住。

声道选单声道。环境噪声测量不需要立体声,双声道只会让数据量翻倍、计算量翻倍,而且两个麦克风的灵敏度和频响往往还不一致,反而增加不确定性。

这三个参数定下来,每秒钟的数据量是 48000 × 2 字节 = 96 KB,一帧 1024 采样点占 2 KB,每帧时长约 21.3 ms。这个帧长是个关键数字,后面算平滑系数要用到。

3.2 缓冲区大小的计算过程

AudioRecord.getMinBufferSize()返回的是能保证正常工作的最小字节数,直接用它当缓冲会非常危险,因为一旦计算线程稍微慢一点就会溢出丢帧。我的做法是取最小值的 4 倍,同时保证至少能装下 4 帧:

private const val SAMPLE_RATE = 48000 private const val FRAME_SIZE = 1024 // 每次读取的采样点数 val minBytes = AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) // 至少 4 帧,且不小于系统最小值的 4 倍 val bufferBytes = maxOf(minBytes * 4, FRAME_SIZE * 2 * 4)

算一下典型值:getMinBufferSize在多数设备上返回 3840 字节左右(约 1920 采样点),乘 4 得 15360 字节,而FRAME_SIZE * 2 * 4 = 8192字节,取大的那个,最终缓冲约 15360 字节,能装 7.5 帧。这意味着计算线程有约 160 ms 的容错窗口,正常情况下远远够用。

这里有个反直觉的点:缓冲不是越大越好。缓冲越大,容错窗口越宽,但read()的返回延迟也更不稳定,因为它可能一次性返回很多数据。对于实时显示,我宁可缓冲小一点(保证 100~200 ms 容错),也不要那种 1 秒缓冲带来的“数值滞后感”。

3.3 音频源的另一个坑:系统音效处理

即便用了UNPROCESSED,部分厂商 ROM 依然会在系统层开均衡器或者“音效增强”。判断方法是:把手机放在安静环境里(图书馆、深夜卧室),看底噪读数。如果读数在 20 dB 以下且波动剧烈,说明有处理在动;如果稳定在 30 dB 左右,一般就是正常的。曾经在某台设备上测出底噪只有 12 dB,接上参考设备一对比差了 20 多 dB,最后定位到是系统自带的“清晰音质”开关在起作用。

排查手法是写一个小工具页,把原始 PCM 波形直接画出来,看有没有被处理过的痕迹——被 AGC 处理过的信号,安静段的幅度会被“抬”起来,表现为波形永远不会贴着零轴。这个方法比看数字直观得多,建议在调试版本里常备。

4. 从 PCM 到分贝:算法链路和校准

4.1 RMS 与 dBFS 的换算

从一串 16 位整数到 dBFS,中间只有两步,但每一步都有细节。

先归一化再算均方根:

fun calcRms(buf: ShortArray, n: Int): Double { var sum = 0.0 for (i in 0 until n) { val v = buf[i] / 32768.0 sum += v * v } return sqrt(sum / n) }

除以 32768.0 而不是 32767.0,是因为 16 位有符号整数的最小值是 -32768,用 32768 做分母才能保证归一化后落在 [-1.0, 1.0) 区间,不会出现幅度 1.00003 这种尴尬值。

然后转 dBFS:

fun toDbfs(rms: Double): Double { if (rms < 1e-7) return -100.0 // 低于此值视为静音,避免 log(0) return 20.0 * log10(rms) }

这里的1e-7阈值不是随便定的。16 位量化噪声的 RMS 大约是 1/(32768 × √12) ≈ 8.8e-6,对应的 dBFS 约 -101 dB。所以低于 -100 dBFS 的读数基本就是量化噪声,没有物理意义,直接钳位掉更干净。

需要强调:这不是峰值检测,是 RMS 检测。人耳感知的是能量,噪声测量的标准做法也是能量平均,用峰值算出来的数字会比实际感受高出 8~12 dB,看着吓人但不准。这也是为什么有些“分贝仪”应用读数特别夸张,多半是用了峰值或者没做时间计权。

4.2 dBFS 到 dB SPL:校准才是核心技术

这中间只有一个偏移量:

dB SPL = dBFS + OFFSET

但OFFSET的确定方式,决定了整个应用精度的上限。理论上它能从麦克风灵敏度算出来:如果麦克风灵敏度是 -38 dBV/Pa,参考压力 94 dB SPL 对应 1 Pa,那么 94 dB SPL 输入产生 -38 dBV 电压;再结合 ADC 的参考电压和满量程关系,一路推下来能算出偏移量。但实际做不到,因为:

  1. 厂商几乎不公布手机麦克风的精确灵敏度;
  2. 系统在 HAL 层还有一层增益,值未知;
  3. 不同机型差异巨大。

所以只能实测校准。标准做法是找一个参考声源,读它的名义值,然后反推偏移量:

OFFSET = L_ref - dBFS_measured

比较靠谱的参考源有几种。最省事的是声级校准器,它会在 1 kHz 输出标称 94 dB 或 114 dB 的稳定声压,把手机麦克风对着它,读 10 秒平均 dBFS,直接算偏移量,误差能压到 1 dB 以内。没有校准器的话,退而求其次用一台经过校准的手持声级计作为参考,两者放在同一位置同时读稳定噪声源(比如风扇、空调外机的持续噪声),也能得到可用的偏移量,误差大概 2~3 dB。

我在模拟项目里的校准流程是这样的,做成一个四步引导页:

  1. 提示用户将手机与参考设备并排放置,距离噪声源一致;
  2. 开始采集,实时显示 dBFS 均值,采样 10 秒;
  3. 用户输入参考设备读数(比如 62.4 dB);
  4. 应用计算OFFSET = 62.4 - dBFS_均值并保存。

10 秒平均是有讲究的。读取的 dBFS 本身是带波动的,取算术平均会有偏差,正确做法是能量平均:

fun energyAverage(dbfsList: List<Double>): Double { val linearSum = dbfsList.sumOf { 10.0.pow(it / 10.0) } return 10.0 * log10(linearSum / dbfsList.size) }

因为分贝是对数单位,直接算术平均会低估能量(尤其是在波动大的时候)。这个错误我在早期版本里犯过,校准出来的偏移量偏高 2~3 dB,后来换成能量平均才对上。

实测下来,主流机型的偏移量落在 88~100 dB 区间,个别设备会到 105。这个值跟机型强相关,不能互相套用。所以校准结果必须按设备保存,我一般是把Build.MODEL和偏移量一起存,换设备时提示重新校准。

4.3 A 计权:不处理会差 5~10 dB

人耳对低频不敏感、对 2~5 kHz 特别敏感,所以噪声标准普遍要求用 A 计权。如果不做计权(也就是 Z 计权,平坦响应),测低频噪声时读数会比人耳感受高很多,测中高频时又偏低,整体偏差能到 5~10 dB。做环境噪声显示,A 计权基本是必选项。

时域实现 A 计权要级联多个双二阶滤波器,系数还得按采样率重新算,容易出错。我采用的是一条更直接的路径:做短时 FFT,在频域乘 A 计权曲线,再积分回总功率。这样既能拿到准确的总声级,还顺便能画出频谱图,一举两得。

A 计权的标准公式(IEC 61672-1)是这样的:

fun aWeightDb(f: Double): Double { if (f <= 0.0) return -200.0 val f2 = f * f val num = 12194.0 * 12194.0 * f2 * f2 val den = (f2 + 20.6 * 20.6) * sqrt((f2 + 107.7 * 107.7) * (f2 + 737.9 * 737.9)) * (f2 + 12194.0 * 12194.0) return 20.0 * log10(num / den) + 2.0 }

公式里的几个常数对应 A 计权曲线的转折频率:20.6 Hz 和 107.7 Hz 是低频衰减段,737.9 Hz 和 12194 Hz 是高频段,末尾的 +2.0 是归一化项,保证 1 kHz 处增益为 0 dB。

频域处理的完整流程:

fun calcASpl(frame: ShortArray, sampleRate: Int, offset: Double): Double { val n = frame.size val re = DoubleArray(n) { frame[it] / 32768.0 } val im = DoubleArray(n) // 汉宁窗,减少频谱泄漏 for (i in 0 until n) { re[i] *= 0.5 * (1 - cos(2 * PI * i / (n - 1))) } fft(re, im) var power = 0.0 val binHz = sampleRate.toDouble() / n for (k in 1 until n / 2) { val f = k * binHz val p = re[k] * re[k] + im[k] * im[k] power += p * 10.0.pow(aWeightDb(f) / 10.0) } if (power < 1e-20) return offset - 100.0 val dbfs = 10.0 * log10(power) - 10.0 * log10(n.toDouble()) return dbfs + offset }

这条链路在 1024 点、48 kHz 下每帧耗时可控制在 2~5 ms(手机中端芯片),对 21 ms 的帧长来说完全跟得上。想要更省电的话,可以把采样率降到 16 kHz,帧长降到 512,计算量直接降到四分之一。

注意:FFT 长度和采样率决定了频率分辨率。1024 点 @ 48 kHz 的分辨率是 46.9 Hz,对低频(100 Hz 以下)的计权精度不算高。如果主要测低频噪声,建议用 2048 点,分辨率提到 23.4 Hz。

4.4 时间计权与平滑:为什么数字会乱跳

刚算出来的每帧声级是不平滑的,直接显示会像心电图一样乱跳,因为每帧只有 21 ms,而人耳对声音的感知有个几百毫秒的积分窗口。标准里定义了三种时间计权:F(快档,125 ms)、S(慢档,1 s)、I(脉冲档,35 ms)。日常显示用 F 档,需要看长期趋势切 S 档。

实现方式是指数平滑,在能量域做:

// 每帧调用,dtSec 是帧时长(秒) fun smooth(newDb: Double, prevDb: Double, tauSec: Double, dtSec: Double): Double { val alpha = 1 - exp(-dtSec / tauSec) val e = (1 - alpha) * 10.0.pow(prevDb / 10.0) + alpha * 10.0.pow(newDb / 10.0) return 10.0 * log10(e) }

代入具体数值算一下:48 kHz、1024 点的情况下dtSec = 0.02133,F 档tauSec = 0.125,那么alpha = 1 - exp(-0.1707) ≈ 0.157。也就是说每帧有 15.7% 的新数据参与,大约 6 帧(128 ms)后能跟到实际值的 63%,这个响应速度用起来是合适的。S 档tauSec = 1.0,alpha ≈ 0.0211,反应明显变慢,适合看趋势。

平滑必须在能量域做,不能直接对 dB 值做算术平滑。这一点跟校准那个坑是同一个原理:dB 是对数尺度,线性插值出来的结果物理意义不对。

4.5 峰值保持和削波检测

除了实时值,还有两个数字值得显示。峰值保持记录一段时间内的最高瞬时值,实现很简单,维护一个变量,每次新值超过它就更新,配合一个 3 秒的衰减计时器往下掉。这个数字对判断突发噪声(敲击、关门声)很有用,因为平滑后的读数会把瞬态抹掉。

削波检测更重要,它是判断读数是否可信的关键指标。做法是统计一帧里接近满量程的采样点比例:

fun clipRatio(buf: ShortArray, n: Int, threshold: Int = 32000): Double { var cnt = 0 for (i in 0 until n) { if (abs(buf[i].toInt()) >= threshold) cnt++ } return cnt.toDouble() / n }

单帧削波比例超过 0.5% 就该在界面上亮个红点,提示“输入过载,读数偏小”。因为一旦削波,波形顶部被切平,RMS 会低估,算出来的分贝比实际低,而且低多少完全不可控。这个提示用户可能不懂,但你可以把它做成“读数可能偏低,请远离声源”的说明文案,比闷头显示一个错误数字负责得多。

5. 实时呈现:界面怎么稳住不掉帧

5.1 数据交接:不要在主线程算东西

采集线程算完的结果,通过一个简单的持有对象传给 UI:

class MeterState { @Volatile var instantDb: Float = 0f @Volatile var fastDb: Float = 0f @Volatile var peakDb: Float = 0f @Volatile var leqDb: Float = 0f @Volatile var clipping: Boolean = false }

多个独立@Volatile字段读的时候可能出现“半新半旧”的情况(比如 instant 是新值、fast 是旧值),对显示影响不大,可以接受。如果追求一致性,就换成AtomicReference持有一个不可变快照对象,每次计算完getAndSet一次,代价是每帧产生一个小对象,GC 压力可以忽略。

UI 侧的刷新节奏我固定在 30 fps,用Choreographer或者简单的postDelayed循环。这里有个反直觉的取舍:UI 刷新率不需要跟采集帧率一致。采集是 47 帧/秒,UI 刷 30 帧/秒,视觉上完全看不出差别,但功耗和主线程压力能降一截。如果做成 60 fps,肉眼收益几乎为零,反而更容易掉帧。

5.2 自定义 View 的绘制要点

表盘用Canvas手绘,几个关键点值得说。

弧线的起止角度要跟分贝范围对应。假设显示范围 30~120 dB,圆弧从 135° 扫到 405°(共 270°),那么任意分贝db对应的角度是:

val ratio = ((db - 30f) / (120f - 30f)).coerceIn(0f, 1f) val sweep = 135f + ratio * 270f

刻度线不要每 1 dB 画一条,太密。我按 10 dB 一条主刻度、5 dB 一条次刻度,视觉上干净。指针用drawLine加圆头笔帽就行,不要用贴图,缩放会糊。

历史曲线用Path累积最近 60 秒的采样点,每帧lineTo一次,超过点数上限就丢掉最早的点。这里有个性能陷阱:如果每帧都新建Path和FloatArray,30 fps 下每秒产生 30 个对象,虽然不多但会触发 GC,表现为曲线偶尔顿一下。正确做法是复用固定的FloatArray环形缓冲,绘制时按索引顺序moveTo/lineTo,只创建一个Path对象反复reset()。

深色模式适配也别忽略。这个应用的使用场景经常是夜间或者昏暗的设备间,纯白背景刺眼。我一般做两套配色,主色用低饱和度的青绿,在深色背景上对比度足够又不刺眼。

5.3 屏幕常亮与后台采集

测量过程中屏幕熄灭会带来两个问题:用户看不到读数,以及部分机型在息屏后限制后台线程调度导致采样断续。最简单的一招是给当前 Activity 加常亮标志:

window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

如果要做长时间连续记录(比如测一整晚的卧室噪声),就得用前台服务,并且声明麦克风类型:

<service android:name=".MeterService" android:foregroundServiceType="microphone" android:exported="false" />

清单里要配FOREGROUND_SERVICE和FOREGROUND_SERVICE_MICROPHONE两个权限。长时间记录的场景下,建议把采样率降到 16 kHz、UI 刷新关掉,只做每秒一次的 Leq 累加和落盘,功耗能压到每小时 2% 以内。这个优化很值得做,因为没人愿意为了测一晚噪声把手机电量耗光。

6. 统计与记录:Leq 和它的朋友们

6.1 等效声级 Leq 的累加算法

瞬时值只能说明“此刻多吵”,要评价一段时间的环境,得用等效连续声级 Leq,也就是能量平均。这个数字是绝大多数噪声标准的评价指标,做出来很有说服力。

累加的思路是每秒算一次能量,累加后除以总时长:

class LeqAccumulator { private var energySum = 0.0 private var totalSec = 0.0 fun add(db: Double, dtSec: Double) { energySum += dtSec * 10.0.pow(db / 10.0) totalSec += dtSec } fun value(): Double = if (totalSec <= 0) 0.0 else 10.0 * log10(energySum / totalSec) }

注意dtSec用的是实际累加的时长,不是固定 1 秒。如果某一秒因为线程调度没采到数据,这一秒就不该计入总时长,否则 Leq 会被稀释得偏低。我在实现里加了个判断:只有该秒的有效帧数超过 40 帧(总帧数 47)才计入,否则跳过并记一次“数据缺口”。

6.2 统计分布:L10、L50、L90

做环境噪声评价的人经常要这三个数:L10 表示 10% 的时间超过这个声级(反映峰值水平),L50 是中位数,L90 是背景噪声(90% 的时间都超过它)。实现方式是把每秒的声级存进一个数组,记录结束后排序取分位:

fun percentile(sorted: DoubleArray, p: Double): Double { if (sorted.isEmpty()) return 0.0 val idx = ((sorted.size - 1) * p).toInt() return sorted[idx] }

排序放在测量结束后做,不要边测边排。一分钟的数据是 60 个点,排一次几微秒,完全不用优化。测量时长超过一小时的话,可以在内存里保留到 3600 个点,再多就分段聚合,避免数组无限膨胀。

6.3 数据落盘与导出

记录结果我存成 CSV,一行一秒,字段包括时间戳、瞬时值、Fast 值、Leq 累计值、是否削波。选 CSV 而不是数据库,是因为导出后用户能直接用表格软件打开看,也能发给别人,通用性最好。

写入用BufferedWriter包一个FileOutputStream,每次write后不flush,靠缓冲区攒着,每 60 秒flush一次。频繁 flush 会让 IO 次数暴增,长时间记录时反而更耗电。文件名带时间戳,存到应用专属的外部目录,卸载时自动清理,不会在用户相册里留垃圾。

提示:Android 10 以后访问公共存储要走 MediaStore 或者 SAF,直接往/sdcard写会失败。导出功能我用ACTION_CREATE_DOCUMENT让用户自己选位置,兼容性最好,也避免了存储权限申请的麻烦。

7. 常见问题与排查实录

7.1 故障速查表

下面这张表是我在实际项目里积累出来的,按“现象 → 可能原因 → 处理方式”整理,遇到问题可以先对着扫一遍:

现象可能原因处理方式
数值一直显示 0 或 -100未获录音权限,或AudioRecord创建失败检查state,权限申请后重建实例
数值剧烈跳动,无规律未做时间计权,或帧长过短加 F 档平滑,帧长不小于 512 点
底噪读数异常低(<20 dB)系统音效处理介入换音频源,检查系统音效开关
底噪读数偏高且稳定麦克风被遮挡,或壳膜堵孔清理开孔,换位置复测
大声时读数反而下降输入削波加削波检测,提示远离声源
后台运行时数据中断息屏后线程被限制前台服务 + 常亮 + 唤醒锁
两台手机差距超过 10 dB未分别校准每台单独校准并绑定保存
曲线卡顿、掉帧主线程做计算或频繁创建对象计算移出主线程,绘制复用对象
数值延迟明显(>1 秒)缓冲过大,或read阻塞缩小缓冲到 4~8 帧
校准后仍有 3~5 dB 偏差校准声源不稳定,或距离不一致换稳定声源,固定摆放位置

7.2 设备差异到底有多大

这是所有做声学应用的人都绕不过去的问题。同一段白噪声,在五台不同手机上同时测,未校准时读数能拉开 15 dB。主要来源有三个:麦克风灵敏度差异(约占 8~10 dB)、系统增益设置差异(3~5 dB)、频响曲线差异(对特定频率能差更多)。

好消息是,这些差异大部分是线性偏移,通过单点校准能消掉绝大部分。校准后剩下的偏差主要来自频响不平,这部分用单点校准解决不了,会随噪声的频谱特性变化。我的经验是:宽带噪声(风扇、空调、车流)校准后误差能压到 3 dB 内;窄带噪声(单一频率的蜂鸣、电机啸叫)误差可能到 5~8 dB,因为频响不匹配被放大了。

所以在应用里我加了一句说明,写清楚“本应用适用于宽带环境噪声的快速估算,不适用于单一频率声源的精确测量”。这句话看着是免责,实际上是让用户建立正确预期,比事后解释说“手机不是专业设备”有用得多。

7.3 麦克风位置和握持姿势的影响

这个坑特别隐蔽,但影响能到 10 dB 以上。很多人测噪声时习惯握着手机底部,手掌正好捂住麦克风开孔(多数在底部或者背面摄像头附近),读数直接掉一大截,而且低频被手挡住后高频成分反而突出,频谱也失真。

我在引导页里放了一张示意图,标出常见机型的麦克风位置,并提示“手持时避开底部和背面开孔,或者放在支架上”。这个小小的交互改动,让用户反馈的“测不准”问题减少了大约一半,比我改算法管用得多。

还有一个容易忽略的点:手机壳。厚壳如果开孔对不准麦克风,相当于加了个声学阻尼,高频衰减明显。测试时建议取下壳子,这个细节在说明书里写一句就够了。

7.4 校准流程怎么设计才不容易出错

校准是这个应用最容易出错的环节,因为要依赖用户操作。我踩过的坑包括:用户没等稳定就点确认、参考设备读数输错、校准声源中途变了。后来把流程改成强约束的四步:

第一步,进入校准页后先跑 5 秒预热,界面显示“环境稳定中”,等 dBFS 的标准差小于 1.5 dB 才允许下一步。第二步,自动采集 10 秒并做能量平均,全程显示进度和实时值。第三步,输入参考值,输入框做范围校验(30~130),超范围直接拦下来。第四步,计算并显示校准前后的对比,让用户确认。

标准差门槛那一条特别有用,它把“用户对着一个正在变化的噪声源校准”这种最常见错误挡在了门外。这个门限值 1.5 dB 是试出来的:太严(0.5 dB)用户等半天通不过,太松(3 dB)就失去意义了。

7.5 关于“准不准”这件事的个人看法

做了这么久,我最大的体会是:手机端的分贝应用,价值不在绝对精度,而在相对变化和可重复性。你想要知道“今天比昨天吵了多少”“这个房间和那个房间哪个更吵”“这台设备运行时的噪声是不是比上周高了”,这些问题的答案不依赖绝对校准,只要测量条件一致就行。而绝对精度受限于硬件,再怎么写算法也很难突破。

所以我在产品设计上做了取舍:把“趋势记录”和“对比”放在主界面,把“绝对读数”做成辅助信息,并明确标注“估算值,仅供参照”。这个方向调过来之后,用户满意度反而更高,因为大家真正关心的是“有没有变差”,而不是“精确到 0.1 dB”。

最后分享一个实用的小技巧:如果你想快速验证自己的应用有没有明显 bug,找一段稳定的白噪声(网上的测试音频、风扇声都行),录一段原始 PCM 存成文件,然后用同一段文件反复跑你的计算链路,看输出是否完全一致。如果两次结果不同,说明链路里有随机性或者时序依赖,先把这个查清楚,再去纠结校准精度。这个自测方法帮我省下了大量排查时间,值得一开始就搭起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询