做Android音频开发绕不开的一个基础组件就是AudioTrack。刚接触时我也一样疑惑明明MediaPlayer能直接播放mp3为什么还要用AudioTrack去喂裸的PCM数据后来做实时语音、歌词同步、乐器类App时才明白MediaPlayer再方便也替代不了它的位置。AudioTrack是Android提供的一个较底层的音频播放接口它不负责解码只负责把PCM数据送到音频设备。换句话说只要有PCM数据你就能用它把声音放出来延迟、节奏、数据量全都可以自己控制。这篇东西适合三类人一是刚学Android音视频开发、想搞懂音频链路的新手二是项目里要低延迟播放或自定义音频处理、却不知道从哪切入的开发者三是被“为什么放出来没声音”或者“声音又卡又破”这类问题折磨过的人。把一个AudioTrack用熟后面接触音频采集、混音、变声、音效处理都会顺畅很多因为音频世界的入口就是一段段裸数据。1. 整体思路拆解为什么偏偏是AudioTrack1.1 它跟MediaPlayer的本质区别先把两个东西摆清楚。MediaPlayer是高层封装内部有解码器、有状态机、有音视频同步逻辑你给它一个文件路径或者URL它自己搞定一切。但它的数据流是“文件 → 解码 → 播放”开发者碰不到中间那一段真正的音频数据。AudioTrack则完全是另一套逻辑它只接受PCM裸数据——也就是解码之后、采样之后的数字音频信号。它不认mp3、aac、flac这些压缩格式你如果拿一个mp3文件的字节流直接塞给它只会听到机器一样的噪声。PCM数据本质上是把连续的声音波形按固定时间间隔采样、量化得到的数值序列没有压缩、没有编码头、没有附加的元信息。它就像一幅没有压缩的BMP位图简单、占空间、但最容易处理和操作。既然是裸数据开发者就获得了极大的自由度数据是拼接还是切割、何时写入、写多少、要不要混音、要不要延迟全部都能手动控制。这也是它在AudioRecord录音回放、卡拉OK实时伴奏、耳返监听、低延迟音效处理等场景中被频繁使用的原因。1.2 什么场景必须用它什么场景别选它我的经验是先判断自己的需求再决定要不要选AudioTrack别一上来就套模版。适合用AudioTrack的场景播放PCM文件或者从网络/本地解码后得到的PCM流尤其是边播放边要处理音频数据的场景。需要低延迟播放比如乐器演奏类App手指拨弦到出声之间的延迟必须控制在几十毫秒内MediaPlayer做不到这种精细度。实现实时混音。比如一边播背景音乐一边播人声需要把多路PCM数据混合后再统一输出。做可视化和音频分析。因为数据是自己的可以拿它做波形绘制、频谱分析、变调变速甚至直接喂给第三方算法库。不适合用AudioTrack的场景只是播放本地音频文件、在线音频URL没有特殊处理需求直接用MediaPlayer或者ExoPlayer更省事解码、缓冲、错误处理都帮你打理好了。要播放mp3、aac、flac这类压缩音频但没有解码能力又不想手动引入解码器那AudioTrack也接不了得先解码成PCM才能喂给它。一句话归纳需要接触和处理原始音频数据时选AudioTrack只需要“放个声音出来”时选现成的播放器组件。这个判断会大大影响后面整个技术路线。1.3 播放PCM的完整链路从数据源到扬声器中间要经过这么几步读取PCM数据可以来自文件、内存、网络流或者实时录音。按AudioTrack要求的格式做转换比如字节序、声道数、位深。创建一个配置正确的AudioTrack实例设置采样率、声道配置、音频格式、缓冲区大小、播放模式。调用播放方法把AudioTrack切到播放状态。循环写入PCM数据AudioTrack内部会把这些数据交给AudioFlinger最终由底层音频设备播放出来。播放结束释放资源。这里面最容易出问题的就是第2步和第3步。数据格式和AudioTrack参数一旦不匹配轻则音调不对重则直接没声音或噪音轰鸣。后面我会把每一步的细节讲透。2. 核心细节解析这些参数才是成败关键2.1 先搞懂PCM的四个基本属性PCM数据有几个关键参数所有播放配置都围绕它们展开。第一个是采样率Sample Rate。它表示每秒钟对声音采样的次数单位Hz。常见的有8000Hz、16000Hz、22050Hz、44100Hz、48000Hz。CD音质是44100安卓设备上最通用的是44100和48000。如果数据录制时的采样率和播放设置的采样率不一致声音会出现明显的音调偏移就像录音带被快放了一样。第二个是位深Bit Depth。它表示每个采样点用多少位来存储常见的是16位即一个采样点占2个字节。16位意味着振幅被划分成65536个等级动态范围大约96dB。还见过8位和24位、32位的PCM但移动端基本以16位为主。AudioTrack原生支持的重点也是16位。第三个是声道数Channel。单声道一个采样点只有一条数据双声道立体声每个采样点有左右两条数据。双声道在存储时通常采用交错格式也就是L、R、L、R交替排列而不是先存完所有左声道再存右声道。初始化和数据写入都要按这个规则来。第四个是采样位数对应的字节序。正是因为这个最容易踩坑所以我单独拿出来强调。Android设备大多数是小端字节序little-endianAudioTrack也默认按小端字节序解析16位PCM数据。假如从某个设备上拿到一段大端字节序的数据直接播放会出现类似抖动、破音、失真的问题。判断方法也很简单如果数据本身来自WAV文件WAV头之后的数据一般就是小端来自某些嵌入式设备或网络流时就要特别小心必要时做字节交换。2.2 AudioTrack的初始化参数逐个说创建AudioTrack的方式有两种传统构造函数和AudioTrack.Builder。虽然写法不同底层参数含义一致我建议新项目直接用Builder代码更清晰。以Java为例传统构造函数的参数一眼就能看到所有细节int sampleRateInHz 44100; int channelConfig AudioFormat.CHANNEL_IN_STEREO; int audioFormat AudioFormat.ENCODING_PCM_16BIT; int bufferSizeInBytes AudioTrack.getMinBufferSize( sampleRateInHz, channelConfig, audioFormat); int mode AudioTrack.MODE_STREAM; AudioTrack audioTrack new AudioTrack( AudioManager.STREAM_MUSIC, sampleRateInHz, channelConfig, audioFormat, bufferSizeInBytes, mode);逐个解释streamType第一个参数代表音频流类型。AudioManager.STREAM_MUSIC是最常用的受媒体音量控制适合播放音乐、游戏音效STREAM_VOICE_CALL用于通话场景走通话音量STREAM_SYSTEM走系统音量。不同类型之间可能互相影响音量这点在调试时很关键。如果你播放的声音受媒体音量控制但用户把媒体音量调低了声音就会很小甚至听不到。sampleRateInHz采样率。尽量使用44100或48000这两个是设备普遍支持的。如果使用不常见的采样率部分设备上会有重采样甚至不支持的风险。channelConfig声道配置。AudioFormat.CHANNEL_IN_MONO和CHANNEL_IN_STEREO用得多。这个常量名字里带IN指输入到AudioTrack的声道布局。立体声数据要填STEREO否则只用一个声道会导致一边没声音或声音错位。audioFormat音频格式。ENCODING_PCM_16BIT最通用ENCODING_PCM_8BIT虽然能减少内存占用但音质下限明显。还有ENCODING_PCM_FLOAT可用于高精度处理但设备兼容性各不相同常见场景用16位就够了。bufferSizeInBytes缓冲区大小。这个值不能太小否则播放容易卡顿。官方提供了AudioTrack.getMinBufferSize()方法获取最小建议值实际使用时可以在这个基础上乘2或者乘3给自己留点余量。后面我专门讲怎么算。mode播放模式。MODE_STATIC和MODE_STREAM差别我放在下一节讲这是决定整个播放架构的分岔路。2.3 静态模式与流式模式怎么选MODE_STATIC是一次性把所有音频数据交给AudioTrack它内部维护一个大缓冲区数据加载完后调用play()就会持续播放。优点是非常简单没有线程同步问题适合播放短音效、提示音、按键反馈这种数据量小的场景。缺点是缓冲区长度有限一次性要加载全部数据动不动就内存吃紧不适合长音频。MODE_STREAM是边写边播调用play()之后应用不断调用write()往里塞数据。AudioTrack内部缓冲区相当于一个环形池数据被底层设备消费掉空了就接着等新的数据进来。这种模式适合播放长音频、实时音频流也是做低延迟播放的主流选择。有个容易犯的误区用MODE_STREAM时如果只调用play()而不写数据声音是不会出来的AudioTrack会一直等待数据写入。所以你必须在play()之前或之后保证有一个写入线程或回调持续供给数据。总结一句话数据短、一次性加载无所谓就选STATIC数据长、实时生成或者要连续播放就选STREAM。我后面展示的完整代码就以STREAM为主因为实际开发里大概率用的是它。2.4 缓冲区大小怎么算才不卡缓冲区大小是AudioTrack最影响体验的参数之一。缓冲区太小底层消费完数据后写入线程还没来得及补充就会产生断续、卡顿。缓冲区太大数据从写入到播放的延迟就增加实时场景下会感觉“慢半拍”。getMinBufferSize()是官方给出的下限。在44100Hz、双声道、16位的前提下它通常会在几百KB到几千字节之间浮动取决于硬件参数。为了稳定我常用的做法是取这个最小值再根据场景往上调整int minBufferSize AudioTrack.getMinBufferSize(44100, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT); // 流式播放建议不低于两倍最小缓冲区 int targetBufferSize minBufferSize * 2;要注意的是targetBufferSize不是想设多少就设多少。AudioTrack内部可能维护自己的缓冲策略你给一个很大的值实际生效的上限仍受系统约束。但大部分情况下最小值的2倍到3倍是一个安全区间。如果做实时耳机监听要尽量往最小值靠拢如果播本地文件、对延迟不敏感就可以多给一些缓冲防卡顿。3. 实操过程把PCM文件真正放出来3.1 准备一段可靠测试数据写代码之前先准备一个PCM文件。网上很多教程会用ffmpeg从mp3转PCM这里给一条我在用的命令尽量保证采样率、声道、位深和你的代码参数完全一致ffmpeg -i input.mp3 -ac 1 -ar 44100 -f s16le output.pcm参数解释-ac 1输出单声道-ar 44100输出采样率44100Hz-f s16le输出格式为16位有符号整型、小端字节序little-endian如果不小心把PCM文件转成其他格式比如浮点型或者8位后续播放参数也要跟着改否则会听到刺耳的杂音。也可以自己用代码生成一段简单正弦波来测试方便确认链路是否通。方法不复杂但需要理解采样原理。比如生成1秒、频率440Hz、采样率44100的单声道PCM每秒钟有44100个采样点每个采样点用short表示int sampleRate 44100; int frequency 440; short[] samples new short[sampleRate]; for (int i 0; i samples.length; i) { samples[i] (short) (Math.sin(2 * Math.PI * frequency * i / sampleRate) * 32767); }将这段samples转为字节数组写入文件就是一个可以直接给AudioTrack播放的PCM数据了。我第一次跑通音频链路时用的就是这种生成数据因为它确定性强不用怀疑文件本身有没有问题。3.2 边写边播MODE_STREAM完整实现有了数据源接下来就是一个标准的流式播放流程。核心逻辑分三块初始化AudioTrack、创建写入线程、停止和释放。public class PcmPlayer { private AudioTrack audioTrack; private boolean isPlaying false; private final int sampleRate 44100; private final int channelConfig AudioFormat.CHANNEL_IN_MONO; private final int audioFormat AudioFormat.ENCODING_PCM_16BIT; public void playPcmFile(String filePath) throws IOException { int minBufferSize AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat); audioTrack new AudioTrack( AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, minBufferSize * 2, AudioTrack.MODE_STREAM); byte[] buffer new byte[minBufferSize]; FileInputStream fis new FileInputStream(filePath); audioTrack.play(); isPlaying true; new Thread(() - { try { int read; while (isPlaying (read fis.read(buffer)) 0) { audioTrack.write(buffer, 0, read); } } catch (IOException e) { e.printStackTrace(); } finally { release(); try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } }).start(); } public void stop() { isPlaying false; if (audioTrack ! null) { audioTrack.pause(); audioTrack.flush(); } } private void release() { if (audioTrack ! null) { audioTrack.stop(); audioTrack.release(); audioTrack null; } } }这段代码里有几个细节值得注意。一是read()的读取大小。我这里是每次读minBufferSize字节这通常等于底层的一次消耗量能保证写入的节奏尽量平滑。读太小的块会导致write调用非常频繁CPU占用上涨读太大的块在文件末尾可能出现期望字节数不够、而read返回值只有一部分的情况AudioTrack的write方法是支持部分写入的这没问题。二是播放状态和写入线程的关系。isPlaying这个标志位要在play()之后立刻置为true否则线程可能还没开始写数据就提前退出了。停止的时候先置false再调用pause和flush防止残留数据走出半截。三是release()的调用时机。数据读完后一定要release否则AudioTrack会一直占用底层资源。这个资源不是说没就没的在反复创建播放器的场景里不释放必然导致后续初始化失败。3.3 用AudioTrack.Builder写出更清晰的代码如果不想背构造函数那一长串参数顺序Android 5.0之后可以用BuilderAudioTrack audioTrack new AudioTrack.Builder() .setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build()) .setAudioFormat(new AudioFormat.Builder() .setSampleRate(44100) .setChannelMask(AudioFormat.CHANNEL_IN_MONO) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .build()) .setBufferSizeInBytes(minBufferSize * 2) .setTransferMode(AudioTrack.MODE_STREAM) .build();Builder方式把参数分组管理以后要改采样率、改声道只动对应那段就行。同时它用AudioAttributes替代了原来streamType那一串音频流类型语义更清楚。但要注意Builder在某些老旧定制系统上可能出现IllegalArgumentException因为底层实现跟原生还是有差异。真遇到这种情况回退到传统构造函数往往是最高效的解法。我在某台公版系统设备上就踩过一次Builder初始化的AudioTrack播放某些长音频时偶发闪退换成传统构造函数之后问题彻底消失。如果对未知设备兼容性没底尽量在主流程中做好try/catch和兜底方案。3.4 播放速度与线程的心跳控制流式播放最怕的是写入线程跑太快或者太慢。跑太快AudioTrack的缓冲区并不一定照单全收。write()方法是阻塞式的当缓冲区满时它会阻塞住当前线程直到底层消费出空间。这实际上是天然的背压机制你不用额外做限速。跑太慢则容易出现播放断层。所以不同场景可以调节每次写入的字节数和线程写入节奏。如果读取数据是一个连续且均匀的动作可以直接用上面那种while循环如果数据是网络分片到达的就要手动控制写入节奏或者让数据排队保证缓冲区不断供。我见过不少初学者在这个环节试图用Thread.sleep去做节流其实没必要。与其sleep控制不如直接依赖write的阻塞特性。如果非要控制播放速度可以用AudioTrack.getPlaybackHeadPosition()来拿当前已播放的采样帧计数换算成已播放时间int playedFrames audioTrack.getPlaybackHeadPosition(); double playedSeconds playedFrames / (double) sampleRate;这个值可以用来做进度条、同步歌词或者判断是否播放结束。但要记住它并不是实时更新的不同设备刷新频率不同做精细同步时不能完全依赖它。4. 常见问题排查基本都是这几个坑4.1 播放没有声音到底哪里断了没声音大概率和播放链路某个环节断了而不是音量没调大。我排查的顺序是先看音量再看状态再看数据格式。用AudioManager.getStreamVolume(AudioManager.STREAM_MUSIC)确认媒体音量不为0。有时候测试机音量被调到最低但媒体音量本身合理人耳却什么都没听到。再看AudioTrack状态是否处于STATE_INITIALIZED。创建成功后getState()应该返回STATE_INITIALIZED。如果返回STATE_UNINITIALIZED基本就是参数非法或者内部初始化失败。再看play()之后有没有数据写入。前面说过STREAM模式不写数据就不会出声。建议在写入线程第一行打日志确认read和write是否执行了。最后看文件数据本身。很多“无声”其实是文件为空或者PCM数据全部是静音值。测试时可以先用上文的纯数据生成方法保证源头一定有声。这四个环节逐一排查90%没声音的问题都能定位。4.2 卡顿、断断续续根源在缓冲区播放卡顿是所有音频开发者的老朋友。回想一下写代码时是不是把bufferSizeInBytes设成了一个特别小的值如果设成几百字节底层消费完之后写入线程还没缓过来声音就会瞬间断掉。解决办法是前面说的把缓冲区调成getMinBufferSize()的2倍甚至3倍。同时观察write()返回值和实际读取数据量是否一致。如果read()返回0或-1说明源数据已经在末尾不是卡顿问题如果read()明明有数据、但write()阻塞时间很长说明缓冲区确实不够用加缓冲即可。还有一个容易忽略的地方文件读写和AudioTrack写入放在主线程里跑也可能导致卡顿。旧项目里见过在主线程里直接做文件读写的一个大的write一执行UI直接掉帧听觉上也跟着难受。播放逻辑必须放工作线程这点没什么可商量的。4.3 声音变调采样率不匹配假设你的PCM文件明明是48000Hz代码里却设置了44100播放出来就会出现类似花栗鼠一样的声音。这是因为每秒被采样的点数少了系统只能用更低的节奏去解释原本的密集信号。排查方法很简单把音频的文件头信息打印出来确认采样率、声道数、位深再和AudioTrack初始化参数做对比。一个实用技巧是如果你用ffmpeg转PCM把参数写成一个常量类代码里直接用同一个常量的值避免两边手写不一致。4.4 声音失真或刺耳PCM数据本身没问题代码初始化也没问题但播放出来就是一片噪音那基本是字节序和位深的问题。常见的情况是数据实际是24位PCM但当成16位PCM去解析。每个采样点读取的字节数不同数据流错位后续所有采样值都是乱的听起来就是刺耳的尖锐声。解决思路明确数据格式按格式解析后再写入AudioTrack。24位、32位浮点等格式如果AudioTrack不支持就要在内存中做一次格式转换。这属于音频开发里绕不开的“算法活”以后专门写一篇来讲。4.5 延迟太高怎么优化延迟来自两个层面数据积累延迟和系统调度延迟。数据积累延迟很好理解。缓冲区越大数据从写入到出声的等待时间就越长。按系统采样周期来算如果缓冲区容纳了约200ms的音频数据那播放时声音天然会慢200ms。实时场景里耳机监听、乐器弹奏这种对延迟敏感的缓冲区要尽量取minBufferSize即可甚至可以用AudioTrack.Builder配合低延迟音频特性但要注意设备兼容性。系统调度延迟则跟线程优先级、Type、CPU调度有关。把写入线程优先级提高有一定帮助但不是决定性因素。真正要低延迟Android上更专业的方案是走AAudio或OpenSL ESAudioTrack只是基础路线。4.6 一张表总结常见问题现象常见原因解决方向完全没有声音音量、数据未写入、文件静音按音量→状态→写入→数据的顺序排查卡顿断续缓冲区太小缓冲区调大2~3倍音调不对采样率不一致对齐采样率参数失真破音位深/字节序错误确认并按原始格式解析延迟明显缓冲区过大调小缓冲区尝试低延迟方案初始化异常参数不合法或不支持打印各参数回退传统构造函数5. 再往前走一步配合采集和实时播放5.1 用AudioRecord采集再实时播放AudioTrack常与AudioRecord成对出现。AudioRecord负责从麦克风采集PCM数据AudioTrack负责把PCM数据播放出来。二者配合能非常直观地体验到音频链路的完整流转。一个最简单的实时音频回路是这样的AudioRecord audioRecord ...; // 采集配置 AudioTrack audioTrack ...; // 播放配置 while (isRunning) { int readSize audioRecord.read(buffer, 0, buffer.length); if (readSize 0) { audioTrack.write(buffer, 0, readSize); } }这段代码如果直接跑大概率能听到延迟不小的自己声音。因为采集和播放之间没有做回声消除、降噪等处理底层的设备调度也会带来额外延迟。但它验证了“采集→处理→播放”的整个流程。我当时第一次在这种循环里加入音频数据处理时同一段代码既完成了录音又完成了播放响度调节瞬间理解了为什么音频App里的功能模块大部分都是围绕PCM数据在做文章。5.2 从本地文件扩展到流媒体本地PCM文件播放跑通后可以尝试把数据源替换成网络流。做法是客户端先拿到PCM字节流通过队列传递给播放线程。要注意网络抖动时不能直接往AudioTrack里裸写而是应该做一个短暂的预缓冲比如积累100ms到200ms的数据再开始播放避免网络抖动导致频繁卡顿。预缓冲和卡顿是一对矛盾。缓冲长、抗抖动但延迟高缓冲短、响应快但容易在网络波动时断音。这个平衡点要根据具体场景去调没有标准答案。5.3 更高阶的扩展方向如果想把Android音频这条线走得深入一些AudioTrack只是起点。后续可以了解SoundPool适合短音效并发播放、AAudioAndroid 8.0后高性能音频管线、OpenSL ESC语言接口游戏音频常用、以及专门做音频解码和处理的第三方框架。每一层都有各自的特点但底层逻辑都离不开PCM处理。会用AudioTrack播放PCM之后我再去看这些方案时理解速度明显加快了。因为所有的音频方案最终都在解决同一个问题如何把连续的数字音频数据高效、按时地送达音频硬件。5.4 个人的一点实战体会踩过足够多的坑之后我最大的感受是用AudioTrack播放PCM本身并不难难的是先彻底理解数据格式再彻底理解缓冲区和线程调度。很多人一开始就纠结“用哪个播放器组件”其实真正要纠结的是“我的数据长什么样”和“我要以多快的节奏把数据送出去”。所以我现在做音频功能时第一步永远是先写一个最小可运行的播放Demo用生成的正弦波或者最简PCM文件把链路跑通确认扬声器有声音。然后才开始往里加文件读取、加网络、加实时处理。这样每一步出现问题时都能立刻判断到底是我新增的环节出了问题还是底层AudioTrack配置本身有毛病。把基础链路稳定在一个可信的状态后面的所有复杂功能都会好做得多。
阅读完成 · 觉得有帮助?