音频基础知识
声音是什么?
记得初中学物理的时候我们就学过声音了,声音是由振动产生的,声音在空气中振动形成振动波传到我们的耳朵,我们的耳膜接收到了振动波,所以能感受到声音。声音在空气中的振动波我们看不见,可以把它比作水中的水波,水波是能看见的,如下:
我们可以想一想水波产生的样子,然后再把水波想像为无形的声音振动波。
振动幅度和振动频率
声音由振动幅度和振动频率组成,振幅即上下振动的幅度,当然这个我们也看不见,一般我们会以拨动尺子的上下振动来比喻,如下图:
在桌面上按住尺子的一端,此时的尺子是一条直线,然后我们拨动另一边,尺子就会上下振动了,这个上下的高度就是幅度了,随着时间的过去,振动幅度会越来越小,最后恢复平静。
振动频率,即上下振动的快慢,比如不同材料的尺子,一分钟内,它可能上下振动100次,也可能振动200次,这个100次、200次就是它振动的频率了。
在计算机中,位深用于记录振动幅度,采样频率用于记录振动频率。
专业术语:位深英文为:Bit Depth,采样频率英文为:Simple Rate
位深
比如,我们画一条垂直的线,把它分为10段,用于表示振动幅度,则它只能表示10个位置,示例如下:
我们假设某一时刻,声音振动幅度在8.6的位置,但是由于我们只分了10个位置,没有办法记录8.6这个位置,所以只能四舍五入,把它当成位置9来处理,这样就不太准确了,就是丢失了一些精度了,和原来的位置有偏差了。所以,这个上下分的位置越多,能描述的振幅的位置就越接近,为什么只能说是接近,因为我们初中学过,一条直线是由无数的点组成的,所以振幅的位置可以在任意的点的位置上,而且点是无数多的,所以只能说接近。那我们要分成多少个位置呢?在计算机中常用的分法用8位、16位、24位,这并不是说分成8个位置、16个位置、24个位置,而是说多少个比特位来表示位置的数量。比如我们只用1个比特位来表示,则可以表示0和1,则只能表示两个位置,如果用两个比特位,则能表示00、01、10、11这4个组合,即可以表示4个位置,那如果是用8个比特位,则有256个组合,能表示256个位置,如果使用16个比特位,则能表示65536个位置,相比使用8个比特位,能表示的范围一下就大了256倍,也就是说位深为16位的采样精度将是8位的256倍。嗯?位深和采样精度是什么关系?相同的东西的不同描述罢了,位深主要是讲它用多少个比特位来描述振幅,这个或多或少的就叫采样精度,采样精度低,意味着使用的比特位少,采样精度高意味着使用的比特位多。所以当我们音频主题方面听到别人说位深或者采样精度时,我们要知道他是在讲振幅。
采样频率
音频的采样频率和视频的采样频率的理解是一样的。比如我们把手机摄像头的视频采集帧率设置为25帧/秒,这就表示在1秒钟内,摄像头会拍摄25张图片,即每40毫秒拍一张,拍25张需要1000毫秒,正好一秒钟的时间,把拍到的所有的图片连续播放出来,这就是会动的视频了。
声音也一样,随着时间的逝去,振动幅度也是在不断的发生变化的,比如在1毫秒的时候,振幅在5的位置,2毫秒的时候,振幅在10的位置,3毫秒的时候振幅在15的位置。。。那我们1秒钟内要取多少次振幅的位置呢?声音的采样率要比视频大的多,视频1秒内取25次摄像头画面就可以得到比较流畅的视频,如果声音1秒内只取25次振幅,则得到的声音效果不知道有多差,因为声音的振动频率是比较大的,比如一个声音一秒钟内振动了8000次,而你只取了其中25次的振动值,可见精度丢失的有多严重。音频的采样频率常用的有8000次、32000次、44100次,专业表示为8kHz、32kHz、44.1kHz。
关于Hz单位的百度百科:
赫兹是国际单位制中频率的单位,它是每秒钟的周期性变动重复次数的计量。
赫兹简称赫。每秒钟振动(或振荡、波动)一次为1赫兹,或可写成次/秒,周/秒。因德国科学家赫兹而命名。
所以Hz就可以理解为中文的“次”,8000Hz或8kHz就是说1秒种要采集8000次,不管这个声音在1秒钟内振动的次数有没有8000次,反正采集时在1秒钟内要采集8000次。所以采样率并不是直接等于振动频率,而是越高的采样率能还原的振动频率就越精确。
8kHz中小写k指的就是1000,8k就是8000,一般小写的单位进制是1000,而大写的单位的进制是1024,比如1k = 1000,1K = 1024。比如说一个人的工资是15000,可以说他的工资是15k,只能用小写的k,不能用大写的K。
有不明来源的知识点如下:
人的发音器官发出的声音频率大约是80-3400Hz,但人说话的信号频率通常为300-3000Hz
所以在做音视频开发的时候,语音通话时的音频采集一般使用8kHz做为采样率,这样的好处是:对人的声音的采集精度足够了,二是8kHz采集到的音频数据量相对较小,方便网络传输。一般音乐mp3这种才需要使用44.1kHz。
最后贴上一张表示位深和采样频率的图片,坐标轴中,垂直方向的刻度表示振幅的高低,水平方向的刻度表示采样的次数。
如上图,把指定采样频率采集到的振幅连起来就会形成一条曲线。
计算PCM音频文件大小
有了前面的知识,我们就可以轻松的计算PCM音频文件的大小了,pcm格式的音频即没有进行过任何压缩的原始音频数据。
录1秒钟的音频,保存为pcm格式需要多少存储空间?通过公式就能计算出来,假设使用的采样参数如下:
- 位深:16位
- 采样率:8000
- 声道数:1
则采集一次振幅数据需要的空间为:16 / 8 * 1 = 2 byte,因为8位为1个字节,所以除以8得到单位为字节,因为只有1个声道,所以乘以1,如果是双声道就要乘以2。
这里采样率为8000,说明1秒钟内采集8000次振幅数据,所以1秒钟pcm文件大小 = 2byte * 8000 = 16000byte = 15.625kb
如果是录1分钟,则16000byte * 60 = 960000 byte = 937.5kb,大概是1M
总结pcm大小计算公式为:位深 / 8 x 通道数 x 采样率 x 时间(单位:秒),得到一个单位为字节的总大小。
源码解读
BytesPerSample(样本大小)
在AndioFormat类上有如下方法:
public static int getBytesPerSample(int audioFormat) {
switch (audioFormat) {
case ENCODING_PCM_8BIT:
return 1;
case ENCODING_PCM_16BIT:
case ENCODING_IEC61937:
case ENCODING_DEFAULT:
return 2;
case ENCODING_PCM_FLOAT:
return 4;
case ENCODING_INVALID:
default:
throw new IllegalArgumentException("Bad audio format " + audioFormat);
}
}
函数名:bytes per sample
,翻译过来就是每个样本的大小,我把它简称为样本大小
,比如位深为16,则一个样本就需要16位来存储,也就是需要2个字节来存储,则每个样本的大小为2字节。所以BytesPerSample
和位深
描述的是相同的东西,只不过一个用字节来描述,一个用比特位来描述。
何为样本?和声音的振动幅度合起来理解一下,我们采集一次声音的振动幅度的数据,要保存起来,如果位深为16,则是使用16位(2字节)来保存振动幅度的数据,保存的这个数据就是样本,保存一次振动幅度的数据就是保存了一个音频样本,保存了两次声音振动幅度数据,则是保存了两个音频样本。
每帧大小
我们在创建AudioRecord对象的时候,需要在构造函数中传入一个叫bufferSizeInBytes
的参数,官方文档解释该参数功能为:用于指定在录制期间写入音频数据的缓冲区的总大小
,怎么理解这句话呢?这意思是说在采集音频数据的时候,采集到的音频数据存在哪里?要先存到缓冲区,而这个缓冲区要设置多大,就由bufferSizeInBytes
参数来指定,系统把采集到的音频数据保存在缓冲区中,然后我们再读取缓冲区中的音频数据,这样我们就拿到了音频数据了。而缓冲区大小不是可以随便任意设置的,必须满足一定的条件,在AudioRecord中有如下方法用于检测bufferSizeInBytes
(缓冲区大小)是否合法:
private void audioBuffSizeCheck(int audioBufferSize) throws IllegalArgumentException {
int frameSizeInBytes = mChannelCount * (AudioFormat.getBytesPerSample(mAudioFormat));
if ((audioBufferSize % frameSizeInBytes != 0) || (audioBufferSize < 1)) {
throw new IllegalArgumentException("Invalid audio buffer size " + audioBufferSize + " (frame size " + frameSizeInBytes + ")");
}
mNativeBufferSizeInBytes = audioBufferSize;
}
函数的第一行代码是计算frameSizeInBytes,翻译过来就是帧大小,即一帧音频的大小,它是用通道数量 x 样本大小来计算的,BytesPerSample
意思为每个样本的大小
(简称样本大小
),如果是单声道,则每帧就采集一个样本,如果是双声道,则每帧采集两个样本。根据如上函数代码可知,我们在设置用于保存采集音频数据的缓冲区大小(bufferSizeInBytes
或audioBufferSize
)时,这个大小值必须是音频帧大小的倍数。通常做语音通话时录音会使用采样率为8000,位深为16位,通道数为1,按照这些参数的话,则frameSizeInBytes
为2,即一帧音频的大小为2字节,则我们指定的bufferSizeInBytes
必须是2的整倍数,比如是2、4、6、8。。。等等。用多少合适呢?推荐的方案是使用AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat)
的返回值,实际运行时发现它返回的是640,不知道运行到不同的设备它的返回值是否会一样。
至此,我们了解到音频帧
是什么意思了,音频帧大小 = 通道数 * 样本大小。
再和之前理解的声音的振动幅度合起来理解一下,一帧音频,即一次声音的振动幅度,一帧音频的大小,即采集一次声音的振动幅度需要的存储大小,如果是单声道,需要的大小就是位深/8
,除8是要把位换为字节,如果是双声道,就需要再乘以2,因为这相当于同时开了两个采集通道。
采集一次声音的振动幅度就是采集了一帧音频数据,如果采样率为:8000Hz(或8kHz),则表示1秒钟要采集8000次声音的振动幅度,换句话说就是1秒钟要采集8000帧音频数据。如果采样率为44100Hz(或44.1kHz),则1秒钟采集44100帧音频数据。
最小缓冲区
前面我们说到,录音的时候,系统会把采集到的音频数据先存到缓冲区,而缓冲区的大小由bufferSizeInBytes
参数来指定,这个参数必须是音频帧大小的倍数,比如下面的一些情况:
假设使用位深为16位,则:
- 单声道,一帧音频数据大小为:16 / 8 * 1 = 2字节
- 双声道,一帧音频数据大小为:16 / 8 * 2 = 4字节
一般录音使用的位深为16位,则如果采用单声道录音,那么设置的缓冲区大小必须是2的倍数,如果采用双声道录音,则设置的缓冲区大小必须是4的倍数。
在Android开发中,缓冲区大小除了要满足必须是音频帧大小的倍数外,还要满足系统的最小缓冲区大小要求。比如,我们把bufferSizeInBytes
设置为2或4都是不允许的,因为这样的缓冲区也太小了
录制函数每次给几次数据
Android中的录制函数如下:
audioRecord.read(byteBuffer, byteBuffer.capacity())
一般我们会以while循环来调用这个函数,不停地从audioRecord中读取音频数据,那一秒钟我能读到几次数据呢?这就要看设置的采样率是多少,和read函数的最后一个参数值是多少,官方文档对于后一个参数的解释是:请求的字节数。建议(但不强制)请求的字节数是帧大小的倍数(以字节为单位的样本大小乘以通道计数)。 简单的理解就是:这个参数用于指定你想一次从audioRecord中拿多少数据。
要想知道1秒钟能从audioRecord中获取多少次数据,其实很简单,计算出录1秒钟需要多少存储空间即可,比如:假设采样率为8000,位深为16位,则1秒钟需要采集8000个音频样本,而每个样本用16位存储(即两个字节),则采集1秒钟音频需要用16000个字节来存储。所以,1秒钟能从audioRecord中获取多少次数据就看你一次要获取多少,比如:audioRecord.read(byteBuffer, 640)
,则表示一次要取640个字节的音频,1秒钟共有16000个字节的音频数据,每次取640个字节,则 16000 / 640 = 25,则1秒钟能取25次数据。算时间的话,则最少每40毫秒就要取一次(1000ms / 25 = 40ms)。
PCM转WAV
PCM
是一种没有压缩的音频文件格式,PCM所有的数据都是原始的音频数据,如果你用一个播放器去播放它,这是播放不了的,因为播放器不知道这个PCM文件的位深是多少,采样率是多少,通道数是多少,所以不知道如何播放。而一些专业的音频编辑工具可以打开播放,是因为在打开pcm文件的时候,你需要手动选择位深、采样率这些参数,以便编辑工具知道如何处理这个pcm文件。
WAV
格式也是一个没有压缩的音频文件格式,相比PCM,它只是多了一个文件头,文件头中记录了音频的位深、采样率、通道数等音频参数,所以一般的音频播放器都可以直接播放wav格式的音频文件。
wav文件头的内容以及功能说明如下:
数据类型 | 占用空间 | 功能说明 |
字符 | 4 | 资源交换文件标志(RIFF) |
长整型数 | 4 | 从下个地址开始到文件尾的总字节数(即前8个字节不算) |
字符 | 4 | WAV文件标志(WAVE) |
字符 | 4 | 波形格式标志(fmt ),最后一位空格。 |
长整型数 | 4 | 过滤字节(一般为16) |
整型数 | 2 | 格式种类(值为1时,表示数据为线性PCM编码) |
整型数 | 2 | 通道数量,单声道为1,双声道为2 |
长整型数 | 4 | 采样频率 |
长整型数 | 4 | 每秒钟音频的大小 |
整型数 | 2 | 每帧音频的大小 |
整型数 | 2 | 位深 |
字符 | 4 | 数据标志符(data) |
长整型数 | 4 | data总数据长度 |
文件头共占用44byte。这里的数据类型都是使用C语言描述的,C的长整形相当于Java的整形,C的整形相当于Java的短整形。百度的PCM转WAV的文章全都是用C语言写的,在我看来,不就是简单的加个文件头嘛,我做Android开发的就没必要用C实现了,不然还得搞JNI与Java调用来调用去的,麻烦。上面的文件头数据中,位深、采样率、通道数在开启录音的时候就能确定的,不能确定的是pcm数据的长度,上面的文件头中,只有两个是关于数据长度,是未知数,所以可以使用随机访问流,在写到文件最后的时候知道数据的长度了,此时再跳到文件开头把数据长度的文件头加上。
根据和我们公司的大神交流,据说即使长度为0,只要其它文件头参数正常也是可以正常播放的,所以初始化的时候可以把长度设置为0,等到文件写完了,长度也就确定了,可以再写一次文件头,这里是把所有文件头都再写一次,是为了简化代码,虽然最后再完整的写一次有点浪费,但是那点性能的浪费微乎其微,因为44个字节的文件头瞬间就能写完,快得很。
如果是直接把pcm文件转wav,则在一开始就能确定pcm数据的长度的,但是如果是录音,录的时候就要直接保存为wav,此时就不能确定数据的长度了,因为你不知道用户什么时候按停止录音的,所以为了通用,我们等到写到文件最后关流了再确定数据的长度,然后再写一次文件头。
好了,原理都说明白了,然后就是上代码,用的kotlin语言,不会kotlin语言的也可以看着代码自己用java敲一遍,Kotlin和Java是一样的。
import java.io.*
fun main() {
val start = System.currentTimeMillis()
val pcmFile = File("C:\\Users\\Even\\Music\\pcm.pcm")
val wavFile = File("C:\\Users\\Even\\Music\\result_01.wav")
val sampleRate = 8000
val bitDepth = 16
val channelCount = 1
val wavWriter = WavWriter(wavFile, sampleRate, bitDepth, channelCount)
BufferedInputStream(FileInputStream(pcmFile)).use { bis ->
val buf = ByteArray(8192)
var length: Int
while (bis.read(buf, 0, 8192).also { length = it } != -1) {
wavWriter.writeData(buf, length)
}
}
wavWriter.close()
println("wav文件写入成功,使用时间:${System.currentTimeMillis() - start}")
}
import java.io.*
class WavWriter(
wavFile: File,
/** 采样率 */
private val sampleRate: Int,
/** 位深 */
private val bitDepth: Int,
/** 通道数量 */
private val channelCount: Int,
) {
private val raf = RandomAccessFile(wavFile, "rws")
private var dataTotalLength = 0
init {
writeHeader(0)
}
private fun writeHeader(dataLength: Int) {
// pcm 文件大小:1358680
val bytesPerFrame = bitDepth / 8 * channelCount
val bytesPerSec = bytesPerFrame * sampleRate
writeString("RIFF") // 资源交换文件标志(RIFF)
writeInt(36 + dataLength) // 从下个地址开始到文件尾的总字节数(即前8个字节不算)
writeString("WAVE") // WAV文件标志(WAVE)
writeString("fmt ") // 波形格式标志(fmt ),最后一位空格。
writeInt(16) // 过滤字节(一般为16)
writeShort(1) // 格式种类(值为1时,表示数据为线性PCM编码)
writeShort(channelCount) // 通道数量,单声道为1,双声道为2
writeInt(sampleRate) // 采样频率
writeInt(bytesPerSec) // 每秒钟音频的大小
writeShort(bytesPerFrame) // 每帧音频的大小
writeShort(bitDepth) // 位深
writeString("data") // 数据标志符(data)
writeInt(dataLength) // data总数据长度
}
fun writeData(data: ByteArray, dataLength: Int) {
raf.write(data, 0, dataLength)
dataTotalLength += dataLength
}
fun close() {
// 数据写完了,长度也知道了,根据长度重写文件头。
// 按道理只需要重写关于长度的那个数据即可,但是因为文件头很小,写入很快,就全部重写吧!
raf.seek(0)
writeHeader(dataTotalLength)
}
/** 保存4个字符 */
private fun writeString(str: String) {
raf.write(str.toByteArray(), 0, 4)
}
/** 写入一个Int(以小端方式写入) */
private fun writeInt(value: Int) {
// raf.writeInt(value) 这是以大端方式写入的
raf.writeByte(value ushr 0 and 0xFF)
raf.writeByte(value ushr 8 and 0xFF)
raf.writeByte(value ushr 16 and 0xFF)
raf.writeByte(value ushr 24 and 0xFF)
}
/** 写入一个Short(以小端方式写入) */
private fun writeShort(value: Int) {
// raf.writeShort(value) 这是以大端方式写入的
raf.write(value ushr 0 and 0xFF)
raf.write(value ushr 8 and 0xFF)
}
}