
1. 播放延迟到底在测什么从音频链路拆起很多人第一次接触 Android 音频延迟脑子里冒出来的第一个念头是我调一下getLatency()不就完了。真到项目里跑一圈才发现这个数跟耳朵听到的完全对不上。问题出在延迟不是一个点而是一整条链路上每一段耗时的叠加。你要测的到底是哪一段决定了你该用哪个 API、该在什么时机读、读出来的数能不能信。先把链路捋清楚。一次AudioTrack.write()调用数据从应用层出发大致要经过这么几站应用缓冲区你 write 进去的数据先落在 AudioTrack 自己的缓冲里这部分大小由bufferSizeInBytes决定。共享内存与 AudioFlinger数据被搬到客户端与服务端共享的环形缓冲区AudioFlinger 的混音线程按周期取走。混音与重采样如果采样率、声道数和输出设备不一致这里会有重采样开销。HAL 与驱动数据最终交给硬件抽象层再进 DSP 或编解码器。硬件缓冲与扬声器DAC 之前还有一段硬件 FIFO最后才是物理发声。所以播放延迟在工程语境里通常指从你写入一帧数据到这帧数据真正从扬声器发出来之间的时间。它包含了软件缓冲、混音周期、硬件缓冲三大部分。而系统 API 给你的那个getLatency()多数情况下只反映了其中一部分——通常是 HAL 上报的、偏硬件侧的那段或者干脆是个估算值。这就解释了一个非常常见的困惑为什么同一个设备用AudioTrack.getLatency()读出来是 20ms但实际拿录音回环测出来是 120ms因为前者没算上你应用层缓冲和混音周期带来的排队时间。你写的缓冲区越大排队越久这部分延迟 API 是不管的。我在实际项目里踩过最典型的一个坑做实时耳返功能一开始死盯着getLatency()调参数怎么调都觉得系统说延迟很低但体验就是有拖尾。后来才反应过来真正影响体验的是端到端延迟而端到端延迟里应用缓冲占了大头。把bufferSizeInBytes从默认值砍到接近最小值之后体感立刻不一样了。这件事让我彻底明白测延迟之前先想清楚你要测的是哪一段延迟。下面这张表把几个容易混淆的概念摆在一起后面所有讨论都基于这个划分概念覆盖范围典型获取方式量级参考API 上报延迟HAL/硬件侧为主AudioTrack.getLatency()10~40ms应用缓冲延迟应用缓冲区排队由 bufferSize 与采样率算可变几十到几百 ms端到端延迟写入到发声全链路回环测量通常最大往返延迟播放录音全链路回环测量端到端的两倍左右把这张表记住后面遇到任何延迟对不上的问题先回来对一下你测的是哪一行。2. 系统给的延迟 API 各自能信几分Android 从早期版本到现在跟延迟相关的 API 换过好几茬名字相似但含义差别很大。如果不搞清楚每个 API 的语义边界很容易拿一个看起来对的数去下结论最后被现实打脸。2.1 AudioTrack.getLatency() 的真实含义AudioTrack.getLatency()返回的是当前 AudioTrack 实例所关联输出路径的估计延迟单位是毫秒。它的值来自 AudioFlinger 向 HAL 查询后上报的结果本质上是这段音频从离开 AudioFlinger 到硬件发声的估计。关键点在于估计两个字。不同厂商的 HAL 实现质量参差不齐有的老老实实按硬件 FIFO 和混音周期算有的直接返回一个固定常数。我见过某些设备上这个值常年稳定在某个整数不动那基本就是写死的参考价值有限。还有一个时机问题必须在 AudioTrack 进入 PLAYSTATE_PLAYING 之后再读。在 STOPPED 或 PAUSED 状态下读很多实现会返回 0 或者一个无意义的值。这个细节文档里写得含糊但实测非常明显。AudioTrack track new AudioTrack.Builder() .setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build()) .setAudioFormat(new AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(48000) .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) .build()) .setBufferSizeInBytes(minBufferSize) .setTransferMode(AudioTrack.MODE_STREAM) .build(); track.play(); // 等状态真正切到 PLAYING 再读 int latencyMs track.getLatency();2.2 AudioManager 上的属性查询AudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)和PROPERTY_OUTPUT_SAMPLE_RATE这两个属性是很多人忽略但极其有用的。它们告诉你系统混音线程一次处理多少帧、以什么采样率处理。为什么这两个值重要因为混音周期直接决定了最小排队延迟。如果系统一次处理 256 帧、采样率 48000那么一个混音周期的时长就是 256/48000 ≈ 5.33ms。你的数据至少要等一个周期才会被取走这就是延迟的下限之一。AudioManager am (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); String framesPerBuffer am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER); String sampleRate am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);这两个值在计算理论最小缓冲时是核心输入后面第 3 节会用到。2.3 AudioTimestamp更接近真相的时间戳如果你的目标平台是 Android 6.0 及以上AudioTrack.getTimestamp(AudioTimestamp)是比getLatency()靠谱得多的工具。它返回两个字段framePosition已经播放到的帧位置和nanoTime对应的系统时间戳。它的价值在于你可以通过连续两次采样反推出真实的播放速率和当前播放头位置进而算出我刚写进去的数据还要多久才播到。这比一个静态的延迟估计值有用得多因为它反映的是动态的真实状态。AudioTimestamp ts new AudioTimestamp(); if (track.getTimestamp(ts) AudioTrack.SUCCESS) { long playedFrames ts.framePosition; long nanoTime ts.nanoTime; // 结合当前写入的总帧数可以估算排队深度 }注意getTimestamp()不是所有设备都返回 SUCCESS。有些实现会返回错误码这时候要能优雅降级到getLatency()方案别让功能直接崩掉。2.4 三个 API 的取舍逻辑把这三个工具放一起选择逻辑其实很清晰只要一个粗略参考、做 UI 展示getLatency()够用。要算理论最小缓冲、做参数配置AudioManager属性是必读的。要做精确的播放进度同步、动态延迟补偿AudioTimestamp是唯一选择。我个人的习惯是三个都读互相交叉验证。如果getLatency()和用AudioTimestamp推算出来的值差得离谱那基本可以判定这台设备的 HAL 上报不可信后续逻辑要以时间戳为准。3. 用缓冲区参数反推理论延迟系统 API 不一定可信但有一类延迟是可以精确算出来的那就是应用层缓冲带来的排队延迟。这部分完全由你自己控制算清楚了至少能把可控的那一半延迟拿捏住。3.1 最小缓冲区的计算AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding)返回的是系统认为能稳定播放的最小缓冲字节数。注意它的语义这是不欠载的下限不是延迟最优值。很多教程直接拿它当 bufferSize 用结果延迟还是高因为系统给的这个值往往偏保守。计算逻辑大致是minBufferBytes framesPerBuffer × channelCount × bytesPerSample × 安全系数其中framesPerBuffer来自前面读到的PROPERTY_OUTPUT_FRAMES_PER_BUFFER安全系数通常取 2 左右。举个例子48000 采样率、立体声、16bitframesPerBuffer 是 256每帧字节数 2 声道 × 2 字节 4 字节一个混音周期 256 帧 × 4 字节 1024 字节取 2 倍安全系数 2048 字节而getMinBufferSize()在某些设备上会返回 4096 甚至更大。这中间的差值就是你白白多付出的延迟。3.2 缓冲延迟的换算有了 bufferSize换算成时间很简单缓冲延迟(ms) bufferSizeInBytes / (sampleRate × channelCount × bytesPerSample) × 1000拿上面 2048 字节的例子2048 / (48000 × 2 × 2) × 1000 ≈ 10.67ms。也就是说光应用缓冲这一层就贡献了约 10ms 的排队延迟。如果 bufferSize 是 8192那就是 42ms 以上差距非常可观。这里有个反直觉的点缓冲区不是越小越好。太小会导致欠载underrun声音出现咔哒声或断断续续。你要在延迟和稳定性之间找平衡点。我的经验是先从getMinBufferSize()的 1/2 开始试逐步往下压直到出现欠载再回退一档。3.3 把各段延迟拼成一张账现在可以把可控部分和不可控部分拼起来了延迟来源是否可控估算方式应用缓冲排队可控bufferSize 换算混音周期半可控framesPerBuffer / sampleRateHAL 硬件缓冲不可控getLatency() 或时间戳推算驱动与 DAC不可控通常包含在 HAL 上报里理论端到端延迟 ≈ 应用缓冲 混音周期 HAL 上报。这个公式不精确但足够你判断延迟大头在哪。如果算出来应用缓冲占了一大半那优化方向就很明确了——压 bufferSize而不是去跟系统 API 较劲。提示不同设备的framesPerBuffer差异很大从 128 到 1024 都有。做跨设备适配时这个值必须动态读取不能写死。4. 回环测量拿到真实端到端延迟理论算得再漂亮也只是估算。要拿到真实的端到端延迟唯一靠谱的办法是回环测量播放一段已知信号同时录音通过信号对齐算出时间差。这是音频工程里的标准做法也是我最终在项目里落地采用的方案。4.1 回环测量的基本原理思路很朴素播放一个特征明显的信号比如一段短促的脉冲、或者一段扫频 chirp用麦克风录下来然后在录音数据里找到这个信号出现的位置。播放时刻和检测到信号时刻之间的差就是端到端延迟。难点在于找到信号出现的位置要足够精确。用简单的能量阈值检测精度只能到几十毫秒不够用。更好的办法是互相关把原始播放信号和录音信号做互相关运算相关峰的位置就对应延迟。4.2 信号选择与互相关实现信号选择上有讲究。短脉冲能量集中但容易被噪声淹没扫频信号chirp抗噪好、相关峰尖锐是更稳妥的选择。我一般用线性调频从 1kHz 扫到 8kHz时长 50ms 左右。互相关的核心代码大致是这样import numpy as np def estimate_latency(play_signal, record_signal, sample_rate): # 归一化避免幅度差异影响 p play_signal / (np.max(np.abs(play_signal)) 1e-9) r record_signal / (np.max(np.abs(record_signal)) 1e-9) # 互相关 corr np.correlate(r, p, modefull) peak_index np.argmax(np.abs(corr)) # 换算成时间偏移 lag_samples peak_index - (len(p) - 1) latency_ms lag_samples / sample_rate * 1000 return latency_ms这段逻辑不复杂但有几个坑要避开。第一录音信号里除了你要找的 chirp还有环境噪声和其他声音互相关之前最好做带通滤波把能量集中在 chirp 的频段内。第二np.correlate在长信号上很慢实际工程里用 FFT 加速的版本。第三采样率必须和播放、录音一致否则换算会错。4.3 测量流程的完整步骤把整个测量流程串起来大概是这么几步准备信号生成一段 chirp存成 PCM 或 WAV。播放用 AudioTrack 播放同时记录播放开始的系统时间戳。录音用 AudioRecord 同步开始录音录足够长的时长覆盖延迟上限。对齐对录音数据做互相关找到 chirp 位置。换算位置差除以采样率得到延迟。重复多次测量取中位数剔除异常值。这里有个容易被忽略的细节播放和录音的启动本身有时间差。AudioTrack 的play()返回不代表声音立刻发出AudioRecord 的startRecording()同理。所以严格来说你测出来的是播放启动到录音检测到信号的总时间包含了两个启动开销。要更精确得用AudioTimestamp分别校准播放和录音的启动时刻。4.4 实测数据的解读我在几台不同设备上跑过这套流程结果差异很大。同一段代码有的设备端到端延迟 60ms 左右有的能到 200ms 以上。把数据拆开看延迟大的设备往往是framesPerBuffer大、或者 HAL 上报的硬件缓冲大。一个有意思的现象延迟的稳定性比绝对值更重要。有的设备平均延迟 80ms但抖动只有 ±3ms有的平均 60ms抖动却有 ±30ms。对于实时交互类应用后者体验反而更差因为抖动会让声音忽快忽慢。所以测量时不能只看平均值标准差和最大最小值同样要记录。设备类型平均延迟抖动主要瓶颈中端机型 A65ms±4ms应用缓冲中端机型 B90ms±8ms混音周期低端机型 C180ms±25msHAL 硬件缓冲这张表是示意但规律是真实的低端设备的延迟大头往往在不可控的 HAL 层这时候再怎么优化应用层也收效有限得换思路比如改用低延迟音频路径。5. 低延迟路径与性能模式怎么选Android 从 8.0 开始引入了低延迟音频的相关支持配合AudioAttributes和性能模式能把延迟压下来一截。但这些东西不是无脑开就完事用错了反而出问题。5.1 性能模式的适用场景AudioTrack.Builder.setPerformanceMode()有两个值PERFORMANCE_MODE_NONE和PERFORMANCE_MODE_LOW_LATENCY。后者会告诉系统我很在意延迟系统会尽量走优化路径。但低延迟模式有代价它要求 bufferSize 足够小且对欠载更敏感。如果你的应用本身对稳定性要求高比如长时间播放音乐开低延迟模式可能得不偿失。我的判断标准是交互式场景耳返、乐器、游戏音效开纯播放场景不开。AudioTrack track new AudioTrack.Builder() .setAudioAttributes(attrs) .setAudioFormat(format) .setBufferSizeInBytes(minBufferSize) .setTransferMode(AudioTrack.MODE_STREAM) .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) .build();5.2 采样率对齐省掉重采样一个经常被忽视的延迟来源是重采样。如果你的 AudioTrack 采样率和系统输出采样率不一致AudioFlinger 会插入重采样环节既增加延迟又增加 CPU 开销。解决办法很简单用PROPERTY_OUTPUT_SAMPLE_RATE读到的系统采样率来配置你的 AudioTrack。这样数据从应用层到 HAL 全程同采样率重采样环节直接省掉。实测下来这一步能省掉几毫秒到十几毫秒不等而且几乎零成本。同理声道数也尽量和输出设备对齐。立体声输出就用立体声别用单声道让系统去转换。5.3 独占模式与 MMAP 的边界再往深了走还有 AAudio 和 MMAP 这类更底层的路径。AAudio 是 Android 8.0 引入的原生音频 API设计目标就是低延迟配合 MMAP 模式可以绕过部分混音环节把延迟压到接近硬件极限。但这条路的门槛不低需要 NDK 开发设备支持情况参差调试也更麻烦。我的建议是先用 Java 层的低延迟模式把延迟压到可接受范围实在不够再考虑 AAudio。很多项目其实到不了需要 MMAP 的程度过早引入只会增加复杂度。注意AAudio 和 MMAP 的支持情况因设备而异上线前必须做设备兼容性测试准备好降级方案。6. 那些文档不会告诉你的实测坑前面讲的都是方法论这一节专门讲我在实际项目里踩过的坑。这些东西文档里基本不会写但每一个都能让你多花好几天。6.1 getLatency 在暂停状态下返回 0这个坑我踩过不止一次。在 AudioTrack 还没play()或者已经pause()的时候调getLatency()很多设备返回 0。如果你在初始化阶段就读这个值存起来备用存进去的就是个 0后面所有基于它的计算全错。正确做法是在 PLAYING 状态下读并且每次需要时重新读不要缓存。延迟值在播放过程中可能变化比如系统负载变化导致混音周期调整缓存反而会引入误差。6.2 蓝牙设备的延迟是另一个世界只要输出设备是蓝牙前面所有关于延迟的讨论基本都要推翻重来。蓝牙音频的延迟通常在 150ms 到 300ms 之间取决于编解码器和设备实现而且系统 API 上报的值往往严重偏低。如果你的应用要支持蓝牙输出必须单独做延迟补偿不能沿用有线设备的参数。而且蓝牙延迟还会随信号质量波动做精确同步几乎不可能只能做粗粒度补偿。这一点在需求评审阶段就要跟产品说清楚别等做完了才发现体验达不到预期。6.3 首次播放的延迟总是偏高有没有发现同一个 AudioTrack第一次播放的延迟总比后续播放高这不是错觉。首次播放时系统要完成音频路径的建立、混音线程的唤醒、HAL 的初始化这些一次性开销都会算进首次延迟里。所以做延迟测量时一定要丢弃前几次的数据等系统进入稳定状态再采。我一般会先播放几秒热身然后再开始正式测量。如果拿首次数据当基准会得出偏高的结论误导后续优化方向。6.4 测量环境的噪声会污染结果回环测量对环境很敏感。如果测试环境有持续的背景噪声互相关的峰值可能被干扰导致延迟估计偏差。更麻烦的是如果噪声恰好和 chirp 频段重叠相关峰甚至会跑偏。我的做法是测量时尽量在安静环境录音前先采一段背景噪声做参考互相关时对录音信号做带通滤波只保留 chirp 频段。另外多次测量取中位数而不是平均值因为异常值比如某次测量被突发噪声干扰会把平均值拉偏中位数更稳健。6.5 不同 Android 版本的 API 行为差异getTimestamp()在 Android 6.0 引入但早期版本的行为和现在差别不小。有些老版本上它返回的时间戳精度不够或者 framePosition 更新不及时。做兼容时要根据 Build.VERSION 做分支处理老版本降级到getLatency()方案。还有setPerformanceMode()是 Android 8.0 才有的低版本调用会直接抛异常或者被忽略。这些版本边界必须在代码里显式处理不能想当然。7. 把延迟测量做成可复用的工具零散的测量代码用一次就扔下次还得重写。更好的做法是把它封装成一个可复用的工具项目里随时能调。这一节讲讲我在实际项目里是怎么组织的。7.1 工具类的接口设计核心接口就两个方法一个测当前配置下的端到端延迟一个给出推荐的 bufferSize。前者用于验证后者用于配置。public class AudioLatencyProbe { // 测量端到端延迟返回毫秒 public float measureEndToEndLatency(int sampleRate, int channelCount); // 根据系统属性推荐最小可用 bufferSize public int recommendBufferSize(int sampleRate, int channelCount, int encoding); // 读取系统上报的延迟做交叉验证 public int readSystemLatency(AudioTrack track); }接口简单但内部要处理前面提到的所有细节状态检查、多次采样、异常剔除、版本兼容。7.2 测量结果的置信度评估光返回一个数字不够还得告诉调用方这个数字可不可信。我的做法是返回一个包含延迟值、抖动、样本数的结构体调用方根据抖动决定要不要采信。public class LatencyResult { public float medianMs; // 中位数延迟 public float jitterMs; // 抖动标准差 public int sampleCount; // 有效样本数 public boolean reliable; // 置信度标记 }判定reliable的逻辑样本数够多比如 10 次以上、抖动在合理范围比如小于中位数的 20%、没有大量异常值。不满足就标记为不可信提示调用方重测或降级处理。7.3 集成到 CI 的可行性延迟这种指标最好能纳入持续集成每次改动都跑一遍防止优化被回退。但音频测量依赖真实硬件CI 环境往往是无声卡的服务器跑不了。折中方案是在真机测试环节跑延迟测量把结果上报到测试平台人工或脚本对比历史数据。如果某次改动导致延迟明显上升就能及时发现。这套流程搭起来有点麻烦但对于延迟敏感的项目长期收益很大。7.4 一个完整的调用示例把工具用起来大概是这样AudioLatencyProbe probe new AudioLatencyProbe(context); int sampleRate probe.getSystemSampleRate(); int channelCount 2; // 先拿推荐缓冲 int bufferSize probe.recommendBufferSize(sampleRate, channelCount, AudioFormat.ENCODING_PCM_16BIT); // 用这个缓冲建 AudioTrack播放热身 // ... 播放几秒 ... // 测端到端延迟 LatencyResult result probe.measureEndToEndLatency(sampleRate, channelCount); if (result.reliable) { Log.i(Latency, 端到端延迟中位数: result.medianMs ms, 抖动: result.jitterMs); } else { Log.w(Latency, 测量结果不可信样本数: result.sampleCount); }这套东西我在两个项目里用过基本能覆盖大部分延迟测量需求。真正花时间的不是写代码而是理解每一段延迟的来源、知道哪个数能信哪个不能信。把前面几节的原理吃透代码本身其实不复杂。最后分享一个我自己的判断习惯任何延迟数字如果不能用两种独立方法交叉验证我就不信它。系统 API 给一个回环测量给一个两个对得上才敢用。对不上的时候回环测量通常更接近真相因为它是物理事实而 API 上报可能只是估算。这个习惯帮我避开了不少看起来很美的坑。