美文网首页
音频开发知识收集整理

音频开发知识收集整理

作者: miniminiming | 来源:发表于2022-11-16 12:23 被阅读0次

    原文链接:https://blog.csdn.net/ljx1400052550/article/details/114182600
    https://zhumulangma.blog.csdn.net/article/details/105678937

    基础概念

    在音频开发中,下面的这几个概念经常会遇到。

    采样率(samplerate)

    采样就是把模拟信号数字化的过程,不仅仅是音频需要采样,所有的模拟信号都需要通过采样转换 成可以用0101来表示的数字信号,示意图如下所示:


    image.png

    蓝色代表模拟音频信号,红色的点代表采样得到的量化数值。采样频率越高,红色的间隔就越密集,记录这一段音频信号所用的数据量就越大,同时音频质量也就越高。根据奈奎斯特理论,采样频率只要不低于音频信号最高频率的两倍,就可以无损失地还原成为原始的声音。

    温馨提示:

    通常人耳能听到频率范围大约在20Hz~20kHz之间的声音,为了保证声音不失真,采样频率应在40kHz以上。常用的音频采样频率有:8kHz、11.025kHz、22.05kHz、16kHz、37.8kHz、44.1kHz、48kHz、96kHz、192kHz等。

    量化精度(位宽)

    image.png

    如上图所示,每一个红色的采样点,都需要用一个数值来表示大小,这个数值的数据类型大小可以是:4bit、8bit、16bit、32bit等等,位数越多,表示得就越精细,声音质量自然就越好,当然,数据量也会成倍增大。常见的位宽是:8bit 或者 16bit。

    声道数(channels)

    由于音频的采集和播放是可以叠加的,因此,可以同时从多个音频源采集声音,并分别输出到不同的扬声器,故声道数一般表示声音录制时的音源数量或回放时相应的扬声器数量。比如我们通常说的单声道(Mono)和双声道(Stereo),前者的声道数为1,后者为2

    音频帧(frame)

    音频跟视频很不一样,视频每一帧就是一张图像,而从上面的正玄波可以看出,音频数据是流式的,本身没有明确的一帧帧的概念,在实际的应用中,为了音频算法处理/传输的方便,一般约定俗成取2.5ms~60ms为单位的数据量为一帧音频。

    这个时间被称之为“采样时间”,其长度没有特别的标准,它是根据编码×××和具体应用的需求来决定的,我们可以计算一下一帧音频帧的大小:假设某通道的音频信号是采样率为8kHz,位宽为16bit,20ms一帧,双通道,则一帧音频数据的大小为: int size = 8000 x 16bit x 0.02s x 2 = 5120 bit = 640 byte


    常见的音频编码方式和压缩格式

    模拟的音频信号转换为数字信号需要经过采样和量化,量化的过程被称之为编码,根据不同的量化策略,产生了许多不同的编码方式,常见的编码方式有:PCM 和 ADPCM,这些数据代表着无损的原始数字音频信号,添加一些文件头信息,就可以存储为WAV文件了,它是一种由微软和IBM联合开发的用于音频数字存储的标准,可以很容易地被解析和播放。

    我们在音频开发过程中,会经常涉及到WAV文件的读写,以验证采集、传输、接收的音频数据的正确性。
    在讲音频的压缩格式前,先简单讲一下音频的压缩原理,原理很简单,和视频的压缩原理差不错,就是因为存在冗余信息,所以可以压缩,减少音频的体积。

    频谱掩蔽效应: 人耳所能察觉的声音信号的频率范围为20Hz~20KHz,在这个频率范围以外的音频信号属于冗余信号。
    时域掩蔽效应: 当强音信号和弱音信号同时出现时,弱信号会听不到,因此,弱音信号也属于冗余信号。

    AudioRecord 的工作流程

    它采集到的音频数据是原始的PCM格式,想压缩为mp3,aac等格式的话,还需要专门调用编码器进行编码

    构造如下:

        public AudioRecord(int audioSource, int sampleRateInHz, int channelConfig, int audioFormat,
                int bufferSizeInBytes)
        throws IllegalArgumentException {
            this((new AudioAttributes.Builder())
                        .setInternalCapturePreset(audioSource)
                        .build(),
                    (new AudioFormat.Builder())
                        .setChannelMask(getChannelMaskFromLegacyConfig(channelConfig,
                                            true/*allow legacy configurations*/))
                        .setEncoding(audioFormat)
                        .setSampleRate(sampleRateInHz)
                        .build(),
                    bufferSizeInBytes,
                    AudioManager.AUDIO_SESSION_ID_GENERATE);
        }
    

    参数含义:

    audioSource

    audioSource是音频采集的输入源,可选的值以常量的形式定义在 MediaRecorder.AudioSource 类中,常用的值包括:DEFAULT(默认),VOICE_RECOGNITION(用于语音识别,等同于DEFAULT),MIC(由手机麦克风输入),VOICE_COMMUNICATION(用于VoIP应用)等等。

    sampleRateInHz

    sampleRateInHz表示采样率,注意,目前44100Hz是唯一可以保证兼容所有Android手机的采样率。

    channelConfig

    通道数的配置,可选的值以常量的形式定义在 AudioFormat 类中,常用的是 CHANNEL_IN_MONO(单通道),CHANNEL_IN_STEREO(双通道)

    audioFormat

    这个参数是用来配置“数据位宽”的,可选的值也是以常量的形式定义在 AudioFormat 类中,常用的是 ENCODING_PCM_16BIT(16bit),ENCODING_PCM_8BIT(8bit),注意,前者是可以保证兼容所有Android手机的。

    bufferSizeInBytes

    这个是最难理解又最重要的一个参数,它配置的是 AudioRecord 内部的音频缓冲区的大小,该缓冲区的值不能低于一帧“音频帧”(Frame)的大小,而前一篇文章介绍过,一帧音频帧的大小计算如下:
    int bufferSizeInBytesSize= 采样率 x 位宽 x 采样时间 x 通道数

    采样时间一般取 2.5ms~120ms 之间,由厂商或者具体的应用决定,我们其实可以推断,每一帧的采样时间取得越短,产生的延时就应该会越小,当然,碎片化的数据也就会越多。

    由于Android的定制化比较严重,不建议采用以上的计算公式计算,幸好AudioRecord 类提供了一个帮助你确定这个 bufferSizeInBytes 的函数:

    int getMinBufferSize(int sampleRateInHz, int channelConfig, int audioFormat)
    

    PCM音频的保存格式

    image.png

    8个byte的位置,8位单声道可以存储8个样本,8位双声道能存储个样本,16位双声道能存储4个,16位双声道只能存储2个。


    image.png

    单声道转双声道

    单声道转双声道的基本原理:

    由图可知,我们需要把单声道的每一份数据都拷贝一份到右声道,这样使用双声道播放就没有问题了。下面是部分操作代码,原博主用kotlin写的。
    首先我录制了一个音频保存到ArrayList中:

     private val recordThread = Thread(
            Runnable {
                val iMinBufferSize = AudioRecord.getMinBufferSize(
                    Constants.SAMPLE_RATE,
                    currentChannel,
                    AudioFormat.ENCODING_PCM_16BIT
                )
    
                val audioRecord = AudioRecord(
                    MediaRecorder.AudioSource.MIC,
                    Constants.SAMPLE_RATE,
                    currentChannel,
                    AudioFormat.ENCODING_PCM_16BIT,
                    iMinBufferSize
                )
    
                audioRecord.startRecording()
                monoByteList.clear()
                val recordBytes = ByteArray(iMinBufferSize)
                var lastTime = 0L
                var pcmSize = 0
                while (lastTime < recordTime * 1000000L) {
                    val readSize = audioRecord.read(recordBytes, 0, recordBytes.size)
                    // 保存音频数据到ArrayList中
                    monoByteList.addAll(recordBytes.asList())
                    pcmSize += readSize
                    lastTime = pcmSize * 1000000L / 2 / Constants.SAMPLE_RATE
                }
    
                audioRecord.stop()
                audioRecord.release()
                recordCallback()
            }
    )
    

    录制的是16位的数据,所以我们每一个采样的数据会占据两位,所以在拷贝的过程中,我们也要每两位拷贝一次:

    private val convertMonoToStereoThread = Thread(Runnable {
            // 单声道转双声道
            // 双声道的存储格式为 LRLRLR
            // 所以把左声道的内容拷贝到右声道即可
            for (index in 0 until monoByteList.size step 2) {
                // 目前保存的是16位的数据,所以要复制前两位
                stereoByteList.add(monoByteList[index])
                stereoByteList.add(monoByteList[index + 1])
                // 目前保存的是16位的数据,所以要复制前两位
                stereoByteList.add(monoByteList[index])
                stereoByteList.add(monoByteList[index + 1])
            }
            convertCallback()
    })
    
    疑问

    这里我是有些疑问的;这个操作相当于把相邻两个mono(记为m1和m2)的,当成一个左和一个右,然后copy一份放进去,立体声的排布顺序就会变成m1m2m1m2,那播放的时候,相当于,左声道会连续播放m1m1,右声道连续播放m2m2,这个对吗?

    双声道转单声道的操作

    双声道转单声道的原理:
    双声道转单声道有两种做法:
    1、丢弃其中一路数据(丢失左声道或右声道的数据)
    2、两路数据相加的平局值。(也可以是其他算法)

    第一种做法:丢弃一路数据

    我们可以按照单声道双声道的做法,每四位取前两位或后两位的数据即可。但是这里我们换一种做法。

    // 保存了录制的16位双声道音频数据,过程省略,里面保存类型Byte
    stereoByteList
    
    // 目标输出ArrayList,类型为Short,如果你需要Byte数据,可以再自行转换一次
    monoByteList
    
    // 开始转换
    private fun convertStereoToMono() {
            thread {
                // 双声道转单声道
                // 方案1:丢掉一路数据,此方法最简单
                // 这里只取左声道的声音
                monoByteList.clear()
                // ByteOrder.LITTLE_ENDIAN 从小到大 ,高位在后
                // ByteOrder.BIG_ENDIAN 从大到小,高位在前,默认
                val shortBuffer = ByteBuffer.wrap(stereoByteList.toByteArray()).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()
                for (index in 0 until shortBuffer.capacity() step 2) {
                    monoByteList.add(shortBuffer.get(index))
                }
                convertCallback()
            }
        }
    

    这里我们使用了ByteBuffer帮助我们把Byte转成Short。其中有一个很重要的坑,就是设置Byte转Short的规则:

    ByteOrder.LITTLE_ENDIAN 从小到大 ,高位在后
    ByteOrder.BIG_ENDIAN 从大到小,高位在前,默认

    short的长度为16位,所以需要两个8位的Byte一起保存,其中一个Byte保存的是前8位,也就是高位另外的一个Byte保存的后8位,也就是低位。

    所以我们一定要确保高低位的顺序,否则得到的Short一定是错的,经过测试,录制的音频是低位在前,所以我们修改ByteBuffer默认的高位在前的配置:

    ByteBuffer.wrap(stereoByteList.toByteArray()).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()
    // 读取指定位置的Short
    val short = shortBuffer.get(index)
    

    相同的原理,我们需要Byte转Int都可以借助对应的Buffer进行读取,非常的方便。


    image.png

    所以我们一定要确保高低位的顺序,否则得到的Short一定是错的,经过测试,录制的音频是低位在前,所以我们修改ByteBuffer默认的高位在前的配置:

    ByteBuffer.wrap(stereoByteList.toByteArray()).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()
    // 读取指定位置的Short
    val short = shortBuffer.get(index)
    

    相同的原理,我们需要Byte转Int都可以借助对应的Buffer进行读取,非常的方便。

    第二种做法:左右声道取平局值

    // 保存了录制的16位双声道音频数据,过程省略,里面保存类型Byte
    stereoByteList
    
    // 目标输出ArrayList,类型为Short,如果你需要Byte数据,可以再自行转换一次
    monoByteList
    
     private fun convertStereoToMono() {
            thread {
                // 双声道转单声道
                monoByteList.clear()
                // ByteOrder.LITTLE_ENDIAN 从小到大 ,高位在后
                // ByteOrder.BIG_ENDIAN 从大到小,高位在前,默认
                val shortBuffer = ByteBuffer.wrap(stereoByteList.toByteArray()).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()
                // 方案二:把左右声道的声音相加,取平均值
                // 使用kotlin的位运算 and shl等,无法得到正确的byte转short,short转init
                for (index in 0 until shortBuffer.capacity() step 2) {
                    monoByteList.add((shortBuffer.get(index) + shortBuffer.get(index + 1) / 2).toShort())
                }
                convertCallback()
            }
        }
    

    大端小端的区别

    大端转小端,先比较一下两份16进制:

    1. 48k,双通道,s16be (BIG_ENDIAN )的文件
    2. 将上述PCM用ffmpeg转换成:48k,双通道,s16le(LITTLE_ENDIAN)
    ffmpeg  -f s16be -ac 2 -ar 48000 -i D:\share\s16be.pcm -f s16le -ar 48000 -ac 2 -y D:\share\s16le.pcm
    

    比较大端转换为小端后的16进制,发现是将每个样点16bit的高8位与低8位进行了交换位置。


    image.png

    定义

    所谓的大端模式,就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。

    所谓的小端模式,就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。

    简单来说:大端——高尾端,小端——低尾端

    举个例子,比如数字 0x12 34 56 78在内存中的表示形式为:

    1)大端模式:

    低地址 -----------------> 高地址

    0x12 | 0x34 | 0x56 | 0x78

    2)小端模式:

    低地址 ------------------> 高地址

    0x78 | 0x56 | 0x34 | 0x12

    可见,大端模式和字符串的存储模式类似。

    3)下面是两个具体例子:

    16bit宽的数0x1234在Little-endian模式(以及Big-endian模式)CPU内存中的存放方式(假设从地址0x4000开始存放)为:

    | 内存地址 | 小端模式存放内容 | 大端模式存放内容
    | 0x4000 | 0x34 | 0x12 |
    | 0x4001 | 0x12 | 0x34 |
    32bit宽的数0x12345678在Little-endian模式以及Big-endian模式)CPU内存中的存放方式(假设从地址0x4000开始存放)为:
    内存地址 小端模式存放内容 大端模式存放内容
    0x4000 0x78 0x12
    0x4001 0x56 0x34
    0x4002 0x34 0x56
    0x4003 0x12 0x78

    MediaCodec

    MediaCodeC是Android 4.1(API16 ) 版本加入的一个新的音视频处理API,旨在提高Android平台的音视频编码能力,Mediacodec类可用于访问底层的媒体编解码器,即编码器/解码器组件。这是Android底层的多媒体支持基础设施的一部分(通常与 MediaExtractor, MediaSync, MediaMuxer,MediaCrypto, MediaDrm, Image, Surface, AudioTrack)。Android 应用层统一由 MediaCodec API 提供音视频编解码的功能,由参数配置来决定采用何种编解码算法、是否采用硬件编解码加速等。由于使用硬件编解码,兼容性有不少问题,据说 MediaCodec 坑比较多。

    MediaCodec 采用了基于环形缓冲区的「生产者-消费者」模型,异步处理数据。在 input 端,Client 是这个环形缓冲区「生产者」,MediaCodec 是「消费者」。在 output 端,MediaCodec 是这个环形缓冲区「生产者」,而 Client 则变成了「消费者」。

    mediacodec的工作流程图如下:


    image.png

    MediaCodeC 状态机:


    image.png
    简化一下工作流程如下:
    1.Client 从 input 缓冲区队列申请 empty buffer [dequeueInputBuffer]
    2.Client 把需要编解码的数据拷贝到 empty buffer,然后放入 input 缓冲区队列 [queueInputBuffer]
    3.MediaCodec 从 input 缓冲区队列取一帧数据进行编解码处理
    4.处理结束后,MediaCodec 将原始数据 buffer 置为 empty 后放回 input 缓冲区队列,将编解码后 的数据放入到 output 缓冲区队列
    5.Client 从 output 缓冲区队列申请编解码后的 buffer [dequeueOutputBuffer]
    6.Client 对编解码后的 buffer 进行渲染/播放
    7.渲染/播放完成后,Client 再将该 buffer 放回 output 缓冲区队列 [releaseOutputBuffer]

    所根据mediacodec的工作流程可以大概归纳出MediaCodec 基本使用流程:

    - createEncoderByType/createDecoderByType
    - configure
    - start
    - while(true) {
        - dequeueInputBuffer
        - queueInputBuffer
        - dequeueOutputBuffer
        - releaseOutputBuffer
    }
    - stop
    - release
    

    音频格式WAV以及与PCM的转换

    WAV主要解决了播放器无法播放的问题,体积上并没有太大的优势。WAV可以直接包含PCM,我们只需要在PCM的前面加入WAV的头文件,就完成转换了,所以我们首先要了解WAV的头文件的内容。

    WAV头文件

    image.png

    上图是一个完整的WAV头文件的结构,其中一部分fact(压缩编码)在包含PCM是不需要的,因为PCM的无损无压缩的。


    image.png

    上图是官方对于wav的头文件描述图,虽然是英文的,但是我们依次了解每一位表达的意义:

    ChunkID:固定RIFF的ACSⅡ码,占4位;
    ChunkSize:文件的总长度,占4位,因为不包含ChunkID和ChunkSize的长度,所以要需要减8;
    Format:固定WAVE的ASCⅡ码,占4位;
    Subchunk1 ID:fmt块,占4位,如果不足4位,补空格,所以是‘fmt ’;
    Subchunk Size:fmt块的总长度,pcm固定16,表示从当前位置到描述fmt信息的长度,从上图计算AudioFormat到BitsPerSample的长度,长度确实是16,如果不是###### PCM长度可能会发生变化,占4位:
    AudioFormat:音频格式,PCM固定是1,占2位;
    NumChannels:声道数,占2位;
    SampleRate:采样率,占4位;
    ByteRate:比特率,占4位;
    BlockAlign:计算方法为 NumChannels * BitsPerSample/8,占两位;
    BitsPerSample:我们录制的格式,一个采样占几个byte,占2位;
    Subchunk2ID:固定保存‘data’,占4位;
    Subchunk2Size:音频数据的长度,如果你知道,计算方法为: NumSamples * NumChannels * BitsPerSample/8;

    经过计算,当WAV包含PCM数据时,头文件的总长度为44位。

    PCM转WAV

    首次我们录制一份PCM文件,并在文件的头部提前预留了44byte的位置:

    // 创建AudioRecord
    AudioRecord(
        MediaRecorder.AudioSource.MIC,
        11025,
        AudioFormat.CHANNEL_IN_MONO,
        AudioFormat.ENCODING_PCM_16BIT,
        getMinBufferSize()
    )
    
    // 创建wav文件,并预留wav头文件的位置
    val mWavFile = File(mFile.absolutePath)
    mWriter = FileOutputStream(mWavFile).channel
    val fakehead = ByteArray(44)
    mWriter?.write(ByteBuffer.wrap(fakehead))
    
    // 写入录制音频
    while (isRecording) {
          val resultRead = audioRecord.read(byteArray, 0, byteArray.size)
          for (i in 0 until result) {
             mWriter.write(ByteBuffer.wrap(recordedBytes, 0, resultRead))
         }
    }
    
    

    上面是一份伪代码,我们录制了一份音频,并预留了wav头文件的位置,接下来我们根据之前的理解,填入wav的信息:

    fun getWaveFileHeader(
            totalAudioLen: Long,
            totalDataLen: Long, 
            longSampleRate: Long, 
            channels: Int, 
            byteRate: Long,
            bitsPerSample: Int
        )
    {
    
      val header = ByteArray(44)
      // 1. ChunkID:固定RIFF的ACSⅡ码,占4位;
      header[0] = 'R'.toByte() // RIFF/WAVE header
      header[1] = 'I'.toByte()
      header[2] = 'F'.toByte()
      header[3] = 'F'.toByte()
      //2. ChunkSize:文件的总长度,占4位,因为不包含ChunkID和ChunkSize的长度,所以要需要减8;
      // 因为int类型,所以我们需要对每一位byte对别保存int,跟之前的PCM的声道转换类似
      header[4] = (totalDataLen and 0xff).toByte()
      header[5] = (totalDataLen shr 8 and 0xff).toByte()
      header[6] = (totalDataLen shr 16 and 0xff).toByte()
      header[7] = (totalDataLen shr 24 and 0xff).toByte()   
      // 3. Format:固定WAVE的ASCⅡ码,占4位;
      header[8] = 'W'.toByte()  //WAVE
      header[9] = 'A'.toByte()
      header[10] = 'V'.toByte()
      header[11] = 'E'.toByte()
      // 4. Subchunk1 ID:fmt块,占4位,如果不足4位,补空格,所以是‘fmt ’;
      header[12] = 'f'.toByte()  // 'fmt ' chunk
      header[13] = 'm'.toByte()
      header[14] = 't'.toByte()
      header[15] = ' '.toByte()
     // 5. Subchunk Size:fmt块的总长度,pcm固定16,表示从当前位置到描述fmt信息的长度
     // 同理是int值,占4位
      header[16] = 16
      header[17] = 0
      header[18] = 0
      header[19] = 0
     // 6. AudioFormat:音频格式,PCM固定是1,占2位;
      header[20] = 1 // format = 1
      header[21] = 0
     // 7. NumChannels:声道数,占2位;
      header[22] = channels.toByte()
      header[23] = 0
     // 8. SampleRate:采样率,占4位;
      header[24] = (longSampleRate and 0xff).toByte()
      header[25] = (longSampleRate shr 8 and 0xff).toByte()
      header[26] = (longSampleRate shr 16 and 0xff).toByte()
      header[27] = (longSampleRate shr 24 and 0xff).toByte()    
     // 9. ByteRate:比特率,占4位;
      header[28] = (byteRate and 0xff).toByte()
      header[29] = (byteRate shr 8 and 0xff).toByte()
      header[30] = (byteRate shr 16 and 0xff).toByte()
      header[31] = (byteRate shr 24 and 0xff).toByte()
      //10. BlockAlign:计算方法为 NumChannels * BitsPerSample/8,占两位;
      header[32] = (channels * 16 / 8).toByte()
      header[33] = 0
     //11.  BitsPerSample:我们录制的格式,一个采样占几个byte,占2位;
      header[34] = bitsPerSample// bits per sample
      header[35] = 0
     //12.  Subchunk2ID:固定保存‘data’,占4位;
      header[36] = 'd'.toByte()  //data
      header[37] = 'a'.toByte()
      header[38] = 't'.toByte()
      header[39] = 'a'.toByte()
     //14.  Subchunk2Size:音频数据的长度
      header[40] = (totalAudioLen and 0xff).toByte()
      header[41] = (totalAudioLen shr 8 and 0xff).toByte()
      header[42] = (totalAudioLen shr 16 and 0xff).toByte()
      header[43] = (totalAudioLen shr 24 and 0xff).toByte()
    }
    

    根据我们录制的配置,我们可以对getWaveFileHeader方法传入一下参数:

    Util.getWaveFileHeader(
              mWriter.size() - 44, // totalAudioLen, 音频数据不包含wav头文件,所以减44
              mWriter.size() - 8, //  totalDataLen总长度,记得减8
              mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE).toLong(), // SampleRa
              mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT), // channels
              mediaFormat.getInteger(MediaFormat.KEY_BIT_RATE).toLong(), // byteRate
              16, // AudioFormat.ENCODING_PCM_16BIT = 16, AudioFormat.ENCODING_PCM_8BIT = 8
    )
    

    到此,我们录制的PCM数据已经变成了播放器可播的WAV格式。

    回采相关:

    智能语音识别产品如智能音箱的语音识别算法,需要进行回声消除处理。在此过程中,需要对智能音箱播放的[音频]数据进行数据回采,该回采数据作为参考信号再进行回声消除处理。

    智能音箱中的AEC

    智能音箱中必须有的AEC(回声消除) 功能, 主要目的就是区分哪些是人发出的声音,哪些是机器发出的声音.

    举个例子 : 音箱在播放音乐过程中, 这个时候再去通过唤醒词唤醒, 设备的麦克风是把人说话的声音和设备自身播放的音乐同时录到的, 这个时候设备需要提供参考信号, 用来区分人声和设备自身的音乐.


    image.png

    相关文章

      网友评论

          本文标题:音频开发知识收集整理

          本文链接:https://www.haomeiwen.com/subject/hgtczrtx.html