[Android开发备忘]关于AudioRecord和AudioTrack的缓冲区的理解,及避坑指南

文档说明:

  • 关于本人文章的说明
  • 本文档适合刚接触安卓录音开发的新手,大佬见笑。
  • 本文档是本人在做安卓开发时,使用 AudioRecordAudioTrack 实现音频的录制和播放的开发过程中,对缓冲区相关内容产生的不解与困惑,通过翻看文档、梳理思路,最终消化理解,特此记录,以作备忘。
  • 本文档不对 AudioRecordAudioTrack 的使用作讲解,如果你想要找的是这两者的使用教程,可以参考下面会提到的几篇文章。
  • 本文档仅根据本人经验记录编写,不代表多数人意见,不代表绝对的正确,仅供参考,如有问题,还请提出指教,谢谢。
  • 本文档如有补充,将会继续完善。

明确一些东西

采样率

       每1秒(1000毫秒)对声音信号的采样次数,如44.1kHZ,表示每1秒进行了44100次采样,即1000毫秒采集到了44100个样本。

产品需求

       公司的要求是,录制音频的同时绘制出当前获得音频数据的波形图,如果使用 AudioRecord.getMinBufferSize() 方法来计算缓冲区大小,不同型号的手机得到缓冲区的大小可能会不同,导致波形图绘制速度和最终样式会有很大的差异。

       为了解决这一问题,公司又作出了要求,获取录音数据和绘制波形图速度统一为每0.025秒(25毫秒)一次。所以,这里不能再使用 AudioRecord.getMinBufferSize() 方法来计算缓冲区大小,所有安卓设备都要使用统一大小的缓冲区。

采样个数sampleSize

       这个统一大小的缓冲区怎么计算呢?

       简单分析一下这一需求,25毫秒获取一次采样数据,1秒(1000毫秒)就会获取40次采样数据。

       采样率为44.1kHZ时,每1次获取到的样本个数就是1102个(44100 / 40 = 1102.5,取整)。

       采样率为48kHZ时,每1次获取到的样本个数就是1200个(48000 / 40 = 1200)。

       这里使用变量 sampleSize 来表示“每1次获取到的样本个数”,即每25毫秒获取到的采样个数。

缓冲区字节大小bufferSizeInBytes

       bufferSizeInBytes 是实例化 AudioRecordAudioTrack 时的一个入参,被称为“缓冲区字节大小”,这个缓冲区就是用来存放 sampleSize 个样本所需要的空间容量。

       即针对公司需求,采样率为44.1kHZ时, bufferSizeInBytes 就是能装下1102个样本的空间容量,采样率为48kHZ时,bufferSizeInBytes 就是能装下1200个样本的空间容量。

       注意,写到这里,我一直写的是多少多少“”样本,并没有代入空间容量的单位进行计算。

       好,接下来开始看会用到 bufferSizeInBytes 的地方,以及会遇到的坑。


实例化AudioRecord和AudioTrack

先翻看下官方文档

       我们先看一下 AudioRecord的构造函数

    public AudioRecord (int audioSource, 
        int sampleRateInHz, 
        int channelConfig, 
        int audioFormat, 
        int bufferSizeInBytes)

       官方对入参 bufferSizeInBytes 的描述是:

    the total size (in bytes) of the buffer where audio data is written to during the recording.

       再来看看 AudioTrack的构造函数

    public AudioTrack (AudioAttributes attributes, 
        AudioFormat format, 
        int bufferSizeInBytes, 
        int mode, 
        int sessionId)

       对入参 bufferSizeInBytes 的描述也是大同小异:

    the total size (in bytes) of the internal buffer where audio data is read from for playback.

重点来了

       请注意,这个入参的官方描述是:“以字节为单位”的缓冲区总大小。

       而上面说到的 sampleSize 只是样本的个数,并非字节数,要计算存放它们所需的空间容量还要乘以存放每个样本所需的空间容量,这取决于你用什么类型的数据来存放。

结论(敲黑板)

       在实例化 AudioRecordAudioTrack 时,缓冲区大小 bufferSizeInBytes 需要根据你在 audioRecord.read() 中定义的缓冲区变量(buffer数组)的类型来进行计算,对应如下:

    byte[]:bufferSizeInBytes = sampleSize * 1(1个byte型数据占用1个字节)
    short[]:bufferSizeInBytes = sampleSize * 2(1个short型数据占用2个字节)
    float[]:bufferSizeInBytes = sampleSize * 4(1个float型数据占用4个字节)

       兜了一圈,简单来说就是: bufferSizeInBytes 指的是缓冲区的字节数,计算方式就是采样个数 x 每个采样占用的字节数

       数据类型及其所占空间大小不清楚的,请 自行补课


       结束了?且慢!坑还没出现呢,继续。

       上面的实例化搞清楚了,接下来,看看音频录制时的情况。

音频录制时的坑

       接下来,有个地方,有可能会见到 bufferSizeInBytes 这个值,那就是录制过程中,录音数据的获取方法, audioRecord.read() 方法。

       为什么说“有可能会见到”呢?因为大多数刚开始接触安卓录音功能的萌新们(包括我),由于涉及到知识盲区,多多少少都会通过搜索,找到一些网上的相关文章作为参考借鉴。网上不同的文章,整体思路上大体相同,但变量命名和书写习惯上,大佬们都有自己的喜好,作为萌新的我们,懵懵懂懂就照着抄了,甚至直接复制粘贴使用。

先来参考下别人的代码

       比如本萌新刚好参考到的这篇(第一篇): Android 录音实现(AudioRecord) ,以及里面的跳转链接(第一篇的扩展版): 使用AudioRecord实现暂停录音功能 ,文章中,作者是这样写的:

    // 缓冲区字节大小
    private int bufferSizeInBytes = 0;
    ……
    
    // 获得缓冲区字节大小
    bufferSizeInBytes = AudioRecord.getMinBufferSize(sampleRateInHz,
        channelConfig, channelConfig);
    audioRecord = new AudioRecord(audioSource, sampleRateInHz, channelConfig, audioFormat, bufferSizeInBytes);
    
    // new一个byte数组用来存一些字节数据,大小为缓冲区大小
    byte[] audiodata = new byte[bufferSizeInBytes];
    ……
    
    //将录音状态设置成正在录音状态
    status = Status.STATUS_START;
    while (status == Status.STATUS_START) {
        readsize = audioRecord.read(audiodata, 0, bufferSizeInBytes);
        ……
    }

       首先定义了 bufferSizeInBytes 这个变量为“缓冲区字节大小”,使用 AudioRecord.getMinBufferSize() 获取缓冲区大小(第三个入参还给写错了,应该是 AudioRecord.getMinBufferSize(int sampleRateInHz, int channelConfig, int audioFormat) ),然后在实例化 AudioRecord 、定义缓冲区( audiodata )的大小和调用 audioRecord.read() 方法的第三个入参时,都使用了 bufferSizeInBytes 这个变量。

       再来看另一篇(第二篇): Android音频开发(2):使用AudioRecord录制pcm格式音频 ,这篇文章的作者又是这样写的:

    private int bufferSize;
    ……
    
    bufferSize = AudioRecord.getMinBufferSize(currentConfig.getFrequency(),
        currentConfig.getChannel(), currentConfig.getEncoding()) * RECORD_AUDIO_BUFFER_TIMES;
    audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, currentConfig.getFrequency(),
        currentConfig.getChannel(), currentConfig.getEncoding(), bufferSize);
    ……
    
    byte[] byteBuffer = new byte[bufferSize];
    
    while (state == RecordState.RECORDING) {
        //3.不断读取录音数据并保存至文件中
        int end = audioRecord.read(byteBuffer, 0, byteBuffer.length);
        ……
    }

       同样是使用了 AudioRecord.getMinBufferSize() 获取缓冲区大小 bufferSize ,然后在实例化 AudioRecord 、定义缓冲区( byteBuffer )的大小时,使用了 bufferSize 这个变量,在 audioRecord.read() 中,第三个入参是 byteBuffer.length ,不过因为 byte[] byteBuffer = new byte[bufferSize] ,所以 byteBuffer.length 也就等于 bufferSize ,换了个写法,意思一样。

这样写有什么问题吗?

       这样写其实一定程度上也没什么毛病,你照着敲了,或者直接复制粘贴过来,运行起来也是一切正常的。

       为什么说“一定程度上”呢?因为你在网上搜索到的大部分文章,都把缓冲区变量定义成了byte[]类型,参照上面说到的 bufferSizeInBytes 的计算方法,带入采样个数 sampleSize , 可以得出缓冲区定义的代码:

    实例化时的缓冲区大小:bufferSizeInBytes = sampleSize * 1;// 1个byte型数据占用1个字节
    定义缓冲区:byte[] buffer = new byte[sampleSize * 1];
    读取音频数据:readsize = audioRecord.read(audiodata, 0, sampleSize * 1);

       当缓冲区变量定义为byte[]时,1个byte数据对应1个字节,这种写法是没有问题的。

注意,坑来了

       当我把缓冲区变量定义为short[]时,先看计算方法:

    实例化时的缓冲区大小:bufferSizeInBytes = sampleSize * 2;// 1个short型数据占用2个字节
    定义缓冲区:short[] buffer = new short[sampleSize * 2];
    读取音频数据:readsize = audioRecord.read(audiodata, 0, sampleSize * 2);

       实例化时的缓冲区大小没有问题,但是获取采样数据时的缓冲区数组长度变成了原来的2倍,读取出的音频数据长度也变成了2倍,如果单从录音功能上来看,其实也没什么问题,代码能正常编译执行。

       然而,回到公司的要求,“每25毫秒获取一次采样数据”,上面的代码把缓冲区数组的长度( buffer.length )扩大了2倍,每一次获取到的样本个数就变成了原来的2倍,每次获取采样数据的时间也延长了2倍,成了“每50毫秒获取一次采样数据”,每一秒获取采样数据的次数也就减少了一半,从要求的40次变成了20次。

       同理,如果使用float[]来定义缓冲区,每次获取采样数据的时间延长了4倍,成了100毫秒获取一次,每一秒获取采样数据的次数也减少为四分之一,只有10次。

填坑

       一起来看看 入参是byte[]型的audioRecord.read()方法 ,对于第三个入参 sizeInBytes ,官方描述是:

    the number of requested bytes.

       直译过来就是,“请求的字节数”,好像没什么问题。

       好的,我们再来看看 入参是short[]型的audioRecord.read()方法sizeInShorts 的描述是:

     the number of requested shorts.

       同上,直译过来是,“请求的……shorts……的数量”?

       等等!好像有什么不对?如果这里理解成“请求的short[]数组中short数据的个数”,那上面byte[]入参的应该理解成“请求的byte[]数组中byte数据的个数”而非“请求的字节数”,虽然两者在数量上是相等的

       如果真是这样,那float……赶紧再看看 入参是float[]型的audioRecord.read()方法 ,果不其然,入参 sizeInFloats 的描述是:

    the number of requested floats.

结论(再敲黑板)

        audioRecord.read() 的第三个入参,并非请求数组的字节数,而是请求数组的数据个数,即数组长度,所以上面第二篇文档代码中使用的 byteBuffer.length 是比较严谨的、比较容易理解的写法。

       在使用byte[]定义缓冲区时, sampleSizebuffer.lengthbufferSizeInBytes 这三个值是相等的,所以就出现了第一篇文章中 bufferSizeInBytes 可以从头用到尾的写法,这种写法不是很严谨,容易误导人(我就被误导了)。


音频播放时的坑同上

学会了,先看官方文档

       上面的 audioRecord.read() 说完,我们趁热打铁,赶紧翻看一下官方文档中, audioTrack.write() 方法的第三个入参是不是也是这个理。

       一起来看看入参是 byte[]short[]float[]audioTrack.write() 方法, 对于第三的入参的官方描述分别是:

    sizeInBytes:the number of bytes to write in audioData after the offset.
    sizeInShorts:the number of shorts to read in audioData after the offset.
    sizeInFloats:the number of floats to write in audioData after the offset.

       (shorts的这个“read”有点迷啊,官方是这样写的,我也不敢乱改,先这样吧……)

       可以看到,跟 audioRecord.read() 如出一辙,传入的是数组的个数,即数组长度,而非数组占用的字节数大小。


总结一下

已知每秒获取采样数据次数的情况(即产品需求)

       1、根据每秒获取采样数据次数计算出每一次获取到的采样个数 sampleSize

       2、定义缓冲区变量( buffer )时,要使用每一次获取到的采样个数 sampleSize 作为数组长度(即 buffer.length = sampleSize )。

       3、实例化 AudioRecordAudioTrack 时,缓冲区字节大小 bufferSizeInBytes 要使用缓冲区数组( buffer )实际占用的空间字节数( buffer.length * buffer的数据类型占用的字节数 )。

       4、音频录制和播放时,调用 audioRecord.read()audioTrack.write() 方法的第三个入参,要使用缓冲区数组( buffer )的长度( buffer.length )。

代码书写建议

    int sampleSize = 采样率 / 每秒获取采样数据次数;// 每1次(25毫秒)获取到的样本个数
    
    /* 使用byte[]定义缓冲区 */
    byte[] buffer = new byte[sampleSize];// 定义缓冲区变量
    int bufferSizeInBytes = buffer.length * 1;// 用于实例化AudioRecord和AudioTrack的缓冲区字节大小
    audioRecord.read(buffer, 0 , buffer.length, AudioRecord.READ_BLOCKING);// 录制时读取音频数据
    audioTrack.write(buffer, 0 , buffer.length, AudioTrack.WRITE_BLOCKING);// MODE_STREAM模式播放时写入音频数据
    
    /* 使用short[]定义缓冲区 */
    short[] buffer = new short[sampleSize];// 定义缓冲区变量
    int bufferSizeInBytes = buffer.length * 2;// 用于实例化AudioRecord和AudioTrack的缓冲区字节大小
    audioRecord.read(buffer, 0 , buffer.length, AudioRecord.READ_BLOCKING);// 录制时读取音频数据
    audioTrack.write(buffer, 0 , buffer.length, AudioTrack.WRITE_BLOCKING);// MODE_STREAM模式播放时写入音频数据
    
    /* 使用float[]定义缓冲区 */
    float[] buffer = new float[sampleSize];// 定义缓冲区变量
    int bufferSizeInBytes = buffer.length * 4;// 用于实例化AudioRecord和AudioTrack的缓冲区字节大小
    audioRecord.read(buffer, 0 , buffer.length, AudioRecord.READ_BLOCKING);// 录制时读取音频数据
    audioTrack.write(buffer, 0 , buffer.length, AudioTrack.WRITE_BLOCKING);// MODE_STREAM模式播放时写入音频数据

对采样不做要求的情况

       1、实例化 AudioRecordAudioTrack 时,使用AudioRecord.getMinBufferSize() 方法来计算缓冲区字节大小 bufferSizeInBytes

       2、定义缓冲区变量( buffer )时,数组长度使用缓冲区字节大小可以容纳的数组个数( bufferSizeInBytes / buffer的数据类型占用的字节数 ),即反推出获取到的采样个数 sampleSize

       3、音频录制和播放时,调用 audioRecord.read()audioTrack.write() 方法的第三个入参,要使用缓冲区数组( buffer )的长度( buffer.length )。

代码书写建议

    /* 使用byte[]定义缓冲区 */
    int bufferSizeInBytes = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding);
    int sampleSize = bufferSizeInBytes / 1;// 缓冲区可容纳的采样个数
    byte[] buffer = new byte[sampleSize];// 定义缓冲区变量
    audioRecord.read(buffer, 0 , buffer.length, AudioRecord.READ_BLOCKING);// 录制时读取音频数据
    audioTrack.write(buffer, 0 , buffer.length, AudioTrack.WRITE_BLOCKING);// MODE_STREAM模式播放时写入音频数据
    
    /* 使用short[]定义缓冲区 */
    int bufferSizeInBytes = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding);
    int sampleSize = bufferSizeInBytes / 2;// 缓冲区可容纳的采样个数
    short[] buffer = new short[sampleSize];// 定义缓冲区变量
    audioRecord.read(buffer, 0 , buffer.length, AudioRecord.READ_BLOCKING);// 录制时读取音频数据
    audioTrack.write(buffer, 0 , buffer.length, AudioTrack.WRITE_BLOCKING);// MODE_STREAM模式播放时写入音频数据
    
    /* 使用float[]定义缓冲区 */
    int bufferSizeInBytes = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding);
    int sampleSize = bufferSizeInBytes / 4;// 缓冲区可容纳的采样个数
    float[] buffer = new float[sampleSize];// 定义缓冲区变量
    audioRecord.read(buffer, 0 , buffer.length, AudioRecord.READ_BLOCKING);// 录制时读取音频数据
    audioTrack.write(buffer, 0 , buffer.length, AudioTrack.WRITE_BLOCKING);// MODE_STREAM模式播放时写入音频数据

       好了,录制和播放都说完了,到这里应该结束了吧。

       如果你的开发内容不涉及音频数据相关的计算,那么下面的内容就不用看了。

音频录制和读取,缓冲区同步的坑

byte[ ]、short[ ]和float[ ]的转换

       为了保证音频录制时和读取时的波形图一致,录制和读取时每一次获取的数据量一致,因为波形图的振幅是通过每次读取的数据来计算的。

       在写入和读取录音文件的时候, FileOutputStream的write()方法DataInputStream的read()方法 只能写入和读取byte[]数据,但byte[]数据不能用来计算振幅(均方根),需要转换为short[]或者float[]来进行计算。

       我们来看看byte[]转short[]的算法:

    public static short[] bytes2Shorts(byte[] bytes) {
        if (null == bytes) {
            return null;
        }
        short[] shorts = new short[bytes.length / 2];
        ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(shorts);
        return shorts;
    }

       这里我们不讨论算法本身是怎么实现的,来看初始化short[]这句:

    short[] shorts = new short[bytes.length / 2];

       可以看出,byte[]转为short[]后,数组长度为原来的1/2,即振幅的总个数为写入文件的byte[]长度的1/2,再来看byte[]转float[]的算法:

    public static float[] bytes2Floats(byte[] bytes) {
        if (null == bytes) {
            return null;
        }
        float[] floats = new float[bytes.length / 4];
        for (int index = 0; index < bytes.length; index += 4) {
            float f = Float.intBitsToFloat(toInt(bytes, index));
            floats[index / 4] = f;
        }
        return floats;
    }

       还是看初始化float[]这句:

    float[] floats = new float[bytes.length / 4];

       可以看出byte[]转float[]后,数组长度为原来的1/4,即振幅的总个数为写入文件的byte[]长度的1/4。

结论(最后一次敲黑板)

       1、如果你在音频录制时定义的缓冲区是short[]或者float[]类型的,需要读取跟录制时相同的数据做处理,那么,在读取音频文件数据时,由于 DataInputStream 的缓冲区只能定义为byte[],因此读取时缓冲区长度应该为录制时缓冲区数组长度的2倍(short[])或4倍(float[])。也就是为了获取到与录制时同样长度的short[]或者float[],需要读取2倍(short[])或4倍(float[])长度的byte[]进行转换。

       代码示范,以short[]为例,音频录制时:

    short[] shorts = new short[sampleSize];// 定义缓冲区
    audioRecord.read(shorts, 0, shorts.length, AudioRecord.READ_BLOCKING);// 获取录音数据
    double rms = AudioUtil.getRMS(shorts);// 计算均方根(方法略)
    byte[] bytes = ByteUtil.shorts2Bytes(shorts);// short[]转byte[]
    fos.write(bytes, 0, bytes.length);// 写入到文件(fos即FileOutputStream的实例)

       音频文件读取时:

    byte[] bytes = new byte[与录制时相等的sampleSize * 2];// 定义缓冲区
    readCount = dis.read(bytes, 0, bytes.length);// 读取音频文件数据(dis即DataInputStream的实例)
    short[] shorts = ByteUtil.bytes2Shorts(bytes);// byte[]转short[]
    double rms = AudioUtil.getRMS(shorts);// 计算均方根(方法略)
    audioTrack.write(shorts, 0 , shorts.length, AudioTrack.WRITE_BLOCKING);// MODE_STREAM模式播放时写入音频数据

       2,如果你在音频录制时定义的缓冲区是byte[]类型的,并且你需要将读取到的音频数据转换成short[]或者float[]进行一些相应的计算,那么,录制时缓冲区的大小也需要设为采样个数 sampleSize 的2倍(short[])或4倍(float[]),读取时同结论1。


       先记录这么多吧,欢迎讨论指正。

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