资讯详情

Android AudioTrack播放PCM音频:原理、实践与延迟优化

📅 2026/10/13 13:40:22 | 华诺云谱 👁 阅读
Android AudioTrack播放PCM音频:原理、实践与延迟优化
去年做一个语音通信的小项目中间要把网络上下来的音频数据播放出去。一开始图省事直接塞给MediaPlayer结果发现它压根不是干这个的它内部会自己去解复用、解压缩你根本没法控制它的数据送到扬声器的时机延迟一上来就是几百毫秒做实时通信根本没法用。后来换成Android原生的AudioTrack配合PCM格式的裸音频数据问题一下简单了很多。AudioTrack是Android音频框架里最接近音频硬件的一层Java API它不走解码、不走中间层封装你给它的就是纯PCM字节流它负责把字节流按你设定的采样率、声道数、位深送进AudioFlinger再由底层混音线程写入声卡。对于需要自己控制音频数据的场景——比如PCM录音回放、语音通话、音频编辑预览、网络音频流播放器——几乎都是首选方案。这篇文章我打算把这套东西从数据准备、核心API调用到延迟优化和常见坑位完整梳理一遍适合那些已经能写简单Android程序、但还没系统玩过音频数据回放的同学。看完以后遇到“播放一段PCM”“做一个低延迟播放器”“音频数据和画面要对上”这类需求应该能少走不少弯路。1. 先分清楚MediaPlayer、SoundPool和AudioTrack的分工很多刚接触音频播放的同学会疑惑Android不是已经有MediaPlayer了吗为什么还要用一个看起来更底层的AudioTrack这俩还真不是互相替代的关系理解它们的分工是整个方案选型的起点。MediaPlayer适合“播放一个完整音频文件”的场景它内部封装了一整套解码器助力你传入一个文件路径或者URI它自己去完成解封装、解码、混音、输出。优点是省心、功能全支持几乎所有的音频格式。缺点是控制粒度太粗你能控制的只是播放、暂停、seek、音量这些高层操作拿不到中间过程的音频数据也不适合对延迟敏感的场景因为它通常要先做一定的缓冲和预取。SoundPool则是为了方便游戏音效这类短促、高频触发的声音内部做了声音加载和并发播放优化适合像按钮音效、碰撞音效这类几秒钟之内的素材。它内部也会解码只是把解码结果缓存在内存里播放时走合成器路径延迟比较低但不适合长音频流。AudioTrack属于最底层的那一类播放出口它不接受文件、不接受路径、不接受压缩格式只接受已经解码好的PCM字节流。使用它的本质是解码这件事你自己负责它只负责“把你喂给它的字节流按你指定的音频参数以尽可能低的时间延迟送出去”。这种设计带来的第一个好处是可控性。PCM数据去哪、什么时候去、一次送多少全都在你掌控里。第二个好处是延迟更短因为少了解码和缓存这两个不确定环节。第三个好处是灵活——你既可以直接播一份PCM文件也可以实时播录音数据还可以播放从解码器比如系统MediaCodec输出的PCM流。我个人的经验是如果需要播放网络语音或录音回放且手里拿着的本来就已经是PCM数据那就直接用AudioTrack不要绕道MediaPlayer再去套一层。如果给人做音频文件播放器那才考虑基于MediaPlayer或者ExoPlayer来做。选错层级的后果最明显的就是实时通信场景里延迟压不下来以及明明手里有PCM数据还得封装成文件再解码回去白消耗性能。1.1 两种传输模式的实际使用边界AudioTrack创建时可以指定传输模式MODE_STATIC和MODE_STREAM这个选择直接决定了你该怎么跟它配合。MODE_STATIC适合数据量小、一次性送完的场景。创建后把整段PCM数据通过write写进去再调用play系统会把这批短数据尽量放到低位延迟的缓冲区里。比如录音软件里的“试听刚才那几秒”、App里的通知提示音、游戏里的短音效都很适合静态模式。它的原则是“给我完整数据我还你一次低延迟播放”。MODE_STREAM则是“边送边播”。创建后你持续往AudioTrack里write数据块AudioTrack内部维护一个环形缓冲队列底层混音线程不断地从队列里取数据送进声卡。这个模式适合音频时长较长、数据无法一次性加载完的场景比如播放一首歌的PCM流、网络实时的语音流。本篇文章后面说的都是这种模式因为它最能反映AudioTrack在真实项目里的工作方式。有一个容易忽略的细节MODE_STATIC模式不能往里write超过缓冲区大小的数据否则会直接抛异常。而MODE_STREAM虽然对单次写入数据块的大小没有硬性限制但write同样会阻塞直到这次数据全部进入内部缓冲。所以“一边读文件一边写AudioTrack”的“边读边写”模型配合流式模式才是长音频播放的标准姿势。1.2 前置知识读懂PCM的关键参数PCM脉冲编码调制可以理解成“对声音波形进行离散采样后得到的原始数字序列”。播放PCM之前必须明确三个基本参数采样率、位深、声道数这三个和硬件输出的匹配程度直接关系到声音是否正确、是否变调、是否延迟过大。采样率表示每秒钟对声音采样多少次常见值是44100Hz和48000Hz。位深表示一个采样点用多少比特量化最常见的是16bit也就是两个字节表示一个采样点。声道数决定了数据是单声道还是立体声立体声数据在内存里的排布是左声道、右声道交替出现业界叫interleaved交错布局。由这三个参数可以算出一个关键数值数据速率。公式很朴素每秒字节数 采样率 × 位深 / 8 × 声道数举个具体例子采样率44100、位深16bit、双声道每秒需要的字节数就是44100 × 2 × 2 176400字节约172KB/s。这个数字在后续设计缓冲区大小、计算播放进度、预估延迟时反复要用到。理解它整个音频回放的节奏感就对了。2. 准备PCM数据没有现成文件时的录音与WAV头部剥离AudioTrack只吃PCM裸数据但实际在项目里直接拿到一个裸的.pcm文件放在assets里的情况并不多。多数时候你手里要么是一段WAV文件要么需要现场从麦克风录一段PCM出来。所以我在开工之前通常先把“数据从哪来”这一步理清楚。2.1 WAV文件与PCM的关系WAV可以理解成是一个外壳容器它把真正的PCM数据包在里面并在文件开头写了一段44字节的头部信息说明这段数据是什么格式。所以拿到WAV文件只要把它头部那几十个字节Skip掉剩下的基本就是我们需要的PCM裸流。标准PCM格式的WAV头头部布局大概是这样的0-3 RIFF 4-7 RIFF块大小 8-11 WAVE 12-15 fmt 16-19 fmt块大小一般是16 20-21 音频格式1表示PCM 22-23 声道数 24-27 采样率 28-31 字节速率 32-33 块对齐 34-35 位深 36-39 data 40-43 data块大小 44 PCM数据从这里开始注意这些数值都是小端序存储的。我最初写解析代码时无视了字节序拿个WAV文件一顿解析采样率读出来是个奇怪的天文数字播放时声音完全变形。后来才想起Windows系文件普遍用Little Endian而Java的ByteBuffer默认是Big Endian必须显式指定。2.2 用RandomAccessFile解析WAV头并定位PCM数据解析头部其实不需要引入第三方库。用RandomAccessFile读前44个字节再手动按小端序解析出关键字段。下面这段代码我实际项目里一直在用解析WAV头部并跳转到PCM数据起点public static WavInfo parseWavHeader(RandomAccessFile raf) throws IOException { // 只读取44字节标准头 byte[] header new byte[44]; raf.readFully(header); if (header[0] ! R || header[1] ! I || header[2] ! F || header[3] ! F) { throw new IllegalStateException(not a riff file); } if (header[8] ! W || header[9] ! A || header[10] ! V || header[11] ! E) { throw new IllegalStateException(not a wave file); } int audioFormat (header[20] 0xff) | ((header[21] 0xff) 8); int channels (header[22] 0xff) | ((header[23] 0xff) 8); int sampleRate (header[24] 0xff) | ((header[25] 0xff) 8) | ((header[26] 0xff) 16) | ((header[27] 0xff) 24); int bitsPerSample (header[34] 0xff) | ((header[35] 0xff) 8); int dataSize (header[40] 0xff) | ((header[41] 0xff) 8) | ((header[42] 0xff) 16) | ((header[43] 0xff) 24); if (audioFormat ! 1) { throw new IllegalStateException(not linear pcm format); } if (channels ! 1 channels ! 2) { throw new IllegalStateException(only support mono/stereo, channels channels); } if (bitsPerSample ! 16) { throw new IllegalStateException(only support 16bit pcm, bits bitsPerSample); } // data块可能在标准44字节位置之前这里的定位做了保守处理 WavInfo info new WavInfo(); info.sampleRate sampleRate; info.channels channels; info.bitsPerSample bitsPerSample; info.dataOffset 44; // 标准无损PCM的data块通常从44开始 info.dataSize dataSize; raf.seek(info.dataOffset); return info; } static class WavInfo { int sampleRate; int channels; int bitsPerSample; long dataOffset; long dataSize; }说句实话实际工程里有些WAV文件的data块并不是刚好从44字节开始因为fmt块里可能包含扩展信息导致头部边长。稳妥的做法是在44字节以后继续查找“data”这个ASCII标记。更简单的做法是直接用MediaExtractor、MediaFormat去解析但那样又过于重量级了。我一般先用这份轻量解析遇到解析异常再分开处理99%的标准PCM WAV都能覆盖。2.3 用AudioRecord现场录制一份PCM测试数据如果没有现成的音频文件最省事的方法是直接用AudioRecord从麦克风录几秒钟存成一个PCM裸文件。这段录音代码也可以顺带验证麦克风采集参数是否和后续播放参数匹配。private void recordPcmToFile(String path, int durationMs) throws IOException { int sampleRate 44100; int channelConfig AudioFormat.CHANNEL_IN_MONO; int encoding AudioFormat.ENCODING_PCM_16BIT; int minBufSize AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding); AudioRecord recorder new AudioRecord(MediaRecorder.AudioSource.MIC, sampleRate, channelConfig, encoding, minBufSize * 2); FileOutputStream fos new FileOutputStream(path); byte[] buffer new byte[Math.max(minBufSize, 4096)]; recorder.startRecording(); long endTime System.currentTimeMillis() durationMs; while (System.currentTimeMillis() endTime) { int read recorder.read(buffer, 0, buffer.length); if (read 0) { fos.write(buffer, 0, read); } } recorder.stop(); recorder.release(); fos.close(); }这里我故意把AudioRecord的缓冲设成getMinBufferSize的两倍因为实际录音时如果缓冲区太小read会频繁地返回短数据写文件就会碎。录音和播放用的采样率、位深要统一不然录出来声音发闷或者变得尖细你甚至会误以为AudioTrack写错了。3. AudioTrack初始化与播放参数配置到数据输送的完整链路数据准备好了接下来就可以创建AudioTrack并开始播放。这节的代码是核心中的核心我在项目里反复迭代过好几版把每一步为什么要这么写解释清楚。3.1 用构造参数和Builder配置播放规格从API 21开始官方推荐用AudioTrack.Builder来创建因为它把混沌的一片参数封装成了可读性很强的链式调用。但如果你看老代码大概率会见到底层构造函数那种写法所以Builder和构造函数我都给出来。Builder方式int sampleRate 44100; int channelConfig AudioFormat.CHANNEL_OUT_MONO; int encoding AudioFormat.ENCODING_PCM_16BIT; int minBufSize AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding); AudioTrack track new AudioTrack.Builder() .setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build()) .setAudioFormat(new AudioFormat.Builder() .setSampleRate(sampleRate) .setEncoding(encoding) .setChannelMask(channelConfig) .build()) .setTransferMode(AudioTrack.MODE_STREAM) .setBufferSizeInBytes(minBufSize * 2) .build();这几个参数分别决定了什么我展开说说。AudioAttributes描述音频的使用场景USAGE_MEDIA表示多媒体音乐USAGE_VOICE_COMMUNICATION表示语音通信不同使用场景会影响系统的音量调节策略、路由策略和是否走低延迟通道。AudioStreamFormat定义了你要喂进来的PCM数据格式采样率、位深、声道数必须和你的PCM数据真实情况完全一致。这里出问题的典型情况是PCM文件明明是44100Hz立体声代码里写成48000Hz单声道结果要么变调要么一样的声音扁平化要么直接出现“刺啦刺啦”的噪声。哪怕你的播放硬件在48000Hz更合适只要你的数据是44100Hz就得按44100Hz告诉AudioTrack让系统内部去转换速率而不是靠你错误地声明格式来碰运气。TransferMode对应上节说的静态/流式模式这里必须用MODE_STREAM因为我们要持续读文件、持续write而不是一次性塞完。BufferSizeInBytes则是AudioTrack内部缓冲队列的大小。很多人会偷懒直接写成getMinBufferSize的返回值这样能跑但很容易出现卡顿。我默认取最小值的两倍给了系统更多的余量去应对底层混音线程的调度波动。顺带一提getMinBufferSize这个函数返回的是“系统建议的缓冲下限”它通常是根据你给的采样率、声道、位深计算出来的单位是字节。它的一般结论是至少要能容纳约两个AudioFlinger混音周期(period)的数据混音周期常见是20ms左右具体值跟设备策略有关。把这个值直接设为缓冲大小相当于杯子里只有刚够一口的水稍微晚一点加水就会断流。放大两倍甚至三倍是一个很实用的工程习惯。3.2 边读边写的PlayThread模型AudioTrack创建完成并调用play之后它并不会自己去读文件得靠我们的代码不断write数据。播放驱动模型可以封装成一个专用的播放线程我习惯叫它PlayThread。这个线程只干一件事循环读PCM文件循环调用write直到数据读完。一个最小可用的播放线程如下private AudioTrack track; private volatile boolean playing false; private FileChannel channel; private void startPlayback(final String path) { playing true; Thread playThread new Thread(() - { try (FileInputStream fis new FileInputStream(path)) { RandomAccessFile raf (RandomAccessFile) fileAccess; // 实际用RandomAccessFile访问 WavInfo info parseWavHeader(raf); sampleRate info.sampleRate; channelCount info.channels; initAudioTrack(sampleRate, channelCount); track.play(); byte[] buffer new byte[8192]; int read; while (playing ((read raf.read(buffer)) 0)) { int written 0; // write可能因为缓存满等原因未写满需要循环写 while (written read) { int ret; if (Build.VERSION.SDK_INT 23) { ret track.write(buffer, written, read - written, AudioTrack.WRITE_BLOCKING); } else { ret track.write(buffer, written, read - written); } if (ret 0) { written ret; } else { Log.e(AudioTrackDemo, write error: ret); playing false; break; } } } // 等待播放完缓冲区内剩余数据 if (playing) { track.stop(); } } catch (IOException e) { e.printStackTrace(); } finally { if (track ! null) { track.release(); } } }); playThread.setPriority(Thread.MAX_PRIORITY); playThread.start(); }这里有一个非常重要的细节AudioTrack.write有很多重载返回值表示实际写入的字节数。我见过很多初版代码简单调用一次write就把返回值忽略掉结果数据量一大一次write可能只写进去一半剩下的就丢了播放尾部就残缺。另一个细节是write是阻塞式的当AudioTrack内部缓冲满时它会阻塞当前线程等待底层消费。这是好事它天然提供了流控避免你“读文件太快、播放来不及”导致内存里堆积大量数据。所以这个线程里不需要加什么复杂的等待逻辑write自己会把节奏拖慢到和播放速度一致。3.3 play、pause、stop、release的状态流转AudioTrack内部有限状态机官方文档把状态分为STATE_INITIALIZED、PLAYSTATE_STOPPED、PLAYSTATE_PLAYING、PLAYSTATE_PAUSED等。状态迁移必须遵守规则否则会出现“明明写了数据为什么没声音”之类的诡异问题。常规顺序是创建AudioTrack成功后处于初始化状态调用play()进入播放状态之后可以反复调用pause()暂停、play()继续。stop()会停止播放并释放内部缓冲区的数据但此时AudioTrack对象还可以重新调用play()继续使用吗严格来说stop后再play在有些版本上可以但因为它内部缓冲区清空了已经上次写入的数据也会被清掉所以“停止再播放”往往需要重新喂数据实际用起来体验并不好。更常见的模型是播完一次就release()释放下次要播再重新create。即便你用MODE_STREAM也是这种思路因为初始化AudioTrack的成本其实不算高这比在状态机边界问题上费尽心思要稳得多。我在项目里就是每次播放都创建新AudioTrack播完在finally里release代码结构清晰也不会踩状态机的一些边角坑。4. 卡顿与延迟数据写入节奏和缓冲区大小怎么控章节标题写的“卡顿与延迟”这两个概念其实不完全是一回事但在实际音频播放中往往同时出现。卡顿是数据输送不及时导致playback空洞声音断断续续延迟是从输入到听到声音的时间差。它们都和缓冲区大小、写入节奏强相关。4.1 声音断断续续的根因与解决思路卡顿的本质是AudioTrack内部缓冲区里没有数据可播了。底层混音线程每过固定周期就试图从缓冲区拿帧数据去输出如果拿不到就输出静音或重复上一帧人耳感受到的就是“卡一下”或者“持续性碎片”。为什么会拿不到常见原因有三个。第一缓冲区太小稍微一点调度波动比如别的线程占了大量CPU主线程GC或者网络IO阻塞就会导致播放线程的write不及时。第二读取文件或网络的IO速度跟不上播放速度尤其从网络读数据时网络抖动直接体现在write的间隔上。第三播放线程优先级不够Android系统在CPU调度紧张时普通线程可能被延迟几十毫秒甚至上百毫秒这点在音频播放中很致命。针对这三个原因对应的对策是缓冲区放大我前面提到过最小值的两倍我现在更夸张一点对长音频播放如果对延迟要求不那么苛刻放到三倍、四倍都是合理的数据和播放之间增加一块预加载区也就是先读一部分数据再启动播放不要一开线程就play等缓冲区攒了一半再开始写把播放线程优先级提到最高可以调用Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)让调度器优先照顾它。4.2 画面与声音同步延迟从哪里来怎么测量做视频播放器或者游戏音效时经常遇到一个需求放某段声音时要和画面或事件对得上。AudioTrack的延迟是客观存在的而且很难完全消除。数据从write进入AudioTrack缓冲区不算底层还会把数据发送给AudioFlingerAudioFlinger的混音线程可能又有一份缓冲区最后才交给声卡。对手机上常见的播放链路来说几百毫秒甚至几十毫秒都可能出现。要测量实际延迟最朴素的办法是做一个“发令枪”测试让AudioTrack播放一段短促的脉冲音同时用一块秒表来肉眼估算但这方法显然拿不到精确值。做稍微精确一点用AudioFlinger提供的回调或者AudioTrack的GetTimestamp接口获取时间戳通过时间戳能算出音频从缓冲区到输出设备的时间差。这对本项目有点超纲了我更想提供一个工程上传用的简单估算公式延迟(秒) ≈ 缓冲区可容纳音频秒数 / 2 固定输出设备延迟为什么要除以2因为正常情况下缓冲区不是全空时才开始播放的在“半填半播放”的平衡状态下平均等待时间近似为缓冲区容量的一半。固定输出设备延迟在手机上通常比缓冲区延迟小很多如果用听感去衡量感受会更明显的是缓冲区相关的部分。如果你的App给出的同步要求是“误差100ms以内”那么缓冲区控制在200ms以内基本可以满足如果要求更高比如语音通话这种要求端到端延迟低于300ms的那我建议去做低延迟音频那一套把采样率设置为设备native格式、使用AudioTrack的低延迟路径同时确保App开启AudioFocus并进入前台减少系统混音带来的额外开销。4.3 实时播放中更平滑的写入策略我在实际项目中比较常用的做法是“双缓冲水位控制”的思路。所谓双缓冲不是说你一定得创建两个AudioTrack而是说在同一时间段内至少要让缓冲区里有两块数据待播放。你可以先往AudioTrack里写入两到三块数据再启动play等播放线程发现缓冲区里剩余数据量低于某个水位时再继续补充写入。按这个思路写出来的循环结构大致是这样保持一个循环不断判断AudioTrack当前的播放进度计算剩余未播放的数据量不足一个阈值就再write一块。AudioTrack本身没有直接提供“剩余可写字节数”的接口但可以通过GetPlaybackHeadPosition和已写入的字节数推算。实际计算时需要注意PlaybackHeadPosition返回的是已播放的采样帧数不是字节数。从它换算成字节数很简单单声道16bit一个帧是两个字节双声道是四个字节等价于字节数 播放帧数 × 声道数 × 位深/8。这样你就能算出“已经写进去多少字节、已经播放了多少字节”两者相减就是缓冲区内未播放的剩余量。有了这个剩余量就可以实现水位控制剩余量低于安全水位就写进一块数据剩余量高于上限就sleep一小会儿等它消耗。这比起单纯依赖write阻塞推进对网络流这种数据到达不均的场景会有更顺滑的效果。5. 播放状态机与常见异常杂音、无声音、崩溃的排查清单AudioTrack用起来其实不算麻烦但它的异常往往看起来特别“玄学”明明数据没问题就是没声音或者有声音但不是人声是高压锅爆炸声再或者App直接闪退了。这些坑我基本都踩过一遍每一个的背后都能讲出对应的原因。5.1 播出来的全是刺啦噪声你自信满满地把数据写进AudioTrack结果耳机里传出来的全是刺啦刺啦的噪声。这个问题的99%原因是喂给AudioTrack的数据格式和你声明的格式不一致。举个最常见的例子你声明的是AudioFormat.ENCODING_PCM_16BIT表示每个采样点两个字节但实际数据可能是8bit的也就是每个采样点一个字节。系统按16bit去解释你的8bit数据读出来的点位全错位自然全是噪声。还有一个经典场景本来数据是立体声的左右声道交错排列左1右1左2右2你却按单声道喂进去系统拿到两个字节就当一个采样点声道数量就对不上声音也会发糊或刺啦。另外如果采样率、位深声明错误很多设备并不会直接拒绝播放而是把数据按错误的格式送出声卡表现为音调全面偏移甚至碎裂成噪声。排查建议用AudioRecord自己录一段、用一致的参数播放如果播放正常说明代码链路没问题问题出在外部数据的格式和你的假设不一致。用文件分析工具看一下数据头的声道数、位深别靠猜。5.2 数据写进去了却听不到声音另一个高频问题是代码执行正常write一次也没报错但耳机里始终无声。这种情况先别急着怀疑硬件按顺序检查几个地方。先检查是否调用了play()。我记得有次重构代码把play()放在了赋值之前结果整个播放流程走完数据全被write进了“未开始播放”的AudioTrack缓冲自然没有声音。AudioTrack必须在播放状态下才会把缓冲里的数据往底层送没有play()写多少都是白搭。然后检查音频焦点如果另一个App正在独占音频焦点或者你自己的App请求焦点失败系统可能会把AudioTrack的声音压到最低甚至直接静音。可以在调试时顺带打印一下AudioManager.isMusicActive和当前的焦点状态。最后检查路由蓝牙耳机、外放、听筒等等不同的路由可能导致某些情况下的声音输出不到你期望的设备。测试时把蓝牙断开重新播放试试很多“无声音”其实是路由切到别处去了。5.3 stop之后立刻release时崩溃在播放线程里如果直接调用track.stop()然后马上track.release()在一些设备上偶尔会碰到底层UAF之类的崩溃或者更常见的是“call release() on a paused AudioTrack”这种极小概率的边角问题。严格来说release任意状态下都可以调用但设计时不应该假设release一定安全。我经历过一次诡异崩溃玩家快速退出音频播放界面Activity销毁时调用了AudioTrack.stop()和release()while循环里的播放线程恰好还在下一个write过程中就崩溃了。原因是同一线程外部的stop和播放线程内部的write并发破坏了状态一致性。稳妥的做法播放线程里使用volatile布尔变量或AtomicBoolean来控制停止标志外部要停止时设置标志为false让播放线程自己退出循环等播放线程结束后再在主线程或统一的地方调用release。只要做到“永远不在别人正在操作AudioTrack时去释放它”这类崩溃基本能根绝。5.4 蓝牙耳机下的延迟和路由切换问题蓝牙传输协议本身的编码缓冲会给音视频同步带来额外延迟蓝牙播放的延迟普遍比有线耳机高出一截。这本身不算是AudioTrack的错而是在任何音频架构下都会有的物理约束。做实时同步时如果发现有线和蓝牙延迟差异明显大概率要在上层做不同的延迟补偿策略。路由切换时比如你正用扬声器播放忽然连上蓝牙耳机AudioTrack底层会自动重路由到新输出设备。多数情况下播放会继续但可能会有一瞬间的卡顿或跳音因为在切换缓冲区的瞬间数据流的时序被重排了。如果项目对这块体验要求高可以通过监听AudioManager的ACTION_AUDIO_BECOMING_NOISY或有线耳机插拔广播主动暂停播放等路由切换完成再续播。6. 进阶能力进度获取、暂停继续、变速处理如果只是把一段PCM播完其实上面已经够用了。但实际产品里你几乎一定会遇到几个追加需求暂停继续、然进度条显示时间、结尾前变速快进。这些功能在MediaPlayer上都是现成的但在AudioTrack这里全都要自己“造轮子”。这些轮子造过之后其实不难而且会加深你对播放引擎的理解。6.1 用getPlaybackHeadPosition计算播放进度AudioTrack提供了一个getPlaybackHeadPosition()方法返回的是当前已经播放了多少个采样帧。一帧的定义是一次采样周期的所有声道数据比如单声道16bit的一帧是两个字节双声道16bit的一帧是四个字节。用它和采样率配合就可以算出当前播放了多少毫秒public long getPlaybackPositionMs(AudioTrack track, int sampleRate) { if (track null || track.getPlayState() ! AudioTrack.PLAYSTATE_PLAYING) { return 0; } int frames track.getPlaybackHeadPosition(); // 注意这个方法返回的是int长时间播放会溢出 return frames * 1000L / sampleRate; }值得注意的是调用getPlaybackHeadPosition本身要求AudioTrack处于初始化后且未释放的状态。它在pause状态时会返回暂停时刻的位置stop之后在部分版本上会归零。所以你不要把它当作可靠的底层时钟当做“用于更新进度条UI的参考值”是比较合适的。如果做精度要求更高的音频进度统计可以考虑自己累计write字节数再减去缓冲区内未播放的字节数算出一个更准确的已播放位置不过对大多数App场景头部位置就已经够用。6.2 暂停继续的代码实现要点AudioTrack的pause和play接口可以直接实现暂停继续但是有一个坑调用pause()后底层可能已经消费掉缓冲区里的一些数据而暂停前你的播放线程还在往缓冲里塞数据。暂停的瞬间缓冲可能还剩一部分正在播放的数据这些数据会很快被消费完然后状态机进入PAUSED此时缓冲里的数据量其实已经不完整了。所以暂停和继续要配套一套“进度管理”最简单的做法是暂停时记录当前getPlaybackHeadPosition播放线程控制不再继续写继续时先计算还要从数据文件哪个偏移量继续读然后重置状态继续写。如果只是暂停几十秒又继续而且文件是从头开始顺序播放其实可以直接pause和play因为缓冲的数据本来就还没读完系统会从缓冲区剩余位置继续播放。但如果你暂停期间文件读取指针已经被其他逻辑移动了就必须做偏移量的对齐否则声音会乱跳。6.3 变速播放用setPlaybackRate就够了AudioTrack有现成方法setPlaybackRate(int rate)实现变速播放rate参数以1000为基准1000表示正常速度。比如想要1.5倍速播放传入1500想要0.5倍速传入500。内部是通过调整播放采样率来改变播放速率的注意它和“改变播放节奏但不改变音调”的声码器变调变速不是一回事变速的同时音调也会跟着变高或变低。做正常倍速、1.25倍、1.5倍、2倍速这种相对粗粒度的播放器功能它完全够用。还有一个细节setPlaybackRate改变的是AudioTrack输出端的速率它假定你喂进来的数据采样率是固定的这会导致缓冲区消耗速度变快或变慢。倍速很高时数据消化速度变快你需要喂数据更勤快倍速很低时消化速度变慢喂数据太频繁会导致缓冲爆炸。把控制数据速率的水位判断也跟倍速挂钩是经验之谈。6.4 继续扩展音量、淡入淡出和声像讲到这顺手补充一点工程价值同样高的API。AudioTrack的setVolume(float)可以控制播放音量但它是下一帧立刻生效切换太突然会带来破音感。如果你希望声音进入时不突兀可以用系统提供的VolumeShaper类做淡入从API 26开始支持可以设置一个从0到1的波段播放开始时自动应用。立体声播放时如果想调节左右声道比例也就是声道平衡可以通过AudioTrack的setVolume只调节单边声道或者配合android.media.audiofx包里的Equalizer、LoudnessEnhancer等效果器做更多音效。音频底层这套接口风格都是“你愿意折腾就能插很多东西进去”但每个效果器都增加处理延迟实时性要求高的场景要慎重叠加。另外关于中断和恢复任何播放都别忘了处理电话打断、闹钟响起、别的App音播抢占音频焦点这些场景。监听AudioManager.AudioFocusChangeListener在获得和失去焦点时对AudioTrack做pause/continue是产线上必须经历的“最后一公里”。音频焦点虽然不属于AudioTrack API本身但和它一起使用时很多“怎么突然没声了”“怎么放了一下又不放了”的问题都出在这里。最后分享一个我在多个项目里沉淀下来的习惯做法所有AudioTrack播放逻辑统一封装成一个类对外只暴露playFile、pause、resume、stop和进度回调这几个接口内部把AudioTrack生命周期、播放线程、状态管理全部包起来。这样即便后面接入更复杂的播放引擎组件的边界也已经画好了。而且这个类内部每一步都检查返回值、统一释放资源、异常兜底能少出非常多的线上怪问题。这篇文章把AudioTrack播放PCM从数据准备到状态管理、延迟优化、异常排查到进阶扩展过了一遍。核心说白了就三件事格式理解正确、数据输送节奏稳定、生命周期管理严格。把这三点吃透Android这块的音频回放你基本不会有太大障碍了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑