Android 里播声音的"最低层" API。
MediaPlayer/ExoPlayer都在它之上封装了一层,只有当你需要精确控制采样数据、缓冲区大小和播放延迟时,才会直接面对它。
一、它处在音频栈的哪个位置
播放一次声音,数据要走过下面这条链路:
scss
你的 PCM 数据(byte[] / ByteBuffer)
↓ write()
AudioTrack (Java 层)
↓ JNI
AudioFlinger (native 系统服务,负责混音、重采样、变调)
↓
Audio HAL (硬件抽象层)
↓ DMA
Codec / DAC
↓
扬声器
关键认知:AudioFlinger 才是真正"出声"的地方,AudioTrack 只是一个往 Flinger 的共享缓冲区里投喂数据的客户端。
这就导致一个反直觉的事实------
你
write()完的数据,不等于 此刻扬声器发出的声音。中间还隔着 AudioTrack 缓冲区、AudioFlinger 混音缓冲、HAL 缓冲、硬件 DMA 缓冲。这一整条链路的滞后,就是输出延迟。
后面第四节会用公式把这条链路拆开算。
二、两种播放模式
AudioTrack 有两种数据传输模式,选错了后面全是坑。
MODE_STREAM |
MODE_STATIC |
|
|---|---|---|
| 数据怎么进缓冲区 | 边播边写,分批投喂 | 一次性全部写入,之后只播不写 |
| 缓冲区角色 | 流水线中间站 | 完整音频的存储区 |
| 适用场景 | 实时流(麦克风采集回放、网络音频、连续播放) | 短音效、提示音、循环音 |
| 延迟表现 | 较小(可精确控制) | 同样小,但要一次性备齐 |
| 数据写完还能再写吗 | 能,随时 | 不能 ,play() 之后 write() 会返回 ERROR_INVALID_OPERATION |
MODE_STREAM 的写入模型
scss
// 流式播放:数据像水一样持续注入缓冲区
AudioTrack track = new AudioTrack.Builder()
.setAudioFormat(format)
.setTransferMode(AudioTrack.MODE_STREAM)
.build();
track.play(); // 先启动播放管道
// 然后在循环里不断投喂,每次投喂一小块
while (hasMoreData) {
int written = track.write(pcmChunk, 0, pcmChunk.length);
// written < 0 表示出错,written < size 表示只写进去一部分
}
注意顺序 :play() 必须在写入循环之前调用。play() 的作用不是"开始出声",而是"打开 AudioFlinger 的取数据通道"------从这一刻起 Flinger 开始按采样率节奏往缓冲区里取数据。
如果你先写后播,写进去的数据会静静躺在缓冲区里,play() 之后才一次性被消费掉。
MODE_STATIC 的一次性写入
scss
// 静态播放:整段音频先备齐,再开播
AudioTrack track = new AudioTrack.Builder()
.setAudioFormat(format)
.setTransferMode(AudioTrack.MODE_STATIC)
.setBufferSizeInBytes(pcmData.length) // 缓冲区 = 音频全长
.build();
// 关键:未 play() 前写入全部数据
int written = track.write(pcmData, 0, pcmData.length);
if (written < 0) {
throw new IllegalStateException("write 失败: " + written);
}
track.play(); // 现在才开始出声
静态模式下还能设置循环点:
scss
track.setLoopPoints(0, written, -1); // -1 = 无限循环
track.play();
setLoopPoints 必须在 play() 之前调用,且参数单位是帧(frame) 而不是字节。
三、三个必配参数
用 AudioTrack.Builder(API 23+)创建时,有三组参数必须配齐,缺一不可。
3.1 AudioFormat ------ 数据长什么样
AudioFormat 描述的是你的 PCM 数据的物理格式,跟设备最终怎么播无关。
scss
AudioFormat format = new AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT) // 采样编码
.setSampleRate(44100) // 采样率 Hz
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) // 声道布局
.build();
常用编码:
| 常量 | 每样本字节 | 说明 |
|---|---|---|
ENCODING_PCM_8BIT |
1 | 有符号 8 位 |
ENCODING_PCM_16BIT |
2 | 最常用,小端有符号 16 位 |
ENCODING_PCM_FLOAT |
4 | 32 位浮点,范围 ±1.0,兼容性略差 |
ENCODING_PCM_24BIT_PACKED |
3 | 24 位打包 |
ENCODING_PCM_32BIT |
4 | 32 位有符号 |
采样率不要随手填 44100。 设备原生采样率可以通过系统属性拿到:
ini
AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
int nativeRate = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
// 典型值:48000
填的和设备原生不一致时,AudioFlinger 会插入一个重采样器 ------多一层 CPU 开销、多一段延迟、还可能引入轻微失真。最省事、最稳的做法是直接用原生采样率。
3.2 AudioAttributes ------ 这声音要干什么用
AudioAttributes 决定的是系统路由策略:声音走哪个输出设备、是否抢占其他音频、要不要申请音频焦点。
scss
AudioAttributes attributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA) // 用途
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) // 内容类型
.build();
usage 的选择会实质影响路由行为:
| usage | 典型场景 | 系统行为 |
|---|---|---|
USAGE_MEDIA |
音乐、视频 | 走媒体音量,可与游戏/通话混合 |
USAGE_GAME |
游戏音效 | 通常走媒体音量,但优先级高于 MUSIC |
USAGE_NOTIFICATION |
通知提示音 | 会打断音乐播放 |
USAGE_ALARM |
闹钟 | 独占,强制最大音量 |
USAGE_VOICE_COMMUNICATION |
通话 | 切到通话音量,走听筒/免提路由 |
USAGE_ASSISTANCE_ACCESSIBILITY |
无障碍导航 | 独占播放 |
contentType 相对次要(CONTENT_TYPE_MOVIE / MUSIC / SONIFICATION / SPEECH),主要影响混音时的音量平衡策略。
踩坑提醒 :
USAGE_VOICE_COMMUNICATION会强制走听筒/免提。写实时通话或语音助手时如果用错 usage,用户会发现声音"从耳朵里跑出来了"。
3.3 BufferSize ------ 缓冲区开多大
这是决定延迟的核心旋钮,也是最容易配错的地方。
正确做法是问系统要最小值,然后按需求放大:
ini
int minSize = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_STEREO, // 注意:传 channelConfig,不是 channelMask
AudioFormat.ENCODING_PCM_16BIT);
if (minSize <= 0) {
throw new IllegalStateException("getMinBufferSize 失败: " + minSize);
// -1 = ERROR,-2 = ERROR_BAD_VALUE
}
int bufferSize = minSize * 2; // 翻倍换稳定性
getMinBufferSize 返回的是字节数 ,且它算的是"刚好够系统跑起来 "的最小值------这个值在实践中太小,直接用极易欠载。
缓冲区大小的数学
理解延迟必须先分清三个单位:
1 frame(帧) = 一次采样,包含了所有声道
1 frame 的大小 = 声道数 × 每样本字节数
帧大小(每帧字节数):
ini
frameSize = channelCount × bytesPerSample
缓冲区能装多少帧:
ini
bufferFrames = bufferSizeInBytes / frameSize
缓冲区能撑多久(毫秒):
ini
bufferDurationMs = bufferFrames × 1000 / sampleRate
以最常见的配置(48kHz / 立体声 / 16bit)为例:
ini
frameSize = 2 × 2 = 4 字节
bufferSize = minSize × 2 = 4800 字节(假设)
bufferFrames = 4800 / 4 = 1200 帧
bufferDuration = 1200 × 1000 / 48000 = 25 ms
这 25ms 就是你为了"不爆音"必须付出的最低代价。
四、延迟是怎么算出来的
端到端输出延迟是一条串联链:
总延迟 = App 缓冲延迟 + AudioFlinger 混音延迟 + HAL 缓冲延迟 + 硬件 DMA 延迟
只有第一项是你能控制的,其余三项由设备和音频通路决定。
其中 App 缓冲延迟又可以细分为两部分:
yaml
App 缓冲延迟 = 缓冲区总时长 - 已消费时长
= bufferFrames × 1000 / sampleRate - (当前已播放帧数 / sampleRate) × 1000
更实用的写法是用 getTimestamp() 精确测量------它返回的是硬件时钟对齐过的播放位置和时刻,比自己算准得多:
ini
AudioTimestamp ts = new AudioTimestamp();
int result = track.getTimestamp(ts); // API 24+,返回 0 表示成功
if (result == 0) {
// ts.framePosition:这一帧对应的播放位置(帧)
// ts.nanoTime: 这一帧被播放的硬件时刻(纳秒)
long frames = ts.framePosition;
long nanoTime = ts.nanoTime;
}
getTimestamp 的意义在于:它给出的 nanoTime 是音频硬件时钟 ,和 System.nanoTime()(系统时钟)之间存在固定偏移和抖动。用它做对齐,可以消除调度抖动带来的误差。
延迟测量(用系统时钟近似):
ini
long framePosition = ts.framePosition;
long nanoTime = ts.nanoTime;
// 从这一帧播完,到"现在",还剩多少时间
long pendingFrames = track.getPlaybackHeadPosition() - framePosition;
double latencyMs = pendingFrames * 1000.0 / sampleRate;
getPlaybackHeadPosition() 的 32 位回绕陷阱
这是个非常隐蔽的坑。getPlaybackHeadPosition() 返回的是 int,本质是 32 位无符号的帧计数器:
csharp
public int getPlaybackHeadPosition();
当播放超过约 24.8 小时 (48000Hz 采样率下,2^32 / 48000 ≈ 89478 秒),计数器会溢出回 0。
正确处理方式:
ini
// 把 int 当无符号数解读
long playedFrames = track.getPlaybackHeadPosition() & 0xFFFFFFFFL;
如果你的应用需要长时间播放(比如会议通话、DJ 类的实时混音),必须自己做溢出补偿 :记录上一次的读数,发现新值小于旧值说明回绕了,累加一个 2^32。
五、完整实战示例
一个可用于生产的流式播放器封装:
ini
public class PcmPlayer {
private static final String TAG = "PcmPlayer";
private static final int SAMPLE_RATE = 48000;
private static final int CHANNEL_COUNT = 2;
private static final int BYTES_PER_SAMPLE = 2; // PCM_16BIT
private static final int FRAME_SIZE = CHANNEL_COUNT * BYTES_PER_SAMPLE;
private AudioTrack audioTrack;
private Thread writeThread;
private volatile boolean isRunning = false;
private final BlockingQueue<byte[]> queue = new LinkedBlockingQueue<>();
/** 初始化 AudioTrack,失败抛异常 */
public void init() throws IllegalStateException {
int minSize = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT);
if (minSize <= 0) {
throw new IllegalStateException("getMinBufferSize 失败: " + minSize);
}
AudioFormat format = new AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build();
AudioAttributes attributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build();
audioTrack = new AudioTrack.Builder()
.setAudioAttributes(attributes)
.setAudioFormat(format)
.setBufferSizeInBytes(minSize * 2)
.setTransferMode(AudioTrack.MODE_STREAM)
.build();
if (audioTrack.getState() != AudioTrack.STATE_INITIALIZED) {
audioTrack.release();
audioTrack = null;
throw new IllegalStateException("AudioTrack 初始化失败");
}
}
/** 启动播放线程 */
public void start() {
if (audioTrack == null || isRunning) {
return;
}
isRunning = true;
audioTrack.play();
writeThread = new Thread(this::writeLoop, "PcmWriter");
// 音频线程高优先级,避免被 UI 任务抢占
writeThread.setPriority(Thread.MAX_PRIORITY);
writeThread.start();
}
/** 写入线程主循环 */
private void writeLoop() {
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO);
while (isRunning) {
byte[] chunk;
try {
// 阻塞取数据:生产者和消费者解耦,写入线程不会被 IO 拖住
chunk = queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
int offset = 0;
while (offset < chunk.length && isRunning) {
int written = audioTrack.write(chunk, offset, chunk.length - offset);
if (written < 0) {
Log.e(TAG, "write 失败,错误码: " + written);
isRunning = false;
break;
}
offset += written;
}
}
}
/** 外部投递音频数据 */
public void enqueue(byte[] pcm) {
queue.offer(pcm);
}
/** 停止并释放 */
public void release() {
isRunning = false;
if (writeThread != null) {
writeThread.interrupt();
writeThread = null;
}
if (audioTrack != null) {
audioTrack.stop();
audioTrack.release();
audioTrack = null;
}
queue.clear();
}
}
使用:
ini
PcmPlayer player = new PcmPlayer();
player.init();
player.start();
byte[] pcm = readPcmFromSomewhere(); // 你的数据源
player.enqueue(pcm);
// ... 播放结束
player.release();
这个结构解决了两件事:写入永不阻塞在主线程 (数据先入队),生产与消费解耦 (BlockingQueue),从根上消除了欠载的主要成因。
六、AudioTrack vs 其他播放方案
| 方案 | 适用场景 | 延迟 | 能控制采样数据 |
|---|---|---|---|
AudioTrack |
实时 PCM、自定义处理、低延迟 | 低 | ✅ 完全控制 |
MediaPlayer |
播本地/网络文件 | 中 | ❌ |
ExoPlayer |
播格式复杂的媒体流 | 中 | ❌ |
SoundPool |
短音效并发播放(枪声、按钮) | 极低 | ⚠️ 只能读 wav 资源 |
AAudio(NDK) |
极致低延迟 | 最低 | ✅ |
选型一句话:要控制数据就 AudioTrack,要播文件就 ExoPlayer,要音效就 SoundPool,要极限延迟就上 AAudio。
七、小结
- 两种模式 :
MODE_STREAM边播边写(实时流),MODE_STATIC一次写完(短音效)。选错会导致write()直接失败。 - 三个必配参数 :
AudioFormat(数据格式,采样率务必对齐设备原生)、AudioAttributes(用途,决定路由)、BufferSize(延迟核心)。 - 延迟公式 :
bufferDurationMs = bufferSizeInBytes / frameSize × 1000 / sampleRate,这是你能控制的最小延迟下限。 - 工程范式:专用高优先级线程 + 阻塞队列解耦生产消费,是 AudioTrack 稳定输出的标准结构。
- 一个隐蔽细节 :
getPlaybackHeadPosition()是 32 位无符号帧计数,长时间播放会回绕,需自行补偿溢出。
本文代码中所有
write()都做了返回值检查,所有getMinBufferSize()都做了失败校验------这两处是线上音频问题最集中的地方。