25、提取并保存为WAV:将AAC解码后保存为PCM/WAV格式

好,咱们继续往前走。上一节我们把AAC解码成了原始的PCM数据,但PCM这玩意儿说白了就是一堆裸数据,没有文件头,播放器根本不认。你想想看,你辛辛苦苦把音频流提出来、解码好,结果存成一个没有格式的文件,那不等于白干了吗?

所以这一节,我们就来解决这个问题——把解码后的PCM数据封装成WAV格式。WAV是Windows上最常见的无损音频格式,它的结构其实很简单:一个文件头 + PCM数据。我们手动拼一个WAV头,然后把PCM数据写进去,就完事了。

核心思路:WAV = 44字节文件头 + PCM裸数据。我们只需要构造好这44个字节,剩下的就是复制粘贴。

WAV文件结构速览

WAV文件头是RIFF格式的一种变体。我刚开始接触的时候也觉得挺玄乎,后来拆开一看,其实就是几个固定字段拼在一起。下面这张图帮你一眼看明白:

WAV 文件结构 RIFF 块 ChunkID="RIFF" (4B) | ChunkSize (4B) | Format="WAVE" (4B) fmt 子块 Subchunk1ID="fmt " (4B) | Subchunk1Size=16 (4B) AudioFormat=1(PCM) (2B) | NumChannels (2B) | SampleRate (4B) ByteRate (4B) | BlockAlign (2B) | BitsPerSample (2B) data 子块 Subchunk2ID="data" (4B) | Subchunk2Size (4B) | PCM数据 (N字节) ← 44字节文件头

说白了,WAV头就是三个块拼在一起:RIFF主块、fmt子块、data子块。每个块都有固定的字节数和字段顺序。你只要按照规范填好这些字段,任何播放器都能正确识别。

手动构造WAV头

我个人习惯用ByteBuffer来拼二进制数据,清晰又不容易出错。下面这个函数,你传入声道数、采样率、位深和PCM数据长度,它就能给你返回一个完整的WAV文件头字节数组。

public static byte[] createWavHeader(int numChannels, int sampleRate, 
                                      int bitsPerSample, long dataSize) {
    int byteRate = sampleRate * numChannels * bitsPerSample / 8;
    int blockAlign = numChannels * bitsPerSample / 8;
    long chunkSize = 36 + dataSize;
    
    ByteBuffer buffer = ByteBuffer.allocate(44);
    buffer.order(ByteOrder.LITTLE_ENDIAN);
    
    // RIFF 块
    buffer.put("RIFF".getBytes());          // ChunkID
    buffer.putInt((int) chunkSize);         // ChunkSize
    buffer.put("WAVE".getBytes());          // Format
    
    // fmt 子块
    buffer.put("fmt ".getBytes());          // Subchunk1ID
    buffer.putInt(16);                      // Subchunk1Size (PCM固定16)
    buffer.putShort((short) 1);             // AudioFormat (1=PCM)
    buffer.putShort((short) numChannels);   // NumChannels
    buffer.putInt(sampleRate);              // SampleRate
    buffer.putInt(byteRate);                // ByteRate
    buffer.putShort((short) blockAlign);    // BlockAlign
    buffer.putShort((short) bitsPerSample); // BitsPerSample
    
    // data 子块
    buffer.put("data".getBytes());          // Subchunk2ID
    buffer.putInt((int) dataSize);          // Subchunk2Size
    
    return buffer.array();
}

小提示:WAV文件头所有多字节字段都是小端序(Little-Endian),别忘了设置ByteOrder。我曾经见过有人用默认的大端序,结果生成的WAV文件播放出来全是噪音,排查了半天才发现是字节序的问题。

完整的保存流程

有了WAV头生成函数,剩下的工作就很简单了。我们把上一节解码出来的PCM数据,加上这个头,一起写入文件。下面是一个完整的示例:

public void saveAsWav(String outputPath, byte[] pcmData, 
                       int numChannels, int sampleRate, int bitsPerSample) 
                       throws IOException {
    // 1. 生成WAV头
    byte[] header = createWavHeader(numChannels, sampleRate, 
                                     bitsPerSample, pcmData.length);
    
    // 2. 写入文件
    try (FileOutputStream fos = new FileOutputStream(outputPath);
         BufferedOutputStream bos = new BufferedOutputStream(fos)) {
        // 先写头
        bos.write(header);
        // 再写PCM数据
        bos.write(pcmData);
        bos.flush();
    }
    
    Log.d("WavSaver", "WAV文件已保存: " + outputPath);
}

嗯,这里要注意一点:PCM数据长度可能会很大,尤其是长时间录音。我建议用BufferedOutputStream包装一下,避免频繁的磁盘I/O操作。我在项目中遇到过,直接写FileOutputStream,一个10分钟的音频文件要写半天,加上缓冲后速度提升了好几倍。

避坑指南

我曾经在保存WAV文件时踩过一个坑,这里分享给你:

  • ChunkSize的计算:RIFF块中的ChunkSize = 36 + dataSize,而不是整个文件大小。36是头中除去ChunkID和ChunkSize本身后的字节数。如果你算错了,某些播放器可能无法正确解析。
  • dataSize必须是偶数:WAV规范要求data子块的大小必须是偶数。如果PCM数据是奇数个字节,需要补一个0字节。不过一般情况下,PCM数据都是按帧对齐的,很少出现奇数情况。
  • 位深与字节对齐:8位PCM是无符号整数,16位及以上是有符号整数。写入时要注意符号处理,否则播放出来声音会失真。

警告:不要直接用MediaMuxer来生成WAV文件。MediaMuxer虽然能生成音频文件,但它输出的格式是MP4或WebM,不是纯WAV。如果你需要WAV格式,老老实实手动拼头是最靠谱的。

验证生成的WAV文件

文件保存好后,怎么确认它是对的?我一般用两种方法验证:

  1. 用播放器打开:Windows自带的Media Player、VLC、Audacity都能直接打开WAV。如果能正常播放,说明头信息基本正确。
  2. 用十六进制查看器检查:打开文件,看前4个字节是不是"RIFF",第9-12字节是不是"WAVE"。如果都对,那基本没问题。

你想想看,手动拼WAV头其实并不复杂,但它是理解音频文件格式的一个很好的切入点。掌握了这个,以后遇到其他RIFF格式(比如AVI)也能举一反三。

好了,这一节的内容就到这里。记住:WAV = 44字节头 + PCM数据,就这么简单。下一节我们会聊聊如何优化这个流程,让提取和保存的效率更高。


公众号:蓝海资料掘金营,微信deep3321