AudioRecord 录制详解

IT小顽童2018-12-07 08:15:30

微信公众号:IT小顽童
最近公司的一个需求,集合了录音、暂停、停止、完成、播放、重录、计时等功能,没找到合适的资源,大部分都不支持暂停,就自己整理并整合了一个,参考官方文档和一些资料后,撸了个库,现在做一些说明和解析。

官方文档地址:https://developer.android.google.cn/reference/android/media/AudioRecord

GitHub 地址:https://github.com/zhoumeng1990/AudioAnalyze

之前除了搞 FFmpeg 那段时间,音频这块接触的也不多,最近用 Android 原生去实现,并做下笔记,配合着已经上传 GitHub 上的代码,观赏性会更好一点,目前上传了 java 和 kotlin 版本,功能都已实现,并在实际项目中运行。本篇主要采用 java 去讲解,kotlin 版本可自行参照,注释和实现方式都一样。


1、简单对比AudioRecordMediaRecorder2、专业名词说明采样率声道采样位深构造函数补充3、实现流程流程图初始化 AudioRecord 对象开始录音暂停/继续录制停止录音释放资源完成、合成、转码播放释放资源重置

1、简单对比

AudioRecord 和 MediaRecorder 两种都可以录制音频,MediaRecorder 基于文件录音,系统已实现大量的封装,集成了录音、编码、压缩等,支持少量的音频格式文件,操作起来更加简单,而 AudioRecord 基于字节流录音,AudioTrack 更接近底层,使用起来更加灵活,能实现更多的功能。因为考虑到需要对音频进行一系列处理,所以嘛,最后选择了 AudioRecord。如下是简单的对比:

AudioRecord

优点:可以实现语音的实时处理,进行边录边播,对音频的实时处理,AudioTrack 更接近底层。
缺点:输出的是PCM的语音数据,如果保存成音频文件是不能被播放器播放的,需要用 AudioTrack 来播放。API还有待完善,常见的暂停功能都不支持。

MediaRecorder

优点:系统封装的完整,直接调用即可,操作简单,录制的音频文件可以用系统自带的播放器播放。
缺点:无法实现实时处理音频,输出的音频格式少。录制的音频文件是经过压缩后的,需要设置编码器。

今天只做 AudioRecord 的一些说明,MediaRecorder 不做过多说明。


2、专业名词说明

采样率

  • 采样率:采样率即采样频率,指每秒钟取得声音样本的次数,采样频率越高,能表现的频率范围就越大,音质就会越好,声音的还原度也更真实,但此同时带来的弊端是占有的内存资源也会越大。因为人耳的分辨率有限,并不是频率越高越好,44KHz已相当于CD音质了,目前的常用采样频率都不超过48KHz。

声道

  • 声道:这个好理解,生活中也经常听到单声道、双声道等,在Android系统中,可以通过设置音频的录制的声道 CHANNEL_IN_STEREO 为双声道,CHANNEL_CONFIGURATION_MONO 为单声道,双声道音质更加,但同样伴随着内存资源消耗更大的弊端。

采样位深

  • 采样位深:位深度也叫采样位深,音频的位深度决定动态范围,它是用来衡量声音波动变化的一个参数,也可以说是声卡的分辨率。它的数值越大,分辨率也就越高,所发出声音的能力越强。在计算机中采样位数一般有8位和16位之分,即分成2的8次方和2的16次方之分,PCM 16位每个样本,保证设备支持。PCM 8位每个样本,不一定能得到设备支持。

构造函数

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

audioSource:录音源,指定声音是从哪里录制的(官网文档查看原文)
sampleRateInHz:采样率
channelConfig:声道数
audioFormat:采样位深
bufferSizeInBytes:最小缓冲大小,可以通过 getMinBufferSize 获取。

补充

  • 补充:存储量= 采样率 * 采样时间 * 采样位深 / 8 * 声道数(Bytes)。以采样率为44.1kHZ、采样位深为16位、双声道计算,一分钟消耗的内存为10.335M。


3、实现流程

流程图

初始化 AudioRecord 对象

    /**
     * 创建默认的录音对象
     *
     * @param fileName 文件名
     */

    public void createDefaultAudio(String fileName) {
        // 获得缓冲区字节大小
        bufferSizeInBytes = AudioRecord.getMinBufferSize(AUDIO_SAMPLE_RATE, AUDIO_CHANNEL, AUDIO_ENCODING);
        audioRecord = new AudioRecord(AUDIO_INPUT, AUDIO_SAMPLE_RATE, AUDIO_CHANNEL, AUDIO_ENCODING, bufferSizeInBytes);
        this.fileName = fileName;
        status = AudioStatus.STATUS_READY;

        AudioAttributes audioAttributes = new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build();

        AudioFormat audioFormat = new AudioFormat.Builder().setSampleRate(AUDIO_SAMPLE_RATE)
                .setEncoding(AUDIO_ENCODING).setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build();

        audioTrack = new AudioTrack(audioAttributes, audioFormat, bufferSizeInBytes,
                AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE);
    }

以上参数已经介绍完,此处不做多余解释。

开始录音

    /**
     * 开始录音
     */

    public void startRecord({
        if (status == AudioStatus.STATUS_NO_READY || TextUtils.isEmpty(fileName)) {
            throw new IllegalStateException("请检查录音权限");
        }
        if (status == AudioStatus.STATUS_START) {
            throw new IllegalStateException("正在录音");
        }
        audioRecord.startRecording();
        cachedThreadPool.execute(new Runnable() {
            @Override
            public void run(
{
                writeDataTOFile();
            }
        });
    }
    /**
     * 将音频信息写入文件
     */

    private void writeDataTOFile({
        // new一个byte数组用来存一些字节数据,大小为缓冲区大小
        byte[] audioData = new byte[bufferSizeInBytes];
        FileOutputStream fos = null;
        int readSize = 0;
        try {
            String currentFileName = fileName;
            if (status == AudioStatus.STATUS_PAUSE) {
                //假如是暂停录音 将文件名后面加个数字,防止重名文件内容被覆盖
                currentFileName += filesName.size();
            }
            filesName.add(currentFileName);
            File file = new File(FileUtils.getPcmFileAbsolutePath(currentFileName));
            if (file.exists()) {
                file.delete();
            }
            // 建立一个可存取字节的文件
            fos = new FileOutputStream(file);
        } catch (IllegalStateException e) {
            e.printStackTrace();
            throw new IllegalStateException(e.getMessage());
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        //将录音状态设置成正在录音状态
        status = AudioStatus.STATUS_START;
        while (status == AudioStatus.STATUS_START) {
            readSize = audioRecord.read(audioData, 0, bufferSizeInBytes);
            if (AudioRecord.ERROR_INVALID_OPERATION != readSize && fos != null) {
                try {
                    fos.write(audioData);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        try {
            if (fos != null) {
                fos.close();// 关闭写入流
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

暂停/继续录制

    /**
     * 暂停录音
     */

    public void pauseRecord() {
        if (status != AudioStatus.STATUS_START) {
            throw new IllegalStateException("没有在录音");
        } else {
            audioRecord.stop();
            status = AudioStatus.STATUS_PAUSE;
        }
    }

说明:为啥说好的是暂停,此处确实停止?上文也曾提到过,AudioRecord没有提供暂停的API,目前所实现的的暂停,就是不断的使用停止和再次录制,形成多个文件,然后在合并成一个文件并转码成wav,最终形成 AudioFormat 可读取的文件,以此来达到暂停和在录制的功能。

停止录音

    /**
     * 停止录音
     */

    public void stopRecord() {
        if (status == AudioStatus.STATUS_NO_READY || status == AudioStatus.STATUS_READY) {
            throw new IllegalStateException("录音尚未开始");
        } else {
            audioRecord.stop();
            status = AudioStatus.STATUS_STOP;
            release();
        }
    }

执行此方法后,便赋值给status,以此来改变状态。

释放资源

    /**
     * 释放资源
     */

    public void release({
        //假如有暂停录音
        try {
            if (filesName.size() > 0) {
                List<String> filePaths = new ArrayList<>();
                for (String fileName : filesName) {
                    filePaths.add(FileUtils.getPcmFileAbsolutePath(fileName));
                }
                //清除
                filesName.clear();
                if (isReset) {
                    isReset = false;
                    FileUtils.clearFiles(filePaths);
                } else {
                    //将多个pcm文件转化为wav文件
                    pcmFilesToWavFile(filePaths);
                }
            }
        } catch (IllegalStateException e) {
            throw new IllegalStateException(e.getMessage());
        }

        if (audioRecord != null) {
            audioRecord.release();
            audioRecord = null;
        }
        status = AudioStatus.STATUS_NO_READY;
    }

完成、合成、转码

/**
 * Created by ZhouMeng on 2018/8/31.
 * 将pcm文件转化为wav文件
 * pcm是无损wav文件中音频数据的一种编码方式,pcm加上wav文件头就可以转为wav格式,但wav还可以用其它方式编码。
 * 此类就是通过给pcm加上wav的文件头,来转为wav格式
 */

public class PcmToWav {
    /**
     * 合并多个pcm文件为一个wav文件
     * @param filePathList    pcm文件路径集合
     * @param destinationPath 目标wav文件路径
     * @return true|false
     */

    public static boolean mergePCMFilesToWAVFile(List<String> filePathList, String destinationPath) {
        File[] file = new File[filePathList.size()];
        byte buffer[] = null;

        int TOTAL_SIZE = 0;
        int fileNum = filePathList.size();

        for (int i = 0; i < fileNum; i++) {
            file[i] = new File(filePathList.get(i));
            TOTAL_SIZE += file[i].length();
        }

        // 填入参数,比特率等等。这里用的是16位单声道 8000 hz
        WaveHeader header = new WaveHeader();
        // 长度字段 = 内容的大小(TOTAL_SIZE) + 头部字段的大小(不包括前面4字节的标识符RIFF以及fileLength本身的4字节)
        header.fileLength = TOTAL_SIZE + (44 - 8);
        header.FmtHdrLeth = 16;
        header.BitsPerSample = 16;
        header.Channels = 2;
        header.FormatTag = 0x0001;
        header.SamplesPerSec = 8000;
        header.BlockAlign = (short) (header.Channels * header.BitsPerSample / 8);
        header.AvgBytesPerSec = header.BlockAlign * header.SamplesPerSec;
        header.DataHdrLeth = TOTAL_SIZE;

        byte[] h = null;
        try {
            h = header.getHeader();
        } catch (IOException e1) {
            Log.e("PcmToWav", e1.getMessage());
            return false;
        }

        // WAV标准,头部应该是44字节,如果不是44个字节则不进行转换文件
        if (h.length != 44) {
            return false;
        }

        //先删除目标文件
        File destFile = new File(destinationPath);
        if (destFile.exists()) {
            destFile.delete();
        }

        //合成所有的pcm文件的数据,写到目标文件
        try {
            buffer = new byte[1024 * 4]; // Length of All Files, Total Size
            InputStream inStream = null;
            OutputStream ouStream = null;

            ouStream = new BufferedOutputStream(new FileOutputStream(
                    destinationPath));
            ouStream.write(h, 0, h.length);
            for (int j = 0; j < fileNum; j++) {
                inStream = new BufferedInputStream(new FileInputStream(file[j]));
                int size = inStream.read(buffer);
                while (size != -1) {
                    ouStream.write(buffer);
                    size = inStream.read(buffer);
                }
                inStream.close();
            }
            ouStream.close();
        } catch (IOException ioe) {
            ioe.getMessage();
            return false;
        }
        FileUtils.clearFiles(filePathList);
//        File wavFile = new File(new File(destinationPath).getParent());
//        if (wavFile.exists()) {
//            FileUtils.deleteFile(wavFile);
//        }

        return true;
    }
}
/**
 * Created by ZhouMeng on 2018/8/31.
 * wav文件头
 */

public class WaveHeader {
    public final char fileID[] = {'R''I''F''F'};
    public int fileLength;
    public char wavTag[] = {'W''A''V''E'};
    public char FmtHdrID[] = {'f''m''t'' '};
    public int FmtHdrLeth;
    public short FormatTag;
    public short Channels;
    public int SamplesPerSec;
    public int AvgBytesPerSec;
    public short BlockAlign;
    public short BitsPerSample;
    public char DataHdrID[] = {'d','a','t','a'};
    public int DataHdrLeth;

    public byte[] getHeader() throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        WriteChar(bos, fileID);
        WriteInt(bos, fileLength);
        WriteChar(bos, wavTag);
        WriteChar(bos, FmtHdrID);
        WriteInt(bos,FmtHdrLeth);
        WriteShort(bos,FormatTag);
        WriteShort(bos,Channels);
        WriteInt(bos,SamplesPerSec);
        WriteInt(bos,AvgBytesPerSec);
        WriteShort(bos,BlockAlign);
        WriteShort(bos,BitsPerSample);
        WriteChar(bos,DataHdrID);
        WriteInt(bos,DataHdrLeth);
        bos.flush();
        byte[] r = bos.toByteArray();
        bos.close();
        return r;
    }

    private void WriteShort(ByteArrayOutputStream bos, int s) throws IOException {
        byte[] myByte = new byte[2];
        myByte[1] =(byte)( (s << 16) >> 24 );
        myByte[0] =(byte)( (s << 24) >> 24 );
        bos.write(myByte);
    }

    private void WriteInt(ByteArrayOutputStream bos, int n) throws IOException {
        byte[] buf = new byte[4];
        buf[3] =(byte)( n >> 24 );
        buf[2] =(byte)( (n << 8) >> 24 );
        buf[1] =(byte)( (n << 16) >> 24 );
        buf[0] =(byte)( (n << 24) >> 24 );
        bos.write(buf);
    }

    private void WriteChar(ByteArrayOutputStream bos, char[] id) {
        for (char c : id) {
            bos.write(c);
        }
    }
}

4字节数据,内容为“RIFF”,表示资源交换文件标识
4字节数据,内容为一个整数,表示从下个地址开始到文件尾的总字节数
4字节数据,内容为“WAVE”,表示WAV文件标识
4字节数据,内容为“fmt ”,表示波形格式标识(fmt ),最后一位空格。
4字节数据,内容为一个整数,表示PCMWAVEFORMAT的长度
2字节数据,内容为一个短整数,表示格式种类(值为1时,表示数据为线性PCM编码)
2字节数据,内容为一个短整数,表示通道数,单声道为1,双声道为2
4字节数据,内容为一个整数,表示采样率,比如44100
4字节数据,内容为一个整数,表示波形数据传输速率(每秒平均字节数),大小为 采样率 * 通道数 * 采样位深
 2字节数据,内容为一个短整数,表示DATA数据块长度,大小为 通道数 * 采样位深
2字节数据,内容为一个短整数,表示采样位数,即PCM位宽,通常为8位或16位
4字节数据,内容为“data”,表示数据标记符
4字节数据,内容为一个整数,表示接下来声音数据的总大小

播放

    /**
     * 播放合成后的wav文件
     *
     * @param filePath 文件的绝对路径
     */

    public void play(final String filePath) {
        audioTrack.play();

        cachedThreadPool.execute(new Runnable() {

            @Override
            public void run() {
                File file = new File(filePath);
                FileInputStream fis = null;
                try {
                    fis = new FileInputStream(file);
                } catch (FileNotFoundException e) {
                    e.printStackTrace();
                }
                byte[] buffer = new byte[bufferSizeInBytes];
                while (fis != null) {
                    try {
                        int readCount = fis.read(buffer);
                        if (readCount == AudioTrack.ERROR_INVALID_OPERATION || readCount == AudioTrack.ERROR_BAD_VALUE) {
                            continue;
                        }
                        if (readCount != 0 && readCount != -1) {
                            audioTrack.write(buffer, 0, readCount);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
    }

释放资源

    /**
     * 释放audioTrack
     */

    public void releaseAudioTrack(){
        if (audioTrack == null) {
            return;
        }
        if (audioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            audioTrack.stop();
        }
        audioTrack.release();
        audioTrack = null;
    }

此处释放的资源,主要是audioTrack对象,在这之前已经释放过一次资源,上一次释放的为audioRecord对象相关。

重置

    /**
     * 重置,删除所有的pcm文件
     */

    private boolean isReset = false;

    public void setReset() {
        isReset = true;
    }

主要通过设置 isReset 参数来实现重置,此操作就是执行停止的操作,在停止的过程中做判断,把录制好的文件都删除,不在走合并和转码的过程,以此来达到重置的效果。

本篇就先到这里,后续还会有一篇讲解这过程中遇到的问题和解决方式。


微信扫我


Copyright © 温县电话机虚拟社区@2017