Android 音视频开发(一):PCM 格式音频的播放与采集
什么是 PCM 格式
声音从模拟信号转化为数字信号的技术,经过采样、量化、编码三个过程将模拟信号数字化。
-
采样
顾名思义,对模拟信号采集样本,该过程是从时间上对信号进行数字化,例如每秒采集 44100 次,即采样频率 44.1 khz
-
量化
既然是将音频数字化,那就需要使用二进制来表示声音的每一个样本。例如每个样本使用 16 位长度来表示,即音频的位深度为 16 位
-
编码
编码就是按照一定的格式记录采样和量化后的数据,比如顺序存储或压缩存储等
编码后经由不同的算法,音频被保存为不同的格式,例如 MP3、AAC 等,而 PCM 就是最为原始的一种格式,PCM 数据是音频的裸数据格式,不经过任何压缩。
从零到一:使用 AudioTrack 支持 PCM 格式音频的播放
AudioTrack 只支持播放 PCM 编码格式的音频流,平时使用的 MediaPlayer 支持 MP3、AAC 等多种音频格式,其内部也是将 MP3 格式文件使用 framework 层创建的解码器解码为 PCM 裸数据,再经由 AudioTrack 播放的。封装过的 Mediaplayer 的 API 是简单好用,但许多细节我们却无法掌控,而使用 AudioTrack,除了播放之外,我们还可以对数据源做许多有意思的操作,二者各有优劣之处。
先通过构造函数来了解 AudioTrack(版本为API26):
public AudioTrack(AudioAttributes attributes, AudioFormat format, int bufferSizeInBytes, int mode, int sessionId) {
...
}
几个参数介绍一下:
-
AudioAttributes
定义音频的类型,包括音乐、通知、闹钟等,调节音量时也会根据不同的类型进行调节
-
AudioFormat
定义音频的格式,可配置声道数(单通道、多通道)、编码格式(每个采样数据位深度,8bit、16bit等)、采样率
-
bufferSizeInBytes
AudioTrack 内部的音频缓冲区的大小,该缓冲区的值不能低于一帧“音频帧”(Frame)的大小,即:采样率 x 位深 x 采样时间 x 通道数,采样时间一般取 2.5ms~120ms 之间,AudioTrack 类提供了
getMinBufferSize()
方法来计算该值 -
mode
AudioTrack 的两种播放模式,MODE_STATIC 和 MODE_STREAM,前者直接将数据加载进内存,后者是按照一定的间隔不断地写入数据
API26 下原有的两个构造函数已经被标为废弃,建议使用 Builder 来构造 AudioTrack 对象:
private fun createAudioTrack(): AudioTrack {
val format = AudioFormat.Builder()
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.setSampleRate(44100)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build()
return AudioTrack.Builder()
.setAudioFormat(format)
.build()
}
网上介绍 AudioTrack 时常见的如何构造 bufferSizeInBytes
也不用关心了,Builder 类会替我们默认生成。
构造出对象后,在调用 play()
函数开启播放后,只要开启一个线程不断地从源文件中读取数据并调用 AudioTrack 的 write()
函数向手机端音频输出设备传输数据,即可播放 PCM 音频。
使用 ffmpeg 将 MP3 转为 PCM
Mac 下安装 ffmpeg:brew install ffmpeg
使用 ffmpeg 将 mp3 转换为 pcm:ffmpeg -i xxx.mp3 -f s16le -ar 44100 -ac 2 -acodec pcm_s16le xxx.pcm
- -i 制定输入文件
- -f 指定输出编码格式为16byte小端格式
- -ar 指定输出采样率
- -ac 指定输出通道数
- acodec 指定解码格式
- xxx.pcm 为输出文件
这里也提供一下我使用的 PCM 文件:hurt-johnny cash.pcm 44.1khz 双通道 16位深
PCM 录制:AudioRecord
MediaRecorder 录制集成了编码、压缩等功能,AudioRecord 录制的是 PCM 格式的音频文件。
同样的,先从构造函数来认识 AudioRecord:
public AudioRecord(AudioAttributes attributes, AudioFormat format, int bufferSizeInBytes, int sessionId) {
...
}
接触过 AudioTrack,对 AudioRecord 一定不会感到陌生,其构造函数参数与 AudioTrack 几乎如出一辙,这里就不多说了。
AudioRecord 的 API 与 AudioTrack 也是遥相呼应的,在调用函数 startRecording()
开启录制后,只要开启一个后台线程不断地调用 read()
函数从手机端的音频输入设备(麦克风等)读取音频数据,并写入本地文件,即可实现音频的录制。
扩展支持的音频格式: WAV
最开始提到过音频会被编码成不同的格式,而常见的压缩编码格式 WAV 格式可能是与 PCM 数据最为接近的一种格式。WAV 编码不会进行压缩操作,它只在 PCM 数据格式前加上 44 字节(并不一定严格是 44 字节)来描述音频的基本信息,例如采样率、声道数、数据格式等。来看看 WAV 文件头的格式:
WAV 文件头格式
长度(字节) | 内容 |
---|---|
4 | "RIFF" 字符串 |
4 | 从下个地址开始到文件尾的总字节数(音频 data 数据长度 + 44 -8) |
4 | "WAVE" |
4 | "fmt "(最后有一个空格) |
4 | 过渡字节(一般为00000010H),若为00000012H则说明数据头携带附加信息(见“附加信息”) |
2 | 格式种类,1 表示为PCM形式的声音数据 |
2 | 通道数,单声道为1,双声道为2 |
4 | 采样率 |
4 | 波形音频数据传送速率,其值为通道数×每秒数据位数×每样本的数据位数/8。播放软件利用此值可以估计缓冲区的大小。 |
2 | 每个采样需要的字节数,其值为通道数×位深度/8。播放软件需要一次处理多个该值大小的字节数据,以便将其值用于缓冲区的调整。 |
2 | 位深度 |
4 | "data" |
4 | DATA数据长度 |
了解了 WAV 文件头的格式,我们可以尝试自己写一个解析 WAV 文件头的方法,结合上文的 AudioTrack 播放 PCM 的内容来看,只要获取到音频的采样率、位深度与声道数就可以播放该音频。自然也就可以播放内容是 PCM 格式的 WAV 文件。
在此之前需要一个 WAV 文件用作测试,可以使用 ffmpeg 将之前转换的 PCM 格式音频转码成 WAV 格式。
使用 ffmpeg 将 PCM 转为 WAV
ffmpeg -i xxx.pcm -f s16le -ar 44100 -ac 2 xxx.wav
也分享一下我使用的 WAV 文件:hurt-johnny cash.wav 44.1khz 双通道 16位深
解析 WAV 文件头
根据上面提到的 WAV 文件头格式,定义一个类用于存放文件头数据:
public class WaveHeader {
private String riff; // "RIFF"
private int totalLength; //音频 data 数据长度 + 44 -8
private String wave; // "WAVE"
private String fmt; // "fmt "
private int transition; //过渡字节,一般为0x00000010
private short type; // PCM:1
private short channelMask; // 单声道:1,双声道:2
private int sampleRate; //采样率
private int rate; // 波形音频数据传送速率,其值为通道数×每秒数据位数×每样本的数据位数/8
private short sampleLength; // 每个采样需要的字节数,其值为通道数×位深度/8
private short deepness; //位深度
private String data; // "data"
private int dataLength; //data数据长度
...
}
定义好文件头后,我们使用 BufferedInputStream
从本地文件输入流中挨个字节读取数据即可。
byte 字节转字符串
private static String readString(InputStream inputStream, int length) {
byte[] bytes = new byte[length];
try {
inputStream.read(bytes);
return new String(bytes);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
byte 字节转 short、int
需要注意的是 WAV 头中的字节数组是经过反转的,例如表示单通道的字节数组为{1, 0},其中 1 为低位字节,即原始的字节为 [0, 1],转换为二进制为 0000 0000 0000 0001
,即十进制的 1,代表单通道。
//从输入流中读取 2 个字节并转换为 short
private static short readShort(InputStream inputStream) {
byte[] bytes = new byte[2];
try {
inputStream.read(bytes);
//{1, 0}
return (short) ((bytes[0] & 0xff) | ((bytes[1] & 0xff) << 8));
} catch (IOException e) {
e.printStackTrace();
return 0;
}
}
//从输入流中读取 4 个字节并转换为 int
private static int readInt(InputStream inputStream) {
byte[] bytes = new byte[4];
try {
inputStream.read(bytes);
return (int) ((bytes[0] & 0xff) | ((bytes[1] & 0xff) << 8) | ((bytes[2] & 0xff) << 16) | ((bytes[3] & 0xff) << 24));
} catch (IOException e) {
e.printStackTrace();
return 0;
}
}
从代码中可以看到,字节转换为整型数据类型(short、int 等)时,需要先与 0xff
做与(&
)操作,这是为什么呢?
类型转换时 byte & 0xff 的原因
要究其原因,首先需要搞清楚计算机中的原码、反码和补码,这个在之前的文章《Bitmap 图像灰度变换原理浅析》中计算 int 数据类型的值范围时也提到过。下面举个栗子来验证一下,类型转换前 & 0xff
到底有什么用:
假定有一个 byte 数组:[0, 0, -1, 0],想求得这 4 个字节代表的 int 值得大小,重点看第三个字节,其值为 -1,byte 占一个字节 8 位,易得:
- 原码:
1000 0001
- 反码:
1111 1110
- 补码:
1111 1111
显而易见,这个 byte 数组代表的 int 值的二进制值为:
0000 0000 0000 0000 1000 0001 0000 0000
只要对 -1 左移 8 位即可。so easy!
byte a = -1;
int b = a << 8; //结果是 -256
值为负数就说明以上转换必然是错的。因为我们想要的结果是 0000 0000 0000 0000 1000 0001 0000 0000
,该二进制最高位符号位为 0,结果必然是一个正数。
我们知道计算机在运算时使用的是补码,则 (byte)-1 << 8 运算时,计算机会对 -1 的补码 1111 1111
做位移操作,结果为 1111 1111 0000 0000
,其原码为 1000 0001 0000 0000
,先记作 16位原码A,当该值赋值给 int 类型的变量时,int 类型占 4 个字节 32 位,则需要对原因的 16 位值做位扩展,负数在位扩展时会对多出的高位补 1(正数补 0),则扩展后的值为 1111 1111 1111 1111 1111 1111 0000 0000
,转为原码为 1000 0000 0000 0000 0000 0001 0000 0000
,记作 32位原码B,与上面的 16 位原码做比较可以发现,二者最高位都是 1,低位的值也相同,两个二进制的值在十进制上是一致的,这就是负数补码高位补 1 的原因:为了保持十进制的一致性。不难理解这样做的原因,否则 short 类型强制为 int 类型后值就发生变化了,这明显是不可接受的。
回到我们的需求,我们这里并不需要保持十进制的一致性,所以要先与 0xff 做与运算,因为 0xff 是十六进制 32 位,二进制值为 32 个 1,-1 & 0xff
时,8 位与 32 位运算时,8 位的需要先在高位补 0 补齐到 32 位才会做运算,所以
-1 & 0xff
的结果为补码:1111 1111
前面带 24 个 0,最高位为正数,再对结果做位移操作,得到的二进制值补码为:0000 0000 0000 0000 1111 1111 0000 0000
,因为是正数,原码与补码相同,该二进制值为 65280。可以验证下:
byte a = -1;
int c = (a & 0xff) << 8; //结果是 65280
概括一下就是:
-
类型转换时补码位扩展(例如 2 个字节转 4 个字节,即 short 转 int)的规则:正数高位补 0,负数高位补1,以此保持十进制的一致性
-
运算时,补码高位统一补 0
这里也附上将 byte 转二进制(补码)的方法:
public static String binary(byte bytes, int radix){
byte[] bytes1 = new byte[1];
bytes1[0] = bytes;
return new BigInteger(1, bytes1).toString(radix);// 这里的1代表正数
}
顺序读取,构造 WavHeader
接着只要使用 InputStream 从目标音频中顺序读取各个参数的值并构造 WavHeader 即可,因为 Header 成员变量众多,所以考虑用建造者模式来构建 Header:
WaveHeader.Builder builder = new WaveHeader.Builder()
.setRiff(readString(dis, 4))
.setTotalLength(readInt(dis))
.setWave(readString(dis, 4))
.setFmt(readString(dis, 4))
...
另外上面提到过,并非所以 WAV 文件头都是标准的 44 个字节,例如我上面提供的 ffmpeg 转码后的 WAV 文件,其文件头的长度就是 78 个字节。对于文件头长度不一致的问题,我的解决方法是从 37 个字节开始,2 个 2 个字节地读取,直到读取到“da”和“ta”,之后再往后读取 4 个字节的 int 值作为 data 数据长度。读取到 header 后,后面播放的就不用说了,复用上面播放 PCM 的代码即可。
需要说明的是我只是从网上随机下载了几个 wav 格式音频测试了下是可以正常播放的,并没有经过广泛验证和对常见的 WAV 文件头格式的考证,所以可能还存在兼容问题。
经过这些以上的学习以及众多资料的查阅,对 Android 端音频开发有了一些小小的认识。后面还会学习一下使用 LAME 将 PCM 转码为 MP3,并实现一些真正意义上的音频播放器的基础功能等。再后面会学习一些视频方面的知识,包括 MediaExtractor、MediaMuxer 解析、封装 MP4 文件、OpenGL ES 渲染图像、MediaCodec 对音视频的硬编、硬解等,并使用一些流行的开源项目例如 ffmpeg 实现一些炫酷的视频处理功能,希望可以在 Android 音视频开发这一块能有所深入,学习过程中的一些收获和困惑也会坚持记录下来。
此路迢迢,与君共勉。
以上源码见 Github