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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容