简介:本资源是一份面向Android开发初学者与中级工程师的环境噪音检测功能实现源码包,解决在移动设备上实时采集麦克风音频并计算分贝值的核心技术问题,适用于噪声监测类App开发、IoT传感集成或高校移动应用实验场景。压缩包共28个文件,含11个Java/Kotlin类文件(实现MediaRecorder音频采集、分贝算法计算与UI更新逻辑)、4张PNG界面截图(含主界面与分贝显示效果)、3个XML布局与配置文件、2个核心Java业务逻辑文件,以及APK安装包、README说明文档和Android项目标准配置文件,整体仅78KB,轻量易读。已有112人学习下载,代码结构清晰,完整覆盖麦克风权限申请、AudioSource.MIC配置、.3gp临时录音、PSD功率谱密度计算及10×log10(P/ref)分贝换算等关键环节,并采用异步处理避免主线程阻塞,附带可直接运行的APK与图文说明,便于快速验证与二次开发。 如果你搜“Android 测试环境分贝”这个关键词,大概率会翻到一些打包好的源码工程,下载下来却经常是一堆零零散散的文件,要么缺依赖,要么跑起来就崩。我自己也踩过几次这种坑,所以这次把一套能直接跑通的环境噪音检测源码完整梳理了一遍,顺便把分贝计算、麦克风权限适配、实时刷新这些关键点都拆开讲清楚。这篇文章适合刚接触Android多媒体开发的新手,也适合需要在项目里快速集成噪音检测模块的朋友,照着抄就能用。
我先说结论:这套源码的核心思路并不复杂,就是通过MediaRecorder的getMaxAmplitude()拿到麦克风采集到的原始振幅,再换算成dB分贝值,最后用自定义View或TextView刷新到界面上。但真正落地的时候,光这一个流程里就藏着不少坑,比如模拟器返回值为0、不同机型麦克风灵敏度差异、Android 6.0以上的运行时权限、Fragment与Service的通信方式等等,下面我都会逐一说明。
1. 整体设计思路拆解
1.1 为什么选择MediaRecorder而不是AudioRecord
最开始做噪音检测的时候,我第一反应是用AudioRecord配合AudioTrack做PCM采集,然后自己写FFT做频谱分析,这样能得到更精确的频域数据。但实际试下来发现有两个问题。
第一,AudioRecord的采样率、缓冲区大小、通道数这些参数如果配置不合适,采集到的数据会出现很大的杂音,需要额外做滤波处理;第二,FFT计算在低端机型上会明显耗电,而且这个项目要的是一个简单的“环境分贝数值”,不需要分析频谱成分,属于杀鸡用牛刀。
相比之下,MediaRecorder内部已经封装好了音频采集和编码逻辑,系统会持续更新当前捕获到的最大振幅,我们只需要通过getMaxAmplitude()这个方法去读取瞬时值就行。这个方法的底层实现是读取音频采集硬件寄存器的状态,返回的是0到32767之间的整数,数值越大代表声音越响,刚好对应Android设备上16位PCM采样的幅值范围。
1.2 分贝计算的数学基础
分贝(dB)本身是一个相对单位,它表示的是测量值与参考值之间的对数关系。环境噪音检测中常用的公式是:
dB = 20 * log10(amplitude / referenceAmplitude)其中referenceAmplitude是参考振幅。在Android的MediaRecorder返回数据中,振幅范围是0到32767,我们一般取1作为参考值,这样计算出来的结果在0到90dB之间浮动,和市面上常见的噪音计读数大致在一个量级。如果希望数值更接近真实设备的声压级,可以取32767 / 90作为参考值,把最大读数映射到90dB,但这样做出来的结果只是相对值,不是绝对声压级,这个我在后面“精度校准”那节再展开说。
1.3 核心模块划分
这套源码从架构上分为几个独立的模块,各司其职:
- 权限处理模块:负责检查、申请
RECORD_AUDIO权限,兼容Android 6.0及以上动态权限。 - 噪音采集模块:封装
MediaRecorder对象的创建、启动、读取、停止逻辑。 - 振幅-分贝转换模块:把
getMaxAmplitude()返回的原始值换算成可读的dB值。 - UI刷新模块:用一个
TextView展示实时分贝值,同时可以用SeekBar或自定义View画一个动态柱状图。 - 生命周期管理模块:在
Activity或Fragment的onResume和onPause中控制采集器的启动与释放。
如果后续想扩展历史记录、图表统计,可以在这个基础上叠加数据库和图表库,核心采集模块不需要改动。
2. 核心细节解析与实操要点
2.1 AndroidManifest.xml权限配置
在AndroidManifest.xml中添加录音权限:
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-feature android:name="android.hardware.microphone" android:required="true" />这里有个比较容易忽略的点:uses-feature声明required="true"之后,在Google Play等应用商店中,没有麦克风的设备(比如部分电视盒子)会直接被过滤掉,无法安装。如果你想保留这部分用户,可以把required改成false,然后在代码中通过PackageManager.hasSystemFeature(PackageManager.FEATURE_MICROPHONE)做运行时判断。
另外,如果你把targetSdkVersion设到了31及以上(Android 12),还需要注意包可见性问题。不过对于录音权限来说,只要申请了RECORD_AUDIO,系统会自动放行麦克风设备的访问,不需要额外加<queries>声明,这一点和蓝牙、USB设备不一样。
2.2 运行时权限申请的正确姿势
Android 6.0(API 23)开始,录音权限属于危险权限,必须在运行时动态申请。这里推荐直接使用ActivityResultContracts.RequestPermission(),比老一套的onRequestPermissionsResult()简洁很多。
class MainActivity : AppCompatActivity() { private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { startNoiseMonitor() } else { Toast.makeText(this, "需要麦克风权限才能检测分贝", Toast.LENGTH_SHORT).show() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) checkPermissionAndStart() } private fun checkPermissionAndStart() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { startNoiseMonitor() } else { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } }这里有个细节:checkSelfPermission的判断结果可能会因为用户选择“仅在使用中允许”而出现差异,但录音权限本来就属于前台使用的场景,所以这个影响不大。真正要注意的是,如果用户连续拒绝两次以上,系统会进入“不再询问”状态,这时候再去launch()是不会弹出系统对话框的,你需要引导用户到应用设置页手动打开。可以在shouldShowRequestPermissionRationale()返回false时,跳转到Settings.ACTION_APPLICATION_DETAILS_SETTINGS。
2.3 MediaRecorder初始化参数选择
getMaxAmplitude()在使用上有一些限制,比如无法在MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED回调里获取实时数值,也不支持在后台长时间不写入文件时一直调用,否则返回的振幅值不会变化。这个特性后面排查问题时会用到,这里先记住。
MediaRecorder的初始化需要指定音频源、输出格式、编码格式和输出文件路径。我这里用一个通用模板:
private MediaRecorder mediaRecorder; private void initMediaRecorder() { mediaRecorder = new MediaRecorder(); mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); mediaRecorder.setAudioEncodingBitRate(128000); mediaRecorder.setAudioSamplingRate(44100); // 这里必须设置一个真实的文件路径,MediaRecorder不写文件直接启动会崩溃 mediaRecorder.setOutputFile(getExternalCacheDir().getAbsolutePath() + "/noise_test.3gp"); try { mediaRecorder.prepare(); mediaRecorder.start(); } catch (IOException e) { e.printStackTrace(); } }很多第一次接触MediaRecorder的开发者会尝试不设置setOutputFile,或者设置一个空路径,想着“我又不真的录音,只是取个振幅值而已”。实测下来,prepare()阶段就会直接抛IllegalStateException,根本走不到start(),所以哪怕你根本不关心录音文件内容,也必须要有一个合法路径。我一般写到getExternalCacheDir()下面,属于应用缓存目录,不需要额外申请存储权限,用完即走。
2.4 getMaxAmplitude()的注意点
getMaxAmplitude()返回的是“自上一次调用以来,捕获到的最大振幅值”,也就是说你每次读完它之后,内部计数器会重置。所以正确的读取方式,是在一个定时任务里循环调用这个方法,不读的时候不要频繁访问,避免拿到的是同一个历史峰值。
实测中发现,如果你在MediaRecorder启动后立刻调用getMaxAmplitude(),返回的往往不是0就是很小的值,因为底层音频管线还没跑起来。稳妥的做法是等start()之后延迟200ms到300ms再开始轮询。我习惯用Handler.postDelayed做定时读取,每次间隔100ms,既能保证实时性,又不会让UI线程卡顿。
3. 实操过程与核心环节实现
3.1 构建噪音检测工具类
为了让代码复用性更强,我把采集逻辑单独封装成一个NoiseDetector类,对外提供start()、stop()和getCurrentDb()三个方法。
public class NoiseDetector { private MediaRecorder mediaRecorder; private boolean isRecording = false; private volatile double currentDb = 0; public void start() { if (isRecording) return; initMediaRecorder(); isRecording = true; // 每次读取之前等待一段时间,让底层音频管线稳定 new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { @Override public void run() { if (isRecording) { currentDb = computeCurrentDb(); new Handler(Looper.getMainLooper()).postDelayed(this, 100); } } }, 300); } private void initMediaRecorder() { mediaRecorder = new MediaRecorder(); mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); mediaRecorder.setOutputFile(FileUtils.getCacheFilePath()); try { mediaRecorder.prepare(); mediaRecorder.start(); } catch (Exception e) { Log.e("NoiseDetector", "initMediaRecorder error", e); } } private double computeCurrentDb() { if (mediaRecorder == null || !isRecording) return 0; int amplitude = mediaRecorder.getMaxAmplitude(); if (amplitude <= 0) { return 0; } return 20 * Math.log10(Math.max(amplitude, 1) / 1.0); } public int getCurrentDb() { return (int) currentDb; } public void stop() { isRecording = false; if (mediaRecorder != null) { try { mediaRecorder.stop(); } catch (RuntimeException e) { // 防止stop时状态异常导致崩溃,这里必须捕获 Log.w("NoiseDetector", "MediaRecorder.stop() failed", e); } mediaRecorder.release(); mediaRecorder = null; } } }上面代码里这里有个值得注意的点:stop()的时候一定要包一层RuntimeException捕获。因为MediaRecorder在没正常写入数据的情况下调用stop(),比如刚初始化完还没开始运行时就被外界打断,系统会直接抛RuntimeException,不处理就闪退。这个异常在真机上出现的频率不低,尤其是快速进出页面的场景。
3.2 UI层实时展示分贝数值和柱状图
UI层我用了两个控件配合:一个TextView展示数值,一个ProgressBar或自绘View展示动态条。考虑到ProgressBar的样式默认比较死板,直接用自定义View绘制垂直柱状图效果更好。
public class DbBarView extends View { private Paint paint; private int dbValue; public DbBarView(Context context, AttributeSet attrs) { super(context, attrs); paint = new Paint(); paint.setColor(Color.parseColor("#FF6B35")); paint.setAntiAlias(true); } public void setDb(int db) { this.dbValue = db; // 在UI线程中触发重绘 postInvalidate(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int barHeight = (int) (getHeight() * (dbValue / 100.0f)); // 从底部往上绘制矩形,模拟音量柱 canvas.drawRect(0, getHeight() - barHeight, getWidth(), getHeight(), paint); } }3.3 在Activity中串联整个流程
在Activity中启动检测流程,生命周期回调里注意释放资源:
public class MainActivity extends AppCompatActivity { private NoiseDetector noiseDetector; private DbBarView dbBarView; private TextView dbTextView; private Handler handler = new Handler(Looper.getMainLooper()); private Runnable uiRunnable; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); dbBarView = findViewById(R.id.dbBarView); dbTextView = findViewById(R.id.dbTextView); noiseDetector = new NoiseDetector(); findViewById(R.id.btnStart).setOnClickListener(v -> startNoiseMonitor()); findViewById(R.id.btnStop).setOnClickListener(v -> stopNoiseMonitor()); } private void startNoiseMonitor() { noiseDetector.start(); uiRunnable = new Runnable() { @Override public void run() { int db = noiseDetector.getCurrentDb(); dbTextView.setText(String.format(Locale.CHINA, "%d dB", db)); dbBarView.setDb(db); handler.postDelayed(this, 200); } }; handler.post(uiRunnable); } private void stopNoiseMonitor() { handler.removeCallbacks(uiRunnable); noiseDetector.stop(); } @Override protected void onPause() { super.onPause(); stopNoiseMonitor(); } }这里有一个细节:onPause()里停止采集,是防止用户按Home键返回桌面后,MediaRecorder仍然在后台占用麦克风。Android系统不允许普通应用在后台持续访问麦克风,一旦切换到后台,系统会直接杀掉录音会话,但是如果你不主动释放MediaRecorder,再次回到前台时会发现它已经处于异常状态,需要重新初始化。所以我习惯绑定onPause而不是onDestroy,因为onDestroy在Activity被系统回收时并不一定及时调用。
4. 常见问题与排查技巧实录
4.1 问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 一直显示0dB | 模拟器不支持麦克风或getMaxAmplitude()恒为0 | 使用真机测试 |
| 启动时崩溃 | MediaRecorder未设置setOutputFile或权限被拒绝 | 检查权限和输出路径 |
| 分贝值不动 | getMaxAmplitude()读取频率太低或未重置 | 定时循环读取,每100ms一次 |
| 数值跳变剧烈 | 环境本身噪音波动大,或采样点太少 | 对连续5次结果取平均再展示 |
| 切换页面后无法再次启动 | stop()后未正常release() | 在stop()中统一release() |
prepare()抛IOException | 文件路径不可写或编码参数不兼容 | 换到cacheDir,使用AAC编码 |
4.2 模拟器上getMaxAmplitude()返回0的坑
如果你用Android Studio自带的模拟器调试,大概率会遇到一个问题:无论怎么对着麦克风喊,分贝值永远是0。这是因为大多数模拟器镜像没有正确映射宿主机的麦克风设备,MediaRecorder虽然可以启动,但底层拿不到真实音频数据流。
解决思路有三种:
- 去
AVD Manager设置里,把Microphone硬件配置改为Virtual并勾选允许模拟。 - 在模拟器的高级设置中手动指定宿主音频输入设备。
- 最省事的办法,直接换真机测试。我后来在做音频类项目时,基本放弃用模拟器验证采集逻辑,只拿它测试UI布局,省下来的时间远大于折腾模拟器的时间。
4.3stop()后再次start()失败的排查思路
有段时间我发现,检测页面退出再进来,start()之后读取到的分贝值一直保持不变,就像“卡死”了一样。后来打日志才发现,是stop()时没有正确调用reset(),导致MediaRecorder内部状态机停留在“Stopped”状态,此时再次调用start()虽然不报错,但底层并没有重新开启采集。
正确写法是在每次stop()后,把MediaRecorder对象置空,下次start()时创建新实例。上面工具类里的写法已经做了这个处理,这里单独强调一下,因为很多网上流传的写法都只做了release()而没有置空引用。
4.4 分贝数值与真实噪音计的差异
用手机测出来的分贝值和专业噪音计之间,一般会差2到10dB,这属于正常范围。原因是手机麦克风的灵敏度和频响曲线与标准声压计不同,再加上手机外壳遮挡、手握方式等因素,会导致高频段衰减严重。
如果想尽量接近真实值,可以做一次“单点校准”:在安静环境下对比手机读数和噪音计读数,算出差值作为偏移量,然后每次计算结果加上这个偏移量。但考虑到绝大多数使用场景只是要一个相对值,比如判断“当前环境比较吵”还是“比较安静”,这个偏移量其实无所谓,我更推荐用相对值做一个分级展示。
public String getNoiseLevel(int db) { if (db < 30) { return "安静"; } else if (db < 50) { return "正常交谈"; } else if (db < 70) { return "较吵"; } else { return "非常吵"; } }4.5 音频焦点和并发冲突
如果你的应用同时还有播放音乐的功能,直接开启MediaRecorder可能会导致音乐音量被压低,甚至在某些定制ROM上出现采集不到声音的情况。这是因为Android的音频焦点机制默认会让多个音频流并发,但麦克风采集和扬声器播放同时进行时,部分机型会触发回声消除等功能,影响采集数据。
目前这个阶段,我的建议是:检测分贝时不要播放高频内容,如果非要做背景音乐,可以降低音乐音量并选择不带AEC(回声消除)的音频输出模式。这个项目以后扩展成“睡眠监测”或“环境噪音记录”时,这个交互细节会直接影响用户体验。
5. 性能优化与体验细节打磨
5.1 数据平滑处理
getMaxAmplitude()返回的是两次读取之间的峰值,这个值波动非常大。比如你安静坐着,偶尔一声咳嗽会让峰值直接飙到80dB以上。如果直接把峰值展示到UI上,用户会觉得这个检测器“疯了”。
我的做法是维护一个长度为5的环形缓冲,每次取平均值再展示。这样既能保证一定实时性,又不会让显示值来回乱跳。
private final int[] buffer = new int[5]; private int bufferIndex = 0; private int bufferCount = 0; private int getSmoothDb() { int rawDb = (int) computeCurrentDb(); buffer[bufferIndex] = rawDb; bufferIndex = (bufferIndex + 1) % buffer.length; if (bufferCount < buffer.length) bufferCount++; int sum = 0; for (int i = 0; i < bufferCount; i++) sum += buffer[i]; return sum / bufferCount; }5.2 持续监测时的耗电优化
如果页面需要长时间检测噪音,比如做“噪音记录仪”或“分贝仪”这类应用,耗电问题避不开。MediaRecorder本身的耗电不算大,但如果每100ms刷新一次UI,CPU会频繁被唤醒做绘制操作,亮屏状态下耗电还是可感知的。
实测下来,把UI刷新间隔从100ms调整到500ms,用户几乎感知不到卡顿,但耗电会明显下降。如果你需要做更精确的趋势曲线,可以每100ms计算一次,但只把数值缓存到内存,每2秒批量刷新一次图表。
5.3 退出页面时的资源回收顺序
这里有一个经常被忽略的顺序问题:先停UI刷新,再停MediaRecorder。如果反过来,你在onPause里先调noiseDetector.stop(),但UI的Runnable还在继续执行,再次调用getCurrentDb()时,mediaRecorder已经是null,computeCurrentDb()里虽然做了判空,但日志里会持续打空指针警告,而且浪费电量。
正确顺序是:
- 调用
handler.removeCallbacks(uiRunnable)停止UI刷新。 - 调用
noiseDetector.stop()释放麦克风。 - 在
onDestroy里再置空ContentValues之类的临时数据(本项目中无)。
6. 更进一步的扩展思路
6.1 将分贝数据绘制成趋势图
单纯展示当前分贝值还不够直观,做一个随时间变化的折线图会更有说服力。可以引入一个轻量级图表库如MPAndroidChart,也可以用自绘SurfaceView做实时波形,但SurfaceView的绘制逻辑更复杂。我倾向用MPAndroidChart的LineChart,把历史数据按时间戳存入ArrayList,超过一定数量就移除最早的数据点,实现滚动窗口。
6.2 保存检测记录到本地
在“分贝记录仪”这类应用中,需要把每次检测的均值、峰值、持续时长保存下来。用Room数据库比较方便,表结构可以这样设计:
CREATE TABLE noise_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_time LONG NOT NULL, end_time LONG NOT NULL, avg_db INTEGER NOT NULL, max_db INTEGER NOT NULL, min_db INTEGER NOT NULL );通过Executors.newSingleThreadExecutor()做异步写入,避免阻塞主线程。
6.3 添加通知栏常驻提醒
如果需要后台持续监测噪音水平,依赖前台服务配合前台通知才能保证进程不被回收。通知里可以实时更新当前分贝值,让用户不打开App也能看到数据。这部分需要动态更新通知的setContentText,并且注意targetSdk31以上的通知权限适配。
6.4 接入系统音量键或物理按键交互
在分贝仪场景中,用户可能习惯通过物理音量键来切换显示单位或开始/暂停检测。Android 13开始,VolumeKey的拦截逻辑更严格,需要确保应用获得INTERACT_ACROSS_USERS_FULL权限或者在onKeyDown中判断按键来源。这个需求在普通App中不常见,但如果要做特定人群的测试工具,可以加上。
7. 最后的几个建议
分享几个我实际开发中总结出来的习惯,不一定都在文档里写着,但真的能帮你少走弯路。
第一,MediaRecorder的getMaxAmplitude()拿到的数值是“相对值”而不是“绝对值”,不要指望它和专业的声级计打表一模一样。如果要做校准,建议在不同的环境音量下采集多组数据,做一次线性回归,比简单加一个固定偏移量靠谱得多。
第二,真机调试时最好准备两台不同品牌的手机。高通芯片和联发科芯片在音频采集路径上的表现差异很大,有些机器麦克风增益偏低,同样的环境下读数能差出十来个dB。提前发现这种差异,比上线后收到用户反馈再改要舒服得多。
第三,MediaRecorder在使用过程中如果遇到prepare()一直失败,先检查一下输出文件目录是否存在、是否有写入权限。虽然getExternalCacheDir()大概率没问题,但如果你的应用在Android 11上运行且targetSdk是30,某些OEM的定制ROM对缓存目录的访问策略略显诡异,最保险的方式是提前判空并动态创建目录。
最后,分贝检测这个功能看起来简单,真正把它打磨到“好用”的状态,需要考虑的点其实不少。当你能把数值从“动不动就飘到90dB”优化到“稳定反映环境真实噪音水平”的时候,说明你对Android音频采集机制已经有比较深的理解了,这时候再去做录音、音频可视化、语音唤醒之类的功能,都会顺畅很多。
本文还有配套的精品资源,点击获取