25、提取并保存为WAV:将AAC解码后保存为PCM/WAV格式
好,咱们继续往前走。上一节我们把AAC解码成了原始的PCM数据,但PCM这玩意儿说白了就是一堆裸数据,没有文件头,播放器根本不认。你想想看,你辛辛苦苦把音频流提出来、解码好,结果存成一个没有格式的文件,那不等于白干了吗?
所以这一节,我们就来解决这个问题——把解码后的PCM数据封装成WAV格式。WAV是Windows上最常见的无损音频格式,它的结构其实很简单:一个文件头 + PCM数据。我们手动拼一个WAV头,然后把PCM数据写进去,就完事了。
核心思路:WAV = 44字节文件头 + PCM裸数据。我们只需要构造好这44个字节,剩下的就是复制粘贴。
WAV文件结构速览
WAV文件头是RIFF格式的一种变体。我刚开始接触的时候也觉得挺玄乎,后来拆开一看,其实就是几个固定字段拼在一起。下面这张图帮你一眼看明白:
说白了,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文件
文件保存好后,怎么确认它是对的?我一般用两种方法验证:
- 用播放器打开:Windows自带的Media Player、VLC、Audacity都能直接打开WAV。如果能正常播放,说明头信息基本正确。
- 用十六进制查看器检查:打开文件,看前4个字节是不是"RIFF",第9-12字节是不是"WAVE"。如果都对,那基本没问题。
你想想看,手动拼WAV头其实并不复杂,但它是理解音频文件格式的一个很好的切入点。掌握了这个,以后遇到其他RIFF格式(比如AVI)也能举一反三。
好了,这一节的内容就到这里。记住:WAV = 44字节头 + PCM数据,就这么简单。下一节我们会聊聊如何优化这个流程,让提取和保存的效率更高。