您当前的位置:首页 > 计算机 > 文件格式与编码

音频基础知识以及PCM转WAV

时间:02-08来源:作者:点击数:

音频基础知识

声音是什么?

记得初中学物理的时候我们就学过声音了,声音是由振动产生的,声音在空气中振动形成振动波传到我们的耳朵,我们的耳膜接收到了振动波,所以能感受到声音。声音在空气中的振动波我们看不见,可以把它比作水中的水波,水波是能看见的,如下:

在这里插入图片描述

我们可以想一想水波产生的样子,然后再把水波想像为无形的声音振动波。

振动幅度和振动频率

声音由振动幅度和振动频率组成,振幅即上下振动的幅度,当然这个我们也看不见,一般我们会以拨动尺子的上下振动来比喻,如下图:

在这里插入图片描述

在桌面上按住尺子的一端,此时的尺子是一条直线,然后我们拨动另一边,尺子就会上下振动了,这个上下的高度就是幅度了,随着时间的过去,振动幅度会越来越小,最后恢复平静。

振动频率,即上下振动的快慢,比如不同材料的尺子,一分钟内,它可能上下振动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意思为每个样本的大小(简称样本大小),如果是单声道,则每帧就采集一个样本,如果是双声道,则每帧采集两个样本。根据如上函数代码可知,我们在设置用于保存采集音频数据的缓冲区大小(bufferSizeInBytesaudioBufferSize)时,这个大小值必须是音频帧大小的倍数。

至此,我们了解到音频帧是什么意思了,音频帧大小 = 通道数 * 样本大小。

再和之前理解的声音的振动幅度合起来理解一下,一帧音频,即一次声音的振动幅度,一帧音频的大小,即采集一次声音的振动幅度需要的存储大小,如果是单声道,需要的大小就是位深/2,除2是要把位换为字节,如果是双声道,就需要再乘以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都是不允许的,因为这样的缓冲区也太小了

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)
    }

}

注:这里有一个大端、小端的知识点,可以参考这篇文件:https://www.cdsy.xyz/computer/fileABC/230208/cd40324.html

方便获取更多学习、工作、生活信息请关注本站微信公众号城东书院 微信服务号城东书院 微信订阅号
推荐内容
相关内容
栏目更新
栏目热门