AudioTrack 深度解析:从原理到实战

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。


七、小结

  1. 两种模式 :MODE_STREAM 边播边写(实时流),MODE_STATIC 一次写完(短音效)。选错会导致 write() 直接失败。
  2. 三个必配参数 :AudioFormat(数据格式,采样率务必对齐设备原生)、AudioAttributes(用途,决定路由)、BufferSize(延迟核心)。
  3. 延迟公式 :bufferDurationMs = bufferSizeInBytes / frameSize × 1000 / sampleRate,这是你能控制的最小延迟下限。
  4. 工程范式:专用高优先级线程 + 阻塞队列解耦生产消费,是 AudioTrack 稳定输出的标准结构。
  5. 一个隐蔽细节 :getPlaybackHeadPosition() 是 32 位无符号帧计数,长时间播放会回绕,需自行补偿溢出。

本文代码中所有 write() 都做了返回值检查,所有 getMinBufferSize() 都做了失败校验------这两处是线上音频问题最集中的地方。

相关推荐
红尘伴蝶舞21 分钟前
从秒开优化到连环崩溃:深入剖析 ViewPager2 在常驻 DialogFragment 中的复用陷阱与自愈方案
android·kotlin
斯维赤28 分钟前
限流、熔断、降级,你真的分得清吗?
java·后端·架构
aiks_yuan3 小时前
Java CPU 100% 怎么排查:JDK 11 起 jstack 自带线程 CPU 时间,不用再 top -H 换算十六进制(JDK 8、11、17 实测)
java
Flynn Liang5 小时前
AiServices 源码级拆解:一个接口如何变成 RAG Agent
java·langchain·源码分析·rag·langchain4j·aiservices
Flynn Liang5 小时前
LangChain4j 1.23 架构全景:Java 大模型应用的 6 层抽象
java·langchain·大模型·rag·ai agent·langchain4j
巫山老妖5 小时前
Android 应用架构演进:MVC / MVP / MVVM / MVI (2026)
android·android jetpack
聪明的笨猪猪5 小时前
实际工程问题:AI生成Redis分布式锁的原子性缺陷导致库存超卖
java·经验分享·面试·ai编程
软件技术NINI6 小时前
基于SpringBoot和Vue的新能源汽车销售推荐系统
java·vue.js·spring boot·后端·汽车·毕业设计·毕设
Flynn Liang7 小时前
LangChain4j 生产级工程骨架:12 个模块 + Docker 一键启动
java·spring boot·langchain·rag·langchain4j