简介:这份资源是「会说话的Tom猫」Android 应用源码,面向具备一定 Android 基础、希望深入理解音频处理与游戏交互开发的开发者。源码围绕录音、变声与动画交互展开,涵盖 MediaRecorder 录音、AudioTrack 播放、SoundTouch 音效处理、JNI 调用以及 UI 事件监听等核心模块,是学习音频 API 与多媒体资源管理的实践案例。压缩包共 16 个文件,全部为 java 源码,整体约 18KB,包含录音线程、音频处理、文件工具与主界面逻辑等类,结构紧凑便于逐类研读。目前已有 1258 人学习下载。读者可从中掌握录音权限申请、原始音频变声处理、音效播放与界面交互的完整链路,理解多平台兼容与内存优化思路,适合作为 Android 音频开发与休闲游戏类项目的参考范例。
1. 会说话的Tom猫 Android 源码:一个被低估的音频交互练手项目
很多人第一次看到「会说话的Tom猫 Android 源码」这个标题,脑子里蹦出来的画面是那只学人说话的卡通猫,觉得不过是个玩具。但如果你真把这份源码拉下来跑一遍,会发现它其实是一个被严重低估的音频交互入门项目——它把录音采集、变声处理、动画状态机、触摸事件分发这几件事串成了一条完整的链路,而且每一环都足够简单,简单到你可以在一个周末里把它拆干净再重新拼起来。
这个标题真正指向的需求是:我想找一个体量不大、但覆盖 Android 音频输入输出全流程的开源项目来练手,最好还能有点交互反馈,别只是打印日志。会说话的Tom猫恰好满足——它不需要网络、不需要后端、不需要复杂权限,一台真机就能跑通。适合谁?适合刚学完 Android 四大组件、想找一个能摸到 MediaRecorder 和 AudioTrack 的项目练手的人;也适合想理解「变声」这件事在端上到底怎么实现的开发者。下面我按自己复现时的顺序,把这条链路拆开讲。
2. 先搞清楚这只猫在端上到底做了什么:音频链路与模块拆解
2.1 从触摸到出声:一条完整的音频数据流
把源码跑起来之后,我做的第一件事不是看代码,而是拿真机点一下猫的肚子,然后盯着 Logcat 看它到底按什么顺序调用了哪些 API。结果很清晰,整条链路是这样的:用户触摸屏幕 → 触发录音开始 → MediaRecorder 采集 PCM 数据写入临时文件 → 录音结束 → 读取 PCM 文件 → 变声处理(改变采样率或做简单滤波)→ AudioTrack 播放处理后的数据 → 同时触发动画状态切换。
这条链路里最容易被忽略的是「录音结束到播放开始」之间的那段处理。很多新手以为变声是实时做的,其实在这个项目里,常见做法是先录完再处理,因为实时变声对端上 CPU 和延迟的要求高得多。源码里用的是「录完再变」的方案,这也是我建议你第一次复现时采用的方案——先把链路跑通,再考虑实时性。
这里有个关键点:Android 的 MediaRecorder 默认输出格式是 AAC 或 AMR,但变声处理通常需要 PCM 裸数据。所以源码里一般会先把录音格式设成 PCM 或者录完后用 MediaCodec 解码成 PCM。我一开始没注意这点,直接拿 AAC 文件去改采样率,结果播放出来全是杂音,这就是典型的「格式没对齐」翻车。
2.2 变声到底怎么变:三种常见实现路径的选型对比
变声这件事,听起来玄学,拆开看其实就三条路。我把它们列出来,你在复现时可以根据自己的目标选:
| 方案 | 原理 | 实现难度 | 音质 | 适合场景 |
|---|---|---|---|---|
| 改采样率 | 录制时用高采样率,播放时用低采样率播放 | 低 | 一般,会变调 | 快速出效果 |
| 时域拼接 | 对 PCM 做简单重叠相加,改变音高 | 中 | 较好 | 想理解变声原理 |
| 引入第三方库 | 调用现成的音频处理库做变速变调 | 低 | 好 | 只想快速集成 |
源码里最常见的是第一种——改采样率。比如录音用 44100Hz,播放时用 22050Hz 播放,声音就会变低沉;反过来用 88200Hz 播放就会变尖。这个方案的好处是代码量极少,几行就能出效果;坏处是它同时改变了音高和时长,听起来会有点「鬼畜」。
我一般会建议第一次复现时先用改采样率的方案把整条链路跑通,确认录音、播放、动画都没问题之后,再换成时域拼接的方案去理解变声的本质。如果你只是想在自己的 App 里加个变声功能,那直接引入成熟库更省事,没必要自己造轮子。
2.3 动画状态机:为什么猫的嘴型能对上声音
很多人以为猫的嘴型动画是跟音频波形实时绑定的,其实不是。源码里的做法通常是:录音开始时切换到「听」的动画,录音结束时切换到「说」的动画,播放期间循环播放嘴型动画,播放结束回到「待机」动画。这是一个典型的状态机,状态切换的触发点就是录音和播放的开始/结束事件。
这种做法的好处是实现简单,不需要做音频特征提取;坏处是嘴型和实际声音对不上,只是「看起来在动」。如果你想让嘴型更贴合声音,那就需要做音量检测,根据音量大小切换不同的嘴型帧。这部分源码里一般没有,属于进阶改造点。
我在复现时踩过一个坑:动画切换和音频播放不在同一个线程里,导致有时候声音已经播完了,动画还停在「说」的状态。解决办法是把动画切换也放到主线程,并且用播放完成的回调来触发状态回归,而不是靠延时。
3. 动手复现:从录音到变声播放的最小可运行代码
3.1 录音模块:MediaRecorder 的参数怎么设才不出错
先看录音部分。源码里一般会封装一个录音工具类,核心就是配置 MediaRecorder 并启动。下面是我整理后的最小可用版本:
// 录音工具类核心方法 public void startRecord(String outputPath) { // 1. 创建实例 recorder = new MediaRecorder(); // 2. 设置音频源,MIC 是麦克风 recorder.setAudioSource(MediaRecorder.AudioSource.MIC); // 3. 输出格式设为 PCM 裸数据,方便后续变声处理 recorder.setOutputFormat(MediaRecorder.OutputFormat.PCM); // 4. 编码器选 PCM,注意不是 AAC recorder.setAudioEncoder(MediaRecorder.AudioEncoder.PCM); // 5. 采样率 44100Hz,这是变声的基础参数 recorder.setAudioSamplingRate(44100); // 6. 单声道就够了,省资源 recorder.setAudioChannels(1); // 7. 输出文件路径 recorder.setOutputFile(outputPath); try { recorder.prepare(); recorder.start(); } catch (IOException e) { // 这里一定要打日志,不然出错了你都不知道 Log.e("Record", "prepare failed", e); } }这段代码里最关键的参数是setOutputFormat和setAudioEncoder。很多人在这里翻车,是因为把输出格式设成了 AAC,但编码器又设成了 PCM,两者不匹配,prepare()直接抛异常。记住一个原则:输出格式和编码器要配套,PCM 配 PCM,AAC 配 AAC。
另外setAudioSamplingRate这个参数决定了你后续变声的空间。设成 44100 是通用做法,如果你想要更明显的变声效果,可以设成 48000,但要注意部分老设备不支持太高的采样率。
3.2 变声处理:改采样率实现「大叔音」和「娃娃音」
录完音之后,变声处理的核心就是改采样率。下面这段代码演示了如何把 44100Hz 的 PCM 数据按不同倍率重采样:
// 简单的重采样方法,ratio 大于 1 变尖,小于 1 变低沉 public short[] resample(short[] input, float ratio) { // 计算输出长度 int outputLength = (int) (input.length / ratio); short[] output = new short[outputLength]; for (int i = 0; i < outputLength; i++) { // 按比例映射回原数组的索引 int srcIndex = (int) (i * ratio); if (srcIndex < input.length) { output[i] = input[srcIndex]; } } return output; }这段代码的逻辑很直白:ratio 设为 0.8 时,输出比输入短,播放时相当于用更快的速度播放,声音变尖;ratio 设为 1.2 时,输出变长,播放变慢,声音变低沉。参数 ratio 就是你的变声旋钮,一般控制在 0.7 到 1.5 之间,超出这个范围音质会明显劣化。
注意这里用的是最近邻采样,音质一般但计算量小。如果你想要更好的音质,可以把output[i] = input[srcIndex]换成线性插值,代码会多几行,但听起来会顺耳很多。
3.3 播放模块:AudioTrack 的正确打开方式
处理完的 PCM 数据要用 AudioTrack 播放。这里有个容易忽略的点:AudioTrack 的构造参数必须和你的 PCM 数据格式完全一致,否则要么没声音,要么全是噪音。
// 播放 PCM 数据 public void playPcm(short[] data) { // 1. 计算缓冲区大小 int bufferSize = data.length * 2; // short 占 2 字节 // 2. 创建 AudioTrack,参数要和录音时对齐 AudioTrack track = new AudioTrack( AudioManager.STREAM_MUSIC, 44100, // 采样率,和录音一致 AudioFormat.CHANNEL_OUT_MONO, // 单声道 AudioFormat.ENCODING_PCM_16BIT, // 16 位 PCM bufferSize, AudioTrack.MODE_STATIC // 静态模式,一次性写入 ); // 3. 写入数据并播放 track.write(data, 0, data.length); track.play(); // 4. 播放完成后释放资源,不然会泄漏 track.setPlaybackPositionUpdateListener(new AudioTrack.OnPlaybackPositionUpdateListener() { @Override public void onMarkerReached(AudioTrack track) { track.release(); } @Override public void onPeriodicNotification(AudioTrack track) {} }); }参数说明:采样率必须和录音时一致,否则播放速度会不对;MODE_STATIC适合短音频,一次性写入后播放,如果是长音频建议用MODE_STREAM边写边播。释放资源这一步很多人会忘,结果跑几次之后 App 就崩了,报AudioTrack初始化失败,其实就是没释放导致的。
4. 避坑指南:复现这个项目时最容易翻车的五个地方
4.1 权限申请了但还是录不了音
现象:已经在 Manifest 里声明了RECORD_AUDIO权限,代码里也调用了requestPermissions,但MediaRecorder.prepare()还是抛异常。
原因:Android 6.0 之后,危险权限需要运行时申请,而且用户拒绝一次之后,再次申请的系统弹窗可能不再显示。另外,如果用户勾选了「不再询问」,后续申请会直接返回拒绝。
解决:在申请权限之前先检查shouldShowRequestPermissionRationale,如果返回 false 且权限未授予,说明用户可能勾了不再询问,这时候要引导用户去设置页手动开启。代码里加一个判断:
if (!shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) && !hasPermission) { // 引导去设置页 Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); intent.setData(Uri.parse("package:" + getPackageName())); startActivity(intent); }4.2 录出来的文件是空的
现象:录音流程走完了,文件也创建了,但大小是 0 字节,播放没声音。
原因:最常见的是stop()调用时机不对。如果在start()之后立刻调用stop(),或者录音过程中出现异常导致stop()没执行,文件就不会被正确写入。另外,如果输出格式设成了 PCM,有些设备在stop()时不会自动刷新缓冲区。
解决:确保stop()在录音真正结束后调用,并且用 try-catch 包住,在 finally 里释放资源。如果是 PCM 格式,建议在stop()之后手动检查文件大小,如果为 0 就提示用户重录。
4.3 变声之后声音断断续续
现象:变声处理后的音频播放时一顿一顿的,像卡带。
原因:重采样时索引计算有误,导致部分数据被跳过或重复。特别是当 ratio 不是整数时,(int)(i * ratio)会丢失精度,造成数据不连续。
解决:改用线性插值,或者把 ratio 限制在容易处理的范围内。另一个办法是先把 PCM 数据转成 float 数组做处理,最后再转回 short,这样精度损失会小很多。
4.4 播放完动画不回到待机状态
现象:声音播完了,猫的嘴还在动。
原因:动画状态切换依赖播放完成的回调,但 AudioTrack 的setPlaybackPositionUpdateListener需要设置 marker 位置才会触发onMarkerReached。如果没设 marker,回调永远不会来。
解决:在write之后调用track.setNotificationMarkerPosition(data.length / 2),这样播放到末尾时会触发回调,在回调里切换动画状态。
4.5 在模拟器上跑一切正常,真机上就崩
现象:模拟器上录音、变声、播放都没问题,换到真机就闪退。
原因:模拟器的音频输入是虚拟的,采样率和格式支持范围比真机宽。真机上部分设备不支持 44100Hz 单声道 PCM 录制,或者不支持MODE_STATIC的大缓冲区。
解决:在代码里加设备兼容性判断,先用AudioTrack.getMinBufferSize获取设备支持的最小缓冲区,再根据返回值调整你的 bufferSize。录音采样率也建议做成可配置的,遇到不支持的设备就降级到 22050Hz。
5. 进阶改造:让这只猫的交互更跟手
5.1 用音量驱动嘴型:从「假动」到「真动」
前面说过,源码里的嘴型动画是循环播放的,和实际声音没关系。如果你想让猫的嘴型跟着音量走,需要做两件事:一是实时计算 PCM 数据的音量,二是根据音量大小切换不同的嘴型帧。
音量计算很简单,对一小段 PCM 数据求均方根就行:
// 计算 PCM 数据的音量,返回 0 到 1 之间的值 public float calculateVolume(short[] data, int offset, int length) { double sum = 0; for (int i = offset; i < offset + length && i < data.length; i++) { sum += data[i] * data[i]; } double rms = Math.sqrt(sum / length); // 归一化到 0 到 1,32768 是 short 的最大值 return (float) (rms / 32768.0); }拿到音量值之后,你可以设几个阈值:音量小于 0.1 显示闭嘴帧,0.1 到 0.3 显示半张嘴,大于 0.3 显示张大嘴。这样猫的嘴型就会跟着声音大小变化,比循环播放自然得多。
参数方面,length建议取 1024 或 2048 个采样点,太小了音量波动大,太大了嘴型反应迟钝。这个值需要根据你的播放帧率来调,一般 30fps 的话取 1470 左右比较合适。
5.2 录音和播放的线程调度:别在主线程里干重活
录音和播放都是耗时操作,放在主线程里会卡 UI。源码里一般会用 HandlerThread 或者 ExecutorService 来处理。我自己的习惯是开一个单线程的 ExecutorService,录音、变声、播放都丢进去顺序执行,这样既不会卡 UI,也不会出现录音和播放同时操作音频设备导致的冲突。
// 单线程执行器,保证音频操作顺序执行 private ExecutorService audioExecutor = Executors.newSingleThreadExecutor(); // 录音任务 audioExecutor.execute(() -> { startRecord(outputPath); // 录音结束后自动触发变声和播放 processAndPlay(outputPath); });注意这个执行器要在 Activity 销毁时关闭,不然会内存泄漏。在onDestroy里调用audioExecutor.shutdownNow()就行。
5.3 验证变声效果的一个小技巧
怎么判断你的变声处理是对的?我的习惯是拿一段自己说的固定话术,比如「今天天气不错」,分别用 ratio 0.8、1.0、1.2 各录一遍,然后对比听。ratio 1.0 应该和原声完全一样,如果不一样,说明你的重采样代码有 bug。ratio 0.8 应该明显变尖,1.2 应该明显变低沉。如果听不出来区别,检查一下是不是播放时采样率没跟着改。
另外,你可以把处理前后的 PCM 数据都存成 wav 文件,用音频软件打开看波形。如果波形形状基本一致只是疏密不同,说明重采样是对的;如果波形完全乱了,那就是索引计算错了。
这个项目我前后复现过三次,第一次卡在权限,第二次卡在格式不匹配,第三次才把整条链路跑顺。最大的教训是:音频开发里,格式和参数的对齐比代码逻辑本身更重要,一个采样率写错,后面全白搭。希望帮到你。
本文还有配套的精品资源,点击获取