news 2026/9/9 12:57:20

AudioRecord.startRecording()深度解析:从参数配置到避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AudioRecord.startRecording()深度解析:从参数配置到避坑指南

上一篇文章还在整理AudioTrack的播放流程,这周又有读者问:录音该用MediaRecorder还是AudioRecord?什么时候必须用AudioRecord.startRecording()?其实这个问题我在好几个项目里都被问过,最典型的场景就是“实时环境噪音监测”——需求方以为录个音很简单,结果MediaRecorder只给你一个.aac文件,别说实时分贝了,连拿到原始PCM数据都做不到。这篇文章就把AudioRecord.startRecording()从头到尾拆开讲一遍,包括它背后的音频链路、启动前必须配置的参数、启动后的数据读取,以及在Android 16这种新系统版本上哪些坑变了、哪些坑还在。

我知道网上已经有很多AudioRecord的Demo,但多数Demo就是把代码抄一遍,至于为什么缓冲区要那样算、为什么录音必须在子线程、为什么有时候startRecording明明没抛异常却读不出数据,这些核心问题往往被一笔带过。这篇文章除了给出一个能直接复用的示例,还会把那些“不写进文档里的经验”一并讲清楚。

1. 为什么录音还得选AudioRecord:不止是“录一段声音”

1.1 MediaRecorder不够用的时候

先聊一个最基础但很多人搞混的问题:什么场景用MediaRecorder,什么场景必须切到AudioRecord。

MediaRecorder看名字就知道,它偏重“录制并编码”。你给它一个文件路径,它把麦克风采集到的声音编码成AAC、AMR、MP3之类的格式,录音结束之后你拿到的是一段可以直接播放的文件。对“语音备忘录”这类需求来说,它简直是零成本方案,两三行代码就能启动播放,而且压缩后的文件体积小。缺点也很明显——你拿不到原始音频数据。它的内部从采集、编码到写入文件是一条完整的链路,应用层基本没有插手空间。

AudioRecord完全是另一条路线。它不编码,只负责从麦克风采集原始PCM数据,然后把数据填进你指定的缓冲区,再由你调用read()把数据读出来自己处理。想算分贝、画波形、做语音识别前处理、喂给算法库做降噪,都必须在拿到原始PCM的前提下才能继续。换句话说,MediaRecorder解决的是“把声音存下来”,AudioRecord解决的是“把声音读到内存里供你折腾”。

我为什么会从MediaRecorder转到AudioRecord?有一次接了个分贝仪的需求,PM说得很轻松:“打开麦克风录音,算个分贝值显示就行了。”一开始想走捷径,用MediaRecorder录完再分析,结果发现文件编码、时间戳对齐、实时性全都对不上,尤其是用户要的是动态变化的实时分贝,不是录完了再算一次。不得已切到AudioRecord,整个世界清净了。所以如果你也遇到类似的需求,直接放弃MediaRecorder,AudioRecord才是正主。

1.2 AudioRecord在Android音频体系中的位置

再看它在系统里的位置。Android的音频链路由底层到上层大致是:麦克风驱动、音频HAL(Hardware Abstraction Layer)、AudioFlinger系统服务、AudioRecord客户端、应用进程。AudioRecord是应用层直接面向PCM数据的入口,它和音频播放方向的AudioTrack是对称的:一个负责把数据写出去,一个负责把数据读进来。

整个流程中,AudioRecord只负责客户端这一侧的事情:配置采集参数、维护一块数据缓冲区、通过read()把缓冲区的数据搬运到应用层。真正控制麦克风硬件、处理混音策略、管理音频路由的,是AudioFlinger系统服务以及底层的HAL。这就解释了一个现象:startRecording()返回很快,但麦克风并不一定立刻就有数据,因为采集链路要经过系统服务、底层驱动、硬件调度,存在一个启动延迟。

理解这个模型之后,你对startRecording()的定义会更清楚:它只是向系统声明“我要开始采集了”,然后系统在后台把数据源源不断填进缓冲区,应用层要做的,是保证自己读取数据的速度永远别落后于系统填充数据的速度。这个“追不上就得丢数据”的约束,是设计录音模块时最重要的底层逻辑。

2. startRecording()之前:权限、参数和缓冲区配置的取舍

2.1 Manifest权限与运行时权限,一个都不能少

AudioRecord要能真正录到声音,第一步是权限。很多人栽在权限上,而且是那种“看起来已经授权了,实际还是录不了”的隐蔽情况。

先看Manifest层面。RECORD_AUDIO是必须声明的,这没什么好说。如果你要录制的场景是应用退到后台仍然录音,Android 14(API 34)开始还要求前台服务声明microphone类型,并且需要额外的FOREGROUND_SERVICE_MICROPHONE权限。这项要求我在多个项目里都踩过,简单说就是:你的应用如果需要在后台进行录音,不能只开一个普通Service,必须在Manifest里把Service标记为android:foregroundServiceType="microphone",然后调用startForeground()时传递对应的前台服务类型。

<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<service android:name=".RecordingService" android:foregroundServiceType="microphone" android:exported="false" />

然后是运行时权限。Android 6.0之后RECORD_AUDIO是危险权限,必须动态申请。这块常规写法大家都会,但有几个容易被忽略的细节:一是用户在Android 12之后可以一键关闭系统级的麦克风开关,后台申请权限大概率被拒绝;二是“仅本次允许”这种一次性授权模式,会让应用在进程被杀后重新授权;三是权限弹窗被用户拒绝两次以上之后,系统可能不再弹窗,只能引导用户去设置页手动打开。

我见过最典型的翻车现场,是同事在真机调试时发现startRecording()抛了SecurityException,查了半天才发现他用的是debug包,某个版本的targetSdkVersion拉到了32以上,但Manifest里的权限声明在一行合并冲突中被干掉了。遇到录音权限问题,第一步永远是打开adb shell dumpsys package <包名>看permissions里的granted状态,而不是直接怀疑代码逻辑。

2.2 采样率、通道和编码格式怎么选才不会启动失败

权限到位之后,AudioRecord的构造参数直接影响startRecording()能不能成功。这里面最核心的三件套是采样率、通道数、编码格式。

采样率方面,44100Hz是Android官方文档明确标注的“所有设备必须支持”的值,47kHz、48kHz在绝大多数设备上也能跑,但某些老旧设备、某些国产ROM的特定机型可能有不支持的情况。如果你的产品没有特殊需求,无脑选44100Hz最稳妥。另外要提一句,采样率不是越高越好——96kHz确实听起来更“HiFi”,但PCM数据量直接翻一倍,read()循环的压力、内存占用、后续处理的耗时全都会增加。做实时监测和语音识别类功能,44100Hz或者48000Hz完全够用。

通道配置上,CHANNEL_IN_MONO是首选。单声道虽然损失了空间感,但数据量减半、处理简单、兼容性最好。做语音识别、分贝监测这些场景,双声道基本没有任何收益。如果你确实需要立体声,用CHANNEL_IN_STEREO,但要知道不是所有设备都支持双麦克风采集,部分设备上构造AudioRecord时可能直接抛出UnsupportedOperationException,或者更糟糕的——构造成功但录出来的全是静音。

编码格式建议用ENCODING_PCM_16BIT。这是最通用的PCM格式,每个采样占2字节,数据是带符号的short,小端字节序。对初学者来说,16bit的数据处理起来也直观:每个采样值的范围是-32768到32767,算分贝、画波形都方便。ENCODING_PCM_FLOAT在某些低延迟处理场景下更省事,但需要额外判断设备是否支持,尤其做音频算法的时候要注意不同格式下数据范围的含义完全不同。

参数组合确定之后,缓冲区大小的计算才是真正拉开新手和老手差距的地方。AudioRecord提供了一个专门的方法:

int minBufSize = AudioRecord.getMinBufferSize( SAMPLE_RATE_IN_HZ, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);

这个方法返回的是系统建议的最小缓冲区字节数,单位是字节。实际使用时,我习惯在此基础上再放大一些,宁可多给也决不手紧:

int bufferSizeInBytes = Math.max(minBufSize, SAMPLE_RATE_IN_HZ * 2 / 5);

这里SAMPLE_RATE_IN_HZ * 2是一秒钟PCM 16bit单声道的数据量(每秒采样数乘以每采样字节数),除以5就是200毫秒的数据量。为什么要留这200毫秒的余量?因为系统填充数据的速度是固定的,而应用read()的时机受线程调度影响并不完全均匀,缓冲区太小,一旦read稍微慢了一拍,系统新采集的数据没地方放,就会把旧数据覆盖掉,实际听到的音频就会出现间断、卡顿。当然缓冲区也不是越大越好,太大会导致数据延迟变高,某些对实时性敏感的场景就不合适。

还有一个很多人不知道的点:getMinBufferSize()返回-1或者ERROR_BAD_VALUE,说明你传入的参数组合不被系统支持。代码里最好做一层防御性判断。

2.3 用构造器还是老式构造函数

AudioRecord的初始化有两种方式:传统构造函数和Builder构造器。传统构造函数从Android 1.5时代就有了,参数多、顺序固定,写起来容易串位:

AudioRecord audioRecord = new AudioRecord( MediaRecorder.AudioSource.MIC, sampleRateInHz, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes);

这里第一个参数是音频源。除了常见的MediaRecorder.AudioSource.MIC,还有VOICE_RECOGNITIONCAMCORDERUNPROCESSED等。VoiceRecognition在部分设备上会额外做回声消除和降噪,和MIC的原始采集效果有区别,具体选哪个要根据业务场景来:想做最原始的波形分析,就选UNPROCESSED或者MIC;想尽量拿到干净的人声,VOICE_RECOGNITION更合适。

比较推荐的是用Builder模式,因为参数配置更清晰,尤其是音频格式和音频源可以分开设置:

AudioRecord audioRecord = new AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) .setAudioFormat(new AudioFormat.Builder() .setSampleRate(44100) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setChannelMask(AudioFormat.CHANNEL_IN_MONO) .build()) .setBufferSizeInBytes(bufferSizeInBytes) .build();

Builder的优势在于:可以动态构造AudioFormat对象,复用同一套格式配置,后续切换设备时不用重写一堆参数。实测下来,如果参数组合本身有问题,Builder在build()阶段就会暴露出来,不至于等到startRecording()才抛异常。

构造成功之后,建议检查一下getState()是否等于STATE_INITIALIZED。如果等于STATE_UNINITIALIZED,基本可以断定是音频源或参数组合不受支持,这时候再调用startRecording()只会得到一个IllegalStateException。

3. startRecording()启动一瞬间发生了什么

3.1 状态迁移:从STOPPED到RECORDING

AudioRecord内部有两个关键状态,一个是初始化状态(State),一个是录制状态(RecordingState)。初始化状态表示实例是否配置成功,录制状态则表示当前是否处于录音中。

startRecording()的作用,就是把录制状态从RECORDSTATE_STOPPED切换到RECORDSTATE_RECORDING。调用之后,系统便会开始从音频源采集数据,并持续填充到AudioRecord内部的缓冲区中。检查是否真的开始录音,可以调用getRecordingState()

if (audioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) { // 已经开始录音 }

这里有个非常实用的经验:调用startRecording()之后,最好先确认状态,再进入read()循环。因为某些设备上,startRecording()本身不会抛异常,但系统因为底层资源问题并没有真正启动采集,录出来的全是静音数据。提前检查状态,可以帮你省掉很多排查时间。

还有一点要强调:startRecording()不是幂等方法。连续调用两次,第二次在大多数情况下会抛IllegalStateException。所以代码里最好用一个标志位记录当前是否正在录音,避免用户因为手速快、连点录音按钮而触发异常。

3.2 数据从哪来:要等多久才能读到第一块数据

启动startRecording()之后,很多人会迫不及待地立刻调用read(),想马上读到数据。先理解一下采集路径:

麦克风先是经过硬件驱动把模拟信号转成数字信号,再经过音频HAL把数据交给AudioFlinger,AudioFlinger在合适的音频策略下将数据写入AudioRecord的缓冲区。这个过程虽然只有几十毫秒,但绝不是同步的。startRecording()只是告诉系统“我要数据了”,它不等数据准备好就立即返回。因此你调用完startRecording()之后马上read(),可能出现两种结果:一种是read()阻塞在那里,等数据填充到位后返回;另一种是数据很快到了,read()立即返回一段有效的PCM数据。

这对实际代码的影响是什么?就是不能假设数据是“立等可取”的,也不能在startRecording()之后立刻对缓冲区做任何“里面已经有数据”的判断。正确的姿态是:启动录音之后,安心进入read()循环,让read()自己决定什么时候有数据可读。

3.3 为什么录音必须在子线程里跑

AudioRecord的read()方法,默认是阻塞式的。什么意思?就是你给read()传一个缓冲区,它必须等到缓冲区被填满,或者缓冲区里有足够的数据,才会返回。如果一直没数据,它就一直卡着。

这个阻塞行为直接决定了一件事:绝对不能在主线程里调用read()。否则Android系统会判定你的UI线程无响应,几秒钟后直接弹ANR。我在刚接触音频开发时也犯过这个错误——想着startRecording()和read()放一起,代码写起来简单,结果一跑就ANR。

正确做法是单独开一个后台线程,专门负责read()循环。线程内部用一个布尔标志位控制退出,保证录音结束时能够及时释放资源。如果是Kotlin协程项目,也要注意用Dispatchers.IO或者专门的单线程调度器,别把阻塞式read放在主调度器上。

Thread recordingThread = new Thread(() -> { byte[] buffer = new byte[bufferSizeInBytes]; while (isRecording) { int readBytes = audioRecord.read(buffer, 0, buffer.length); if (readBytes > 0) { // 对buffer前readBytes个字节做处理 } } }); recordingThread.start();

这个模型虽然简单,但它就是AudioRecord所有应用场景的地基。实时频谱分析、分贝监测、语音识别,本质上都是在这个read()循环里灌入不同的数据处理器。

4. 录音线程里的read()循环:把数据搬到你的数组里

4.1 read()的几种形态和返回值

read()是一个重载方法,常见的变体有三种:支持byte数组、short数组和ByteBuffer。对于PCM 16bit数据,byte数组直接读的话拿到的是小端字节序,如果你后续要自己解析采样值,最好用short数组或者ByteBuffer配ByteOrder.LITTLE_ENDIAN,省得手动交换字节。

read()返回值的含义也很重要:返回正整数表示实际读取的字节数;返回0表示没有数据;返回负数则代表错误。常见错误码有这么几个:

错误码含义
ERROR_INVALID_OPERATION-3实例状态不对,通常是没有初始化成功或者已经release
ERROR_BAD_VALUE-2参数不合法,比如缓冲区指针为空、长度超过实际容量
ERROR_DEAD_OBJECT-6AudioRecord底层对象已经失效,一般是系统服务异常导致
ERROR-1通用错误

如果你在真机上遇到read()不断返回负值,先别急着怀疑麦克风坏了。最常见的原因是AudioRecord实例已经在某个地方被release()了,但线程仍然在跑,这时候返回的值大概率是-3。排查方向优先看向生命周期管理,其次才是参数问题。

4.2 一个可以跑起来的录音循环示例

下面给一个完整的示例代码,包含采样率选择、缓冲区计算、录音线程启动以及简单的音量计算。这段代码我在多个项目的原型阶段都直接抄过,属于“能跑且不容易翻车”的那一版。

private static final int SAMPLE_RATE_IN_HZ = 44100; private AudioRecord audioRecord; private Thread recordingThread; private volatile boolean isRecording = false; private void startRecording() { int minBufSize = AudioRecord.getMinBufferSize( SAMPLE_RATE_IN_HZ, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); int bufferSizeInBytes = Math.max(minBufSize, SAMPLE_RATE_IN_HZ * 2 / 5); audioRecord = new AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) .setAudioFormat(new AudioFormat.Builder() .setSampleRate(SAMPLE_RATE_IN_HZ) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setChannelMask(AudioFormat.CHANNEL_IN_MONO) .build()) .setBufferSizeInBytes(bufferSizeInBytes) .build(); if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { audioRecord.release(); audioRecord = null; return; } audioRecord.startRecording(); isRecording = true; recordingThread = new Thread(this::recordLoop); recordingThread.start(); } private void recordLoop() { byte[] buffer = new byte[Math.max( audioRecord.getBufferSizeInFrames() * 2, 4096)]; while (isRecording) { int read = audioRecord.read(buffer, 0, buffer.length); if (read > 0) { short[] samples = new short[read / 2]; ByteBuffer.wrap(buffer) .order(ByteOrder.LITTLE_ENDIAN) .asShortBuffer() .get(samples); double db = calculateDb(samples); // 在这里更新UI或者把数据交给处理器 } else if (read < 0) { // 记录错误并考虑退出循环 break; } } } private double calculateDb(short[] samples) { double sum = 0; for (short sample : samples) { sum += sample * sample; } double rms = Math.sqrt(sum / samples.length); return 20 * Math.log10(rms / 32768.0); }

这段代码里有两个我特别想强调的点。

第一,getBufferSizeInFrames()返回的是当前AudioRecord内部缓冲区的帧数,帧的概念和“一帧数据”有关。在单声道PCM 16bit场景下,一帧是一个采样,占2字节。所以getBufferSizeInFrames() * 2能得到缓冲区对应的字节数。你申请的read缓冲区大小最好和内部缓冲区匹配,这样一次read可以取完一整批数据,避免读一半留一半。

第二,分贝计算的公式里用到了dBFS(全幅分贝)的概念,参考值取32768,即16bit能表示的最大幅值。实际算出来的分贝是负值,越接近0代表声音越大,这符合录音行业的通用做法。如果你的需求是显示“60dB”这种绝对声压级,那就需要标定过的麦克风以及更复杂的换算公式,不能直接用这个数字。

4.3 阻塞、非阻塞和回调方式怎么选

read()默认是阻塞的,前面已经说过。但AudioRecord也支持通过setBlocking(false)切换成非阻塞模式。非阻塞模式下,缓冲区里暂时没有数据,read()会立即返回0,不会卡住线程。

非阻塞模式比较适合那种还需要在录音线程里做其他事情的场景,比如同时处理按键事件、更新UI状态值,或者在等待数据的过程中去检查其他资源。但注意,非阻塞模式会让线程变成忙等状态,CPU占用率会明显增加。如果没数据就返回0,你总不能纯空转吧?一般需要配合sleep之类的操作降低功耗。

如果你既不想用阻塞线程,又不想轮询空转,可以试试OnRecordPositionUpdateListener回调。AudioRecord允许你设置一个监听器,当录制位置达到一定帧数时回调onMarkerReached()或者周期性回调onPeriodicNotification()。这种方式适合需要按帧边界处理数据的场景,比如编码器恰好要求每次喂入1024个采样。但这个方案的缺点是,回调同样发生在底层线程,如果回调里做耗时操作,还是得自己再转一次线程。

我的个人结论是:绝大多数场景老老实实用阻塞式read()最稳。它逻辑简单、线程模型清晰、不容易出幺蛾子。回调方式适合对“精确帧边界”有要求的音频算法场景,非阻塞模式基本只有在特殊架构下才会用到。

5. Android版本差异:从运行时权限到前台服务类型

5.1 Android 6以来的权限变化

聊到录音,版本差异是绕不开的话题。AudioRecord这个API本身从诞生到现在变化不大,startRecording()的签名也几乎没有变过,但Android系统对录音权限的收紧,让开发者的工作量逐年增加。

Android 6.0引入了运行时权限,录音权限从安装时授权变成了运行时弹窗申请。Android 11引入了“仅本次允许”的一次性权限,用户授权之后,应用一旦被系统回收,下次使用还得重新申请。Android 12则加入了麦克风指示器和系统级麦克风开关:状态栏右上角出现绿色小圆点,说明当前有应用正在使用麦克风;用户在快捷设置里一键关闭麦克风权限,所有应用的RECORD_AUDIO都会变成拒绝状态。

这些变化看起来都是系统层面的,实际上对AudioRecord的影响非常大。最直接的一点是,你必须时刻做好“用户随时可能收回麦克风权限”的心理准备。录音线程跑着跑着,用户从下拉栏把麦克风开关关掉,你的read()可能立刻返回错误,或者更糟糕——不报错但一直拿到静音数据。

5.2 Android 14/15/16:后台录音的限制越来越严

再把时间线拉到最近几个版本。Android 14是一个分水岭,因为从它开始,后台录音必须搭配前台服务并且声明microphone类型。也就是说,如果你的App点击录音按钮后,用户按Home键把App切到后台,你仍然想继续录音,必须在进入后台时启动前台服务,并持续显示通知。否则,系统就会中断你的录音。

Android 15延续了这个趋势,对“什么场景能用麦克风”做了进一步细分,强行在后台启动录音的行为会被系统直接拦截。

到了Android 16,AudioRecord的API本身依旧稳定,startRecording()的用法和过去没有本质区别。但整个系统对麦克风使用透明度的要求更高了,核心表现是:麦克风指示器不再只是一个小绿点,系统会在状态栏、控制中心、隐私仪表盘等多个位置展示当前哪个应用正在使用麦克风。用户比过去更知道自己手机上有多少App在暗地里录音,这种情况下,开发者在非必要场景悄悄调用startRecording(),很容易引发隐私合规问题。

另外,Android 16继续推动低延迟音频能力,越来越多的设备原生支持48kHz甚至更高采样率的低延迟通路。对开发者的直接启示是,在保证兼容性的前提下,可以把采样率从44100Hz提升到48000Hz,实测延迟和音质都会有一些提升。前提是你得先在目标设备上做一轮getMinBufferSize()AudioManager.getProperty()的探测,确认设备确实支持。

5.3 兼容性验证清单

如果你的App要兼容到Android 10之前的设备,同时又要适配最新的Android 16,建议按下面这份清单做一轮排查:

  • Manifest里是否声明了RECORD_AUDIO权限,以及targetSdkVersion升至34以上时是否补充了FOREGROUND_SERVICE_MICROPHONE权限。
  • 后台录音逻辑是否全部走前台服务,并且startForeground()传入了正确的服务类型。
  • 录音开始时是否在UI层正确回调了权限请求,用户拒绝后是否有引导去设置页的逻辑。
  • 采样率是否优先使用44100Hz,48000Hz只作为增强选项。
  • 缓冲区是否通过getMinBufferSize()动态计算,避免硬编码。
  • 录音过程中是否监听AudioManager.getActiveRecordingConfigurations(),以便在系统策略变化时做出响应。

这份清单看着简单,但每一项都可能在实际设备上翻车。尤其是不同厂商的定制系统,对麦克风权限、前台服务类型的处理方式会有很大差异,同一套代码在Pixel上没问题,换到某些国产ROM上可能就会遇到权限申请失败、前台服务启动异常之类的问题。

6. 实战避坑:startRecording()常见的五个“不工作”场景

6.1 权限明明给了,start还是抛SecurityException

这是咨询频率最高的一个问题。很多人的代码里已经动态请求了RECORD_AUDIO权限,用户也点允许了,但调用startRecording()时仍然抛SecurityException。

排查方向分两步。第一步,检查Manifest里是否有<uses-permission android:name="android.permission.RECORD_AUDIO" />。很多使用第三方框架的项目,Manifest在构建时被合并,权限声明可能被框架的配置覆盖或删除。第二步,检查系统级麦克风开关。Android 12之后,用户在快捷设置里关掉麦克风,App会在不知情的情况下收到拒绝结果。

提示:权限问题最有效的定位手段是adb shell dumpsys package <你的包名> | grep -A 20 "runtime permissions",先看状态,再怀疑代码。

6.2 模拟器上永远录不出声音

模拟器没有麦克风,这是很多新人的第一反应,但实际上现在大部分模拟器是可以把电脑的麦克风映射到虚拟设备里的。问题在于映射的时机和路径:模拟器需要手动开启麦克风权限,而且默认可能并没有把宿主机的麦克风设备挂载进来。

这就导致一个尴尬的现象:同样的代码在模拟器上运行,startRecording()不报错,read()也不报错,但读出来的一直是静音数据。你排查了半天参数和权限,最后发现是模拟器本身的问题。

我的建议是:音频开发从一开始就坚定地使用真机调试。尤其涉及录音参数配置、缓冲区性能验证、后台录音这些功能,模拟器的行为完全不能作为参考。真机也尽量多准备几个不同芯片平台、不同系统版本的设备,国产ROM和原生系统之间的差异可能会让你大开眼界。

6.3 采样率和buffer组合不支持

startRecording()偶尔会抛IllegalStateException,或者更隐蔽的——构造成功、start成功,但read之后数据是乱的。这种情况十有八九出在参数组合上。

比如某些中低端设备,标称支持48000Hz采样率,但getMinBufferSize(48000, CHANNEL_IN_MONO, ENCODING_PCM_16BIT)返回的缓冲区大小却小得离谱,或者直接返回-1。又比如有些设备在录制时,如果音频源选CAMCORDER(相机麦克风),此时摄像头没开启,麦克风采集的方向和设备冲突,录出来的声音就很奇怪。

处理这类问题,务必要做两层防御。第一层是构造参数前先探测:AudioManager.getProperty(AudioManager.PROPERTY_SUPPORT_AUDIO_SOURCE_UNPROCESSED)可以判断是否支持UNPROCESSED源;AudioRecord.getMinBufferSize()可以验证参数组合。第二层是启动时捕获异常,做降级:如果48000Hz启动失败,就回退到44100Hz再试一次。这种降级逻辑虽然不优雅,但在兼容性测试中非常实用。

6.4 录音到一半没声音:并发与音频策略冲突

Android 10之后,系统是支持多个应用同时录制音频的,但这不意味着所有设备都能完美处理并发录音。在某些设备上,当另一个应用开始录音时,你的应用会收到系统底层音频策略变化,read()返回的数据可能突然变静音,甚至直接丢失。

出现这种问题,第一步是监听录音配置变化:

AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); audioManager.registerAudioRecordingCallback(new AudioManager.AudioRecordingCallback() { @Override public void onRecordingConfigChanged(List<AudioRecordingConfiguration> configs) { boolean recordingActive = false; for (AudioRecordingConfiguration config : configs) { if (config.getClientAudioSource() != MediaRecorder.AudioSource.VOICE_RECOGNITION) { recordingActive = true; break; } } // 根据recordingActive决定是否暂停自己的录音 } }, null);

这里注意,回调会返回当前所有活跃的录音会话,包括其他应用的。如果检测到非本应用的活跃录音,或者检测到设备的录音策略发生了切换,建议主动暂停自己的录音,等系统稳定后再恢复。硬扛着录,只会得到一段听不清楚的噪音或者静音。

6.5 为什么stop()之前还得多读一次

录音结束时很多人直接调用audioRecord.stop()然后release(),表面看没什么问题,但有时候录出来的音频尾部会少一截数据,或者出现一个短暂的空洞。这背后的原因很简单:AudioRecord的数据是持续往缓冲区里填的,你最后一次read()之后,缓冲区里可能还残留着几十到几百毫秒的PCM数据。stop()一调,缓冲区里那些还没被读走的数据直接作废了。

所以更稳妥的停止流程是:先在read()循环里通过标志位让循环退出,等read()正常返回之后,再把缓冲区里剩余的数据尽可能排空,最后再调用stop()。具体做法可以这样:在循环退出前,连续调用read()直到返回0或者负值,把残留数据取出来,再执行stop()。

我把这个细节放到最后讲,是因为它非常容易被忽略,而且一旦录音时长超过几十分钟,尾巴上缺几百毫秒数据几乎没人听得出来。但在做语音评测、需要对音频做完整性校验的产品里,这一小段丢失的数据就可能造成评分差异或者对齐失败。如果你的录音结果需要做后续精确处理,务必把这个“排空缓冲区”的步骤加进去。

实际上在我做过的多个音视频项目里,startRecording()本身极少成为瓶颈,真正让人熬夜排查的,往往是它周围的那些“配套问题”——权限被系统悄悄回收、录音线程生命周期混乱、ROM厂商在麦克风策略上做的私有修改。Android 16这一代系统,AudioRecord的核心理念没有变,但系统对隐私、后台行为、前台服务的约束比过去严苛得多。希望这篇文章能帮你建立一套完整的录音模块设计思路,下次打开麦克风的时候,不再只盯着startRecording()这一个方法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 12:56:37

Taro多端开发中input光标跳转问题全解析:从受控组件到五种解决方案

做 Taro 多端开发的朋友&#xff0c;大概率都踩过这个坑&#xff1a;input 框里有一段文字&#xff0c;你本想点中间改个字&#xff0c;结果光标“啪”一下跳到末尾&#xff0c;后面敲的整段内容全跑到了末尾。一开始我以为是业务代码问题&#xff0c;调了半天发现跟业务逻辑毫…

作者头像 李华
网站建设 2026/9/9 12:55:28

SpringBoot2+Vue3全栈开发毕业生实习与就业管理系统

接手这种“毕业生实习与就业管理系统”的项目&#xff0c;第一反应是技术栈怎么选。很多同学私信我&#xff0c;说学校布置的课题或者公司接到类似外包&#xff0c;上来就不知道该用Spring Boot还是Spring Cloud&#xff0c;前端是选Vue2还是Vue3&#xff0c;数据库用MySQL5.7还…

作者头像 李华
网站建设 2026/9/9 12:54:59

技术文档阅读方法论:从结构认知到知识沉淀的高效路径

1. 文档阅读这件事&#xff0c;为什么值得单独拿出来说一天做到第31天&#xff0c;很多技能点已经形成了肌肉记忆&#xff0c;但“文档阅读”这个环节恰恰是最容易被低估、又最能拉开长期差距的能力。你回想一下自己最近的开发经历&#xff1a;是不是经常遇到一个开源库、一套内…

作者头像 李华
网站建设 2026/9/9 12:54:00

词袋模型(Bag of Words):文本数值化入门与工程实践

词袋模型&#xff08;Bag of Words&#xff09;—— 文本数值化的第一步刚接触自然语言处理的朋友&#xff0c;第一个绕不开的概念大概率就是词袋模型&#xff08;Bag of Words&#xff0c;简称 BoW&#xff09;。不管你是要做垃圾短信识别、舆情分析&#xff0c;还是给搜索系统…

作者头像 李华
网站建设 2026/9/9 12:53:14

C语言指针完全指南:从底层原理到内存调试实战

很多初学者对指针的概念是这样的&#xff1a;背下"指针就是地址"这一句&#xff0c;然后遇到段错误就原地懵掉。我见过不少人面试时能把"指针保存的是变量的地址"倒背如流&#xff0c;但一写链表插入函数&#xff0c;指针传参问题就全暴露了。这篇文章我会…

作者头像 李华