什么是 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

Android 音视频开发(一):PCM 格式音频的播放与采集的更多相关文章

  1. Android 音视频开发学习思路

    Android 音视频开发这块目前的确没有比较系统的教程或者书籍,网上的博客文章也都是比较零散的.只能通过一点点的学习和积累把这块的知识串联积累起来. 初级入门篇: Android 音视频开发(一) ...

  2. Android音视频开发(1):H264 基本原理

    前言 H264 视频压缩算法现在无疑是所有视频压缩技术中使用最广泛,最流行的.随着 x264/openh264 以及 ffmpeg 等开源库的推出,大多数使用者无需再对H264的细节做过多的研究,这大 ...

  3. Android 音视频开发(一) : 通过三种方式绘制图片

    版权声明:转载请说明出处:http://www.cnblogs.com/renhui/p/7456956.html 在 Android 音视频开发学习思路 里面,我们写到了,想要逐步入门音视频开发,就 ...

  4. Android 音视频开发(七): 音视频录制流程总结

    在前面我们学习和使用了AudioRecord.AudioTrack.Camera.MediaExtractor.MediaMuxer API.MediaCodec. 学习和使用了上述的API之后,相信 ...

  5. Android 音视频开发入门指南

    Android 音视频从入门到提高 —— 任务列表 http://blog.51cto.com/ticktick/1956269(以这个学习为基础往下面去学习) Android 音视频开发学习思路-- ...

  6. Android 音视频开发(六): MediaCodec API 详解

    在学习了Android 音视频的基本的相关知识,并整理了相关的API之后,我们应该对基本的音视频有一定的轮廓了. 下面开始接触一个Android音视频中相当重要的一个API: MediaCodec.通 ...

  7. Android 音视频开发(四):使用 Camera API 采集视频数据

    本文主要将的是:使用 Camera API 采集视频数据并保存到文件,分别使用 SurfaceView.TextureView 来预览 Camera 数据,取到 NV21 的数据回调. 注: 需要权限 ...

  8. Android 音视频开发(五):使用 MediaExtractor 和 MediaMuxer API 解析和封装 mp4 文件

    一个音视频文件是由音频和视频组成的,我们可以通过MediaExtractor.MediaMuxer把音频或视频给单独抽取出来,抽取出来的音频和视频能单独播放: 一.MediaExtractor API ...

  9. Android 音视频开发(三):使用 AudioTrack 播放PCM音频

    一.AudioTrack 基本使用 AudioTrack 类可以完成Android平台上音频数据的输出任务.AudioTrack有两种数据加载模式(MODE_STREAM和MODE_STATIC),对 ...

随机推荐

  1. python应用(4):变量与流程

    程序是什么?就是一堆代码啰.但是代码是有组织而来的,不是凭空堆砌出来的.有一个"古老"的说法:程序=数据结构+算法,意思是,程序是由一些数据结构(数据的组织结构)加上某些算法而形成 ...

  2. MIT-6.005软件构建

    L01 Static Typing 主要对比Java和Python Java:静态语言,运行之前所有变量都要声明.traps:整型相除还是整型,5/2=2.数值溢出,20亿*2结果是负数,这个bug不 ...

  3. Eclipse的新建工作空间如何用以前工作空间的配置

    1.找到以前工作空间的配置目录:\.metadata\.plugins\org.eclipse.core.runtime 2.替换掉新的工作空间的配置目录:\.metadata\.plugins\or ...

  4. Python中sort、sorted的cmp参数废弃之后使用cmp_to_key实现类似功能

    Python2.1以前的排序比较方法只提供一个cmp比较函数参数,没有__lt__等6个富比较方法, Python 2.1引入了富比较方法,Python3.4之后作废了cmp参数.相应地从Python ...

  5. 第10.7节 Python包和子包的定义步骤

    一. 包的定义步骤 按照包名创建或使用一个已有目录,目录名就是包名,必须注意包的目录必须位于Python加载模块的搜索路径中(具体请参考<第10.1节 Python的模块及模块导入>关于模 ...

  6. Python中使用“模块名.__all__”查看模块建议导出的属性

    在<第10.5节 使用__all__定义Python模块导入白名单>中,老猿介绍了在自定义模块中使用定义__all__属性来提供模块对外可见的白名单,使用该机制除了可以定义访问的白名单外, ...

  7. jmeter使用中的问题

    1.响应乱码 step1:指定请求节点下,新建后置控制器"BeanShell PostProcessor" step2:其脚本框中输入以下代码,保存 //获取响应代码Unicode ...

  8. 【题解】「P6771」[USACO05MAR]Space Elevator 太空电梯

    P6771 这是一道很明显的 dp 问题. 首先 dp 最重要的三要素是:动态表示.动态转移.初始状态. 只要这三个要素搞明白了,基本就能把这题做出来了. solution 让我们来看看这题的动态表示 ...

  9. hashmap为什么是二倍扩容?

    这个很简单,首先我们考虑一个问题,为什么hashmap的容量为2的幂次方,查看源码即可发现在计算存储位置时,计算式为: (n-1)&hash(key) 容量n为2的幂次方,n-1的二进制会全为 ...

  10. C#9.0新特性详解系列之六:增强的模式匹配

    自C#7.0以来,模式匹配就作为C#的一项重要的新特性在不断地演化,这个借鉴于其小弟F#的函数式编程的概念,使得C#的本领越来越多,C#9.0就对模式匹配这一功能做了进一步的增强. 为了更为深入和全面 ...