Android录制音频并使用Lame转成mp3

这篇文章主要介绍在Android平台上使用AudioRecord采集声音数据,采集到的数据是PCM格式的,由于需要上传以及在其他平台设备上播放,所以使用Lame库将PCM数据进行编码转成Mp3格式,有关于声音采集的基础知识可以参考这篇笔记声音采集-笔记

声音录制

Android中使用AudioRecord录制声音,根据上面讲述的声音采集原理,需要传递给AudioRecord采样频率、采样位数和声道数,除此之外还需要传入两个参数,一个是声音源,一个是缓冲区大小。

权限

在Android中录制声音需要相应的权限,6.0需要动态申请权限。

<uses-permission android:name="android.permission.RECORD_AUDIO" />

初始化AudioRecord

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

声音源(在MediaRecorder.AudioSource中进行定义),支持的音频源有如下几种,这里我们使用的是MIC。

/** 默认声音 **/
public static final int DEFAULT = 0;
/** 麦克风声音 */
public static final int MIC = 1;
/** 通话上行声音 */
public static final int VOICE_UPLINK = 2;
/** 通话下行声音 */
public static final int VOICE_DOWNLINK = 3;
/** 通话上下行声音 */
public static final int VOICE_CALL = 4;
/** 根据摄像头转向选择麦克风*/
public static final int CAMCORDER = 5;
/** 对麦克风声音进行声音识别,然后进行录制 */
public static final int VOICE_RECOGNITION = 6;
/** 对麦克风中类似ip通话的交流声音进行识别,默认会开启回声消除和自动增益 */
public static final int VOICE_COMMUNICATION = 7;
/** 录制系统内置声音 */
public static final int REMOTE_SUBMIX = 8;
sampleRateInHz

第二个参数就是采样频率

44100Hz is currently the only
     *   rate that is guaranteed to work on all devices, but other rates such as 22050,
     *   16000, and 11025 may work on some devices.

根据文档可以看到,Android系统要求所有的设备都要支持44100HZ的采样频率,而其他的在一些设备上不一定支持。

8000, 11025, 16000, 22050, 44100, 48000

上面是一些常用的采样频率,可以通过如下代码获取手机支持的音频采样率:

public void getValidSampleRates() {
    for (int rate : new int[] {8000, 11025, 16000, 22050, 44100}) {  // add the rates you wish to check against
        int bufferSize = AudioRecord.getMinBufferSize(rate, AudioFormat.CHANNEL_CONFIGURATION_DEFAULT, AudioFormat.ENCODING_PCM_16BIT);
        if (bufferSize > 0) {
            // buffer size is valid, Sample rate supported

        }
    }
}
channelConfig
See {@link AudioFormat#CHANNEL_IN_MONO} and
     *   {@link AudioFormat#CHANNEL_IN_STEREO}.  {@link AudioFormat#CHANNEL_IN_MONO} is guaranteed
     *   to work on all devices.

MONO是单声道,而STEREO是立体声,想要在所有设备上都适用的话,推荐使用单声道。

audioFormat

即我们所说的采样位数。

 See {@link AudioFormat#ENCODING_PCM_8BIT}, {@link AudioFormat#ENCODING_PCM_16BIT},
     *   and {@link AudioFormat#ENCODING_PCM_FLOAT}.

常用的是ENCODING_PCM_8BIT,和ENCODING_PCM_16BIT,ENCODING_PCM_16BIT能够兼容大多数设备。
想要进一步了解PCM格式的编码的可以看雷神的这篇文章。

视音频数据处理入门:PCM音频采样数据处理

bufferSizeInBytes

缓冲区的大小,采集到的数据会先写到缓冲区,之后从缓冲区中读取数据,从而获取到麦克风录制的音频数据。在Android中不同的声道数、采样位数和采样频率会有不同的最小缓冲区大小,当AudioRecord传入的缓冲区大小小于最小缓冲区大小的时候则会初始化失败。大的缓冲区大小可以达到更为平滑的录制效果,相应的也会带来更大一点的延时。

mBufferSize=AudioRecord.getMinBufferSize(sampleRateInHz,
                channelConfig, audioFormat);

通过上面的代码可以获取到最小缓冲区的大小。
在我们自己使用lame对pcm数据进行编码时,需要周期性的通知,所以需要将bufferSize像上取整到满足周期的大小。

private static final int FRAME_COUNT = 160;
/**
*bytesPerFrame
*PCM_8BIT 1字节
*PCM_16BIT 2字节
**/
int frameSize = mBufferSize / bytesPerFrame;
if (frameSize % FRAME_COUNT != 0) {
    frameSize += (FRAME_COUNT - frameSize % FRAME_COUNT);
    mBufferSize = frameSize * bytesPerFrame;
}

读取数据

AudioRecord可以通过下面的方法进行数据读取。读取失败的话会返回失败码。

public int read(@NonNull byte[] audioData, int offsetInBytes, int sizeInBytes) {    
        return read(audioData, offsetInBytes, sizeInBytes, READ_BLOCKING);
}

监听AudioRecord进行转码

给AudioRecord设置刷新监听,待录音帧数每次达到FRAME_COUNT,就通知转换线程转换一次数据。

audioRecord.setRecordPositionUpdateListener(OnRecordPositionUpdateListener listener, Handler handler);
audioRecord.setPositionNotificationPeriod(FRAME_COUNT);

在OnRecordPositionUpdateListener的onPeriodicNotification(AudioRecord recorder)的回调方法中就可以使用Lame对读取到的数据进行编码,然后写入文件。

导入lame库

Android studio已经支持使用CMake了,所以这里就使用CMake来集成lame。如何创建项目可以参考我之前的这篇文章《android opencv JNI开发环境搭建》

下载Lame源码

下载地址

修改Lame内容
  1. 下载完之后解压,然后找到libmp3lame文件夹,将里面的.c和.h文件全部复制到项目的cpp目录中。
    注意:libmp3lame文件夹内还包含其他文件夹,不用管它。
    然后,再找到include文件夹,将lame.h文件拷贝到cpp目录中。(总共43个文件)
  2. 接下来需要将源文件导入到项目中修改CMakeLists将Lame的源码加入。
aux_source_directory(src/main/cpp/libmp3lame SRC_LIST)

add_library(lamemp3
             SHARED
             src/main/cpp/native-lib.cpp
              ${SRC_LIST})

3.移植修改
首先,需要对lame中的三个文件进行一些小改动。

  • fft.c中47行将vector/lame_intrin.h这个头文件注释了或者去掉
#ifdef HAVE_CONFIG_H
# include <config.h>
#endif

#include "lame.h"
#include "machine.h"
#include "encoder.h"
#include "util.h"
#include "fft.h"

//#include "vector/lame_intrin.h"
  • 修改set_get.h文件的24行的#include“lame.h”
#ifndef __SET_GET_H__
#define __SET_GET_H__

#include "lame.h"
  • 将util.h文件的574行的”extern ieee754_float32_t fast_log2(ieee754_float32_t x);”
    替换为 “extern float fast_log2(float x);”因为android下不支持该类型。

这些跟ndk-builde是一样的,网上有很多教程。
然后,需要修改app -> build.gradle文件

android {
...
    defaultConfig {
    ...
        externalNativeBuild{
            cmake{
                cFlags "-DSTDC_HEADERS"
            }
        }
    }
}

添加-D标志的意思就是给编译器添加宏定义。那么-DSTDC_HEADERS就相当于给项目增加一句"#define STDC_HEADERS"。
我们打开machine.h文件看一下第34行:

#ifdef STDC_HEADERS
# include <stdlib.h>
# include <string.h>
#else
# ifndef HAVE_STRCHR
#  define strchr index
#  define strrchr rindex
# endif
char   *strchr(), *strrchr();
# ifndef HAVE_MEMCPY
#  define memcpy(d, s, n) bcopy ((s), (d), (n))
#  define memmove(d, s, n) bcopy ((s), (d), (n))
# endif
#endif

意思很明白,如果没有定义STDC_HEADERS这个宏则会用到bcopy方法,而这个方法我们根本没有,于是就报错了。

测试

打开native-lib.cpp文件,进行修改

extern "C"
JNIEXPORT jstring

JNICALL
Java_zeller_com_mp3recorder_MainActivity_stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(get_lame_version());
}

app中显示Lame的版本信息说明导入Lame库成功。

编写JNI代码

我们需要Lame提供如下几个方法供Java层调用

    public native static void close();

    public native static int encode(short[] buffer_l, short[] buffer_r, int samples, byte[] mp3buf);

    public native static int flush(byte[] mp3buf);

    public native static void init(int inSampleRate, int outChannel, int outSampleRate, int outBitrate, int quality);
init方法,初始化Lame
static lame_global_flags *glf = NULL;

extern "C"
JNIEXPORT void JNICALL
Java_zeller_com_mp3recorder_Utils_LameUtils_init(JNIEnv *env, jclass type, jint inSampleRate,
                                                 jint outChannel, jint outSampleRate,
                                                 jint outBitrate, jint quality) {
    if (glf != NULL) {
        lame_close(glf);
        glf = NULL;
    }
    glf = lame_init();
    lame_set_in_samplerate(glf, inSampleRate);
    lame_set_num_channels(glf, outChannel);
    lame_set_out_samplerate(glf, outSampleRate);
    lame_set_brate(glf, outBitrate);
    lame_set_quality(glf, quality);
    lame_init_params(glf);
}
encode方法,将PCM编码成MP3格式
extern "C"
JNIEXPORT jint JNICALL
Java_zeller_com_mp3recorder_Utils_LameUtils_encode(JNIEnv *env, jclass type, jshortArray buffer_l_,
                                                   jshortArray buffer_r_, jint samples,
                                                   jbyteArray mp3buf_) {
    jshort *buffer_l = env->GetShortArrayElements(buffer_l_, NULL);
    jshort *buffer_r = env->GetShortArrayElements(buffer_r_, NULL);
    jbyte *mp3buf = env->GetByteArrayElements(mp3buf_, NULL);

    const jsize mp3buf_size = env->GetArrayLength(mp3buf_);

    int result =lame_encode_buffer(glf, buffer_l, buffer_r, samples, (u_char*)mp3buf, mp3buf_size);

    env->ReleaseShortArrayElements(buffer_l_, buffer_l, 0);
    env->ReleaseShortArrayElements(buffer_r_, buffer_r, 0);
    env->ReleaseByteArrayElements(mp3buf_, mp3buf, 0);

    return result;
}
flush方法

将MP3结尾信息写入buffer中

extern "C"
JNIEXPORT jint JNICALL
Java_zeller_com_mp3recorder_Utils_LameUtils_flush(JNIEnv *env, jclass type, jbyteArray mp3buf_) {
    jbyte *mp3buf = env->GetByteArrayElements(mp3buf_, NULL);

    const jsize  mp3buf_size = env->GetArrayLength(mp3buf_);

    int result = lame_encode_flush(glf, (u_char*)mp3buf, mp3buf_size);

    env->ReleaseByteArrayElements(mp3buf_, mp3buf, 0);

    return result;
}
close方法
extern "C"
JNIEXPORT void JNICALL
Java_zeller_com_mp3recorder_Utils_LameUtils_close(JNIEnv *env, jclass type) {
    lame_close(glf);
    glf = NULL;
}

Java层代码

Jni层的事情到这里就做完了,接下来就交给Java层去做了。

初始化

首先需要对AudioRecord以及Lame进行初始化,初始化需要的参数在前面已经分析过。初始化完之后设置监听,周期性的对数据进行重新编码,编码的操作需要放在一个新的线程中完成。

private void initAudioRecorder() throws IOException {
        mBufferSize = AudioRecord.getMinBufferSize(DEFAULT_SAMPLING_RATE,
                DEFAULT_CHANNEL_CONFIG, DEFAULT_AUDIO_FORMAT.getAudioFormat());
        
        int bytesPerFrame = DEFAULT_AUDIO_FORMAT.getBytesPerFrame();
        /* Get number of samples. Calculate the buffer size 
         * (round up to the factor of given frame size) 
         * 使能被整除,方便下面的周期性通知
         * */
        int frameSize = mBufferSize / bytesPerFrame;
        if (frameSize % FRAME_COUNT != 0) {
            frameSize += (FRAME_COUNT - frameSize % FRAME_COUNT);
            mBufferSize = frameSize * bytesPerFrame;
        }
        
        /* Setup audio recorder */
        mAudioRecord = new AudioRecord(DEFAULT_AUDIO_SOURCE,
                DEFAULT_SAMPLING_RATE, DEFAULT_CHANNEL_CONFIG, DEFAULT_AUDIO_FORMAT.getAudioFormat(),
                mBufferSize);
        
        mPCMBuffer = new short[mBufferSize];
        /*
         * Initialize lame buffer
         * mp3 sampling rate is the same as the recorded pcm sampling rate 
         * The bit rate is 32kbps
         * 
         */
        LameUtil.init(DEFAULT_SAMPLING_RATE, DEFAULT_LAME_IN_CHANNEL, DEFAULT_SAMPLING_RATE, DEFAULT_LAME_MP3_BIT_RATE, DEFAULT_LAME_MP3_QUALITY);
        // Create and run thread used to encode data
        // The thread will 
        mEncodeThread = new DataEncodeThread(mRecordFile, mBufferSize);
        mEncodeThread.start();
        mAudioRecord.setRecordPositionUpdateListener(mEncodeThread, mEncodeThread.getHandler());
        mAudioRecord.setPositionNotificationPeriod(FRAME_COUNT);
    }

不断的从audioRecord中读取数据,然后交给EncodeThread进行编码。

public void start() throws IOException {
        if (mIsRecording) {
            return;
        }
        mIsRecording = true; // 提早,防止init或startRecording被多次调用
        initAudioRecorder();
        mAudioRecord.startRecording();
        new Thread() {
            @Override
            public void run() {
                //设置线程权限
            android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
                while (mIsRecording) {
                    int readSize = mAudioRecord.read(mPCMBuffer, 0, mBufferSize);
                    if (readSize > 0) {
                        mEncodeThread.addTask(mPCMBuffer, readSize);
                    }
                }
                // release and finalize audioRecord
                mAudioRecord.stop();
                mAudioRecord.release();
                mAudioRecord = null;
                // stop the encoding thread and try to wait
                // until the thread finishes its job
                mEncodeThread.sendStopMessage();
            }
        }.start();
    }

在DataEncodeThread中把数据转码然后写入文件。

private int processData() { 
        if (mTasks.size() > 0) {
            Task task = mTasks.remove(0);
            short[] buffer = task.getData();
            int readSize = task.getReadSize();
            int encodedSize = LameUtil.encode(buffer, buffer, readSize, mMp3Buffer);
            if (encodedSize > 0){
                try {
                    mFileOutputStream.write(mMp3Buffer, 0, encodedSize);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            return readSize;
        }
        return 0;
}

结束录制的时候需要把mp3的结尾信息写入,然后释放资源。

if (msg.what == PROCESS_STOP) {
                //处理缓冲区中的数据
                while (encodeThread.processData() > 0);
                // Cancel any event left in the queue
                removeCallbacksAndMessages(null);
                encodeThread.flushAndRelease();
                getLooper().quit();
            }
            
private void flushAndRelease() {
        //将MP3结尾信息写入buffer中
        final int flushResult = LameUtil.flush(mMp3Buffer);
        if (flushResult > 0) {
            try {
                mFileOutputStream.write(mMp3Buffer, 0, flushResult);
            } catch (IOException e) {
                e.printStackTrace();
            }finally{
                if (mFileOutputStream != null) {
                    try {
                        mFileOutputStream.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                LameUtil.close();
            }
        }
    }           

参考文章

Android手机直播(三)声音采集

利用Cmake在AndroidStudio来使用lame库

Android NDK 开发之 CMake 必知必会

Android移植lame库(采用CMake)

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

推荐阅读更多精彩内容