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这块的音频回放你基本不会有太大障碍了。

相关新闻

Cifar-10 数据集深度解析:从加载陷阱到 CNN 鲁棒性验证

Cifar-10 数据集深度解析:从加载陷阱到 CNN 鲁棒性验证

简介:Cifar-10数据集是计算机视觉入门与CNN模型实践的核心基准资源,面向深度学习初学者、算法工程师及高校课程实验者,用于快速构建、训练与评估图像分类模型。本zip包共8个文件,含5个训练批次(data_batch_1至5&#x…

2026/10/13 13:40:23 阅读更多 →
Claude Code 实战指南:从零构建轻量级代码审查工具

Claude Code 实战指南:从零构建轻量级代码审查工具

简介:本资源是面向软件开发者的 Claude Code CLI 命令行工具权威使用手册源码包,专为希望高效管理代码、提升终端开发效率的中高级开发者设计。手册系统覆盖快速入门、常用命令详解、Skill 创建与调用技巧、多文件编辑与代码分析、配置文件定制&#xff…

2026/10/13 13:40:23 阅读更多 →
SystemView.zip解压安装全攻略:识别、校验与伪加密排查

SystemView.zip解压安装全攻略:识别、校验与伪加密排查

简介:SystemView是一款面向ARM及单片机开发者的嵌入式实时分析工具,专注于CPU活动监控、任务调度和中断处理的可视化调试。包内收录Windows可直接运行的EXE及相关DLL,并包含C/H源码示例、txt说明文档、SVDat运行记录数据、补丁、PDF用户手册等…

2026/10/13 13:39:19 阅读更多 →

最新新闻

SPWM三相六路互补输出与调频调压:死区、载波比与V/F控制详解

SPWM三相六路互补输出与调频调压:死区、载波比与V/F控制详解

简介:面向STM32F103C8T6三相电机驱动与变频控制场景,该资源提供基于高级定时器TIM1查表法生成三相六路互补SPWM的完整工程。代码生成相位互差120的六路PWM,可配置死区时间,并通过恒压频比策略实现调频调压,适用于逆变器…

2026/10/13 14:23:19 阅读更多 →
西门子S7-1200 PLC机器人码垛与AGV调度系统实战解析

西门子S7-1200 PLC机器人码垛与AGV调度系统实战解析

先交代一下背景。这套项目是某食品企业码垛工段的自动化改造,原来的方案是人工作业加一台老旧机械臂,产能跟不上、垛形不稳定,客户一口气提了三个需求:自动码垛、空满垛转运、AGV自动接驳。设备清单下来的时候列的是两台西门子S7-…

2026/10/13 14:23:19 阅读更多 →
健康管理系统PRD拆解:从角色权限到数据字段的落地指南

健康管理系统PRD拆解:从角色权限到数据字段的落地指南

简介:《传智健康PRD文档1》是一份面向健康管理系统的产品需求说明书,适合产品经理、研发与测试人员梳理业务逻辑、明确功能边界。文档围绕数据结构管理展开,对健康评估指标进行规范化定义:涵盖序号、指标名称、参考值、单位、针对…

2026/10/13 14:23:19 阅读更多 →
解决方案架构设计实战:从业务意图到技术决策

解决方案架构设计实战:从业务意图到技术决策

做解决方案架构设计的人,大多是从软件开发、系统设计或者技术管理岗转过来的。刚开始接触“解决方案”这三个字,很容易把它理解成“画一张架构图、定几个技术选型、然后写个文档”。但真正经历过几个大型项目之后会发现,解决方案架构设计的核…

2026/10/13 14:23:19 阅读更多 →
从上下文窗口到MCP:Agent记忆与工具调用的设计实战

从上下文窗口到MCP:Agent记忆与工具调用的设计实战

最近跟几个做 Agent 的朋友聊了一圈,发现大家几乎都在同一个地方卡住:上下文窗口。很多人刚开始都觉得,8k、32k、甚至 128k 的上下文已经很宽裕了,可一旦真正跑长期对话、接上三五个工具、还要记住用户偏好,窗口里的东…

2026/10/13 14:23:19 阅读更多 →
MT9M001C全局快门CMOS驱动LCD实战:FPGA时序控制与信号链调试

MT9M001C全局快门CMOS驱动LCD实战:FPGA时序控制与信号链调试

简介:本资源是面向FPGA初学者与嵌入式图像处理学习者的实战项目,聚焦于MT9M001 CMOS图像传感器的驱动与实时显示实现,适用于基于Altera/Intel FPGA的开发板平台。项目完整呈现了从传感器数据采集、FPGA逻辑控制(含时序生成、像素流…

2026/10/13 14:22:18 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 21:05:09 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →