NDK之LAME编码Mp3实战

LAME是目前非常优秀的一种MP3编码引擎,在业界转码成MP3格式的音频文件时,最常用的编码器就是LAME库。用LAME的源码通过交叉编译就能得到SO库(这一部分在交叉编译那篇文章已经有完整的过程了,此处不再赘述),现在只需要把SO库集成到我们自己的项目中即可,需要做的就是编写好接口,上层调用即可。

LAME

PCM当达到320Kbit/s以上时,LAME编码出来的音频质量几乎可以和CD的音质相媲美,并且还能保证整个音频文件的体积非常小,因此若要在移动端平台上编码MP3文件,使用LAME便成为唯一的选择。

MP3参数

mp3(MPEG Layer III)这种格式在生活中很常见,但是mp3有很多种参数,这里讨论一下mp3编码所必须知道的一些参数。

  • 采样率(sampleRate):采样率越高声音的还原度越好。
  • 比特率(bitrate):每秒钟的数据量,越高音质越好。
  • 声道数(channels):声道的数量,通常只有单声道和双声道,双声道即所谓的立体声。
  • 比特率控制模式:ABR、VBR、CBR,这3中模式含义很容易查询到,不在赘述。

MPEG有几个版本的协议,不同版本的协议能够支持的参数能力是不同的。编码库的使用者必须清楚不同版本的区别才能正确的设置参数。

有以下3个版本的协议,MPEG1、MPEG2、MPEG2.5。其中MPEG2.5是非官方的标准,但是流传广泛,所以基本也都支持。他们的区别主要集中在支持的比特率和采样率不同。

采样率支持(Hz)

MPEG1 MPEG2 MPEG2.5
44100 22050 11025
48000 24000 12000
32000 16000 8000

比特率支持(bit/s)

MPEG1 MPEG2 MPEG2.5
32 8 8
40 16 16
48 24 24
56 32 32
64 40 40
80 48 48
96 56 56
112 64 64
128 80
160 96
192 112
224 128
256 144
320 160

编码Mp3基本流程

编码mp3基本上遵循以下的流程

初始化编码参数

  • lame_init:初始化一个编码参数的数据结构,给使用者用来设置参数。

设置编码参数

  • lame_set_in_samplerate:设置被输入编码器的原始数据的采样率。
  • lame_set_out_samplerate:设置最终mp3编码输出的声音的采样率,如果不设置则和输入采样率一样。
  • lame_set_num_channels :设置被输入编码器的原始数据的声道数。
  • lame_set_mode :设置最终mp3编码输出的声道模式,如果不设置则和输入声道数一样。参数是枚举,STEREO代表双声道,MONO代表单声道。
  • lame_set_VBR:设置比特率控制模式,默认是CBR,但是通常我们都会设置VBR。参数是枚举,vbr_off代表CBR,vbr_abr代表ABR(因为ABR不常见,所以本文不对ABR做讲解)vbr_mtrh代表VBR。
  • lame_set_brate:设置CBR的比特率,只有在CBR模式下才生效。
  • lame_set_VBR_mean_bitrate_kbps:设置VBR的比特率,只有在VBR模式下才生效。

其中每个参数都有默认的配置,如非必要可以不设置。这里只介绍了几个关键的设置接口,还有其他的设置接口可以参考lame.h(lame的文档里只有命令行程序的用法,没有库接口的用法)。

初始化编码器器

lame_init_params:根据上面设置好的参数建立编码器

编码PCM数据

  • lame_encode_bufferlame_encode_buffer_interleaved:将PCM数据送入编码器,获取编码出的mp3数据。这些数据写入文件就是mp3文件。
  • 其中lame_encode_buffer输入的参数中是双声道的数据分别输入的,lame_encode_buffer_interleaved输入的参数中双声道数据是交错在一起输入的。具体使用哪个需要看采集到的数据是哪种格式的,不过现在的设备采集到的数据大部分都是双声道数据是交错在一起。
  • 单声道输入只能使用lame_encode_buffer,把单声道数据当成左声道数据传入,右声道传NULL即可。
  • 调用这两个函数时需要传入一块内存来获取编码器出的数据,这块内存的大小lame给出了一种建议的计算方式:采样率/20+7200。

销毁编码器

  • lame_close销毁编码器,释放资源。

编码器参数

对于编码器的参数设置,所能接受的参数值并不是任意的。上面表格中列出了编码器器能够支持的参数值,如果我们设置的参数值不在其中,那么编码器会自动帮我们选择一个最近的值。但是每个版本支持的参数范围不一致,假如设置了MPEG2的比特率又设置了MPEG1的采样率那么会发生什么?

Lame库会优先服从输出采样率的设置,根据采样率选择协议版本,然后在这个版本所能支持的比特率中选一个和设置比特率最接近的。

/* 
 * 0: MPEG-2 LSF
 * 1: MPEG-1
 * 2: MPEG-2.5 LSF FhG extention                  (1995-07-11 shn)
 */
typedef enum {
    MPEG_2 = 0,
    MPEG_1 = 1,
    MPEG_25 = 2
} MPEG_t;
const int bitrate_table[3][16] = {
    {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, -1}, /* MPEG 2 */
    {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1}, /* MPEG 1 */
    {0, 8, 16, 24, 32, 40, 48, 56, 64, -1, -1, -1, -1, -1, -1, -1}, /* MPEG 2.5 */
};
const int samplerate_table[3][4] = {
    {22050, 24000, 16000, -1}, /* MPEG 2 */
    {44100, 48000, 32000, -1}, /* MPEG 1 */
    {11025, 12000, 8000, -1}, /* MPEG 2.5 */
};

编译Mp3实践

准备PCM文件

需要先准备PCM文件放在手机的公共存储区,lame的源码包里有一个testcase.wav,其实这个wav去掉前面的提出44位的wav前缀,就是pcm文件,通过代码去除前44位的wav前缀,修改后缀名即可得到testcase.pcm

public class WAV2PCM {
    public static void main(String[] args) {
        wavToPcmFilePath("/Users/zchanglin/Downloads/lame-3.100/testcase.wav");
    }
  
    public static String wavToPcmFilePath(String wavFile){
        try {
            byte[] buffer= new byte[1024];
            // wav和PCM的区别就是wav在pcm的前面多了44字节
            byte[] preBuffer= new byte[44];
            int readByte = 0;
            FileInputStream fis = new FileInputStream(wavFile);
            String new_audio = wavFile.substring(0,wavFile.lastIndexOf(".")+1)+"pcm";
            FileOutputStream fos = new FileOutputStream(new_audio);
            // 提出44位的wav前缀
            if (fis.read(preBuffer)==-1) {
                return null;
            }
            // 复制PCM内容
            while((readByte = fis.read(buffer)) != -1) {
                fos.write(buffer,0,readByte);
            }
            fos.flush();
            fos.close();
            fis.close();
            return new_audio;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

现在得到了PCM文件,也就是原始音频文件。

编写JNI层代码

新建一个C++的Native工程,删除native-lib.cpp,开始编写我们自己的调用库,app目录树如下:

├── assert
├── build
├── build.gradle
└── src
    ├── androidTest
    ├── main
    │   ├── AndroidManifest.xml
    │   ├── cpp
    │   │   ├── CMakeLists.txt
    │   │   ├── Mp3Encoder.cpp
    │   │   ├── include
    │   │   │   ├── Mp3Encoder.h
    │   │   │   └── lame.h
    │   │   └── lib
    │   │       ├── arm64-v8a
    │   │       │   └── liblame.so
    │   │       └── armeabi-v7a
    │   │           └── liblame.so
    │   ├── java
    │   │   └── com
    │   │       └── xxx
    │   │           └── mp3encode
    │   │               ├── MainActivity.java
    │   │               └── Mp3Encoder.java
    │   └── res
    │       └── layout
    │           └── activity_main.xml
    │   
    └── test

lame的so库得拷贝过来,头文件同样必不可少。

Mp3Encoder.java

package com.xxx.mp3encode;

/**
 * Mp3编码器
 */
public class Mp3Encoder {
    /**
     * 编码(PCM -> Mp3文件)
     */
    public native void encode();

    /**
     * 初始化编码器
     * @param pcmPath
     * @param audioChannels
     * @param bitRate
     * @param sampleRate
     * @param mp3Path
     * @return
     */
    public native int init(String pcmPath, int audioChannels,
                           int bitRate,
                           int sampleRate, String mp3Path);
    /**
     * 销毁编码器
     */
    public native void destroy();
}

通过javah命令生成头文件Mp3Encoder.h,然后稍作修改:

#include <jni.h>
#include <cstdio>
// 引入Lame头文件
#include "lame.h"

extern "C"
JNIEXPORT void JNICALL
Java_com_xxx_mp3encode_Mp3Encoder_encode
(JNIEnv *env, jobject);

extern "C" JNIEXPORT jint JNICALL
Java_com_xxx_mp3encode_Mp3Encoder_init
        (JNIEnv *, jobject, jstring, jint, jint, jint, jstring);

extern "C" JNIEXPORT void JNICALL
Java_com_xxx_mp3encode_Mp3Encoder_destroy
        (JNIEnv *, jobject);

class Mp3Encoder {
private:
    FILE* pcmFile;
    FILE* mp3File;
    lame_t lameClient;
public:
    Mp3Encoder();
    ~Mp3Encoder();
    // 初始化准备工作
    int Init(const char* pcmFilePath, const char *mp3FilePath,
             int sampleRate, int channels, int bitRate);
    // 编码
    void Encode();
    // 销毁
    void Destroy();
};

Mp3Encoder mp3Encoder;

Mp3Encoder.cpp

#include "include/Mp3Encoder.h"
#include <android/log.h>
#include <string>

//定义输出的TAG
#define LOG_TAG "MP3Encoder"
#define LOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)

extern "C"
JNIEXPORT void JNICALL
Java_com_tal_mp3encode_Mp3Encoder_encode
        (JNIEnv *env, jobject){
    mp3Encoder.Encode();
}

Mp3Encoder::Mp3Encoder(){

};

Mp3Encoder::~Mp3Encoder() {
    mp3Encoder.Destroy();
}

int Mp3Encoder::Init(const char* pcmFilePath, const char* mp3FilePath,
                     int sampleRate, int channels,
                     int bitRate) {
    int ret = -1;
    // 打开PCM文件流
    LOGI("打开PCM文件流, pcmFilePath = %s", pcmFilePath);
    pcmFile = fopen("/storage/emulated/0/testcase.pcm", "rb");
    if(pcmFile == nullptr){
        LOGE("PCM文件指针为NULL");
        return -1;
    }

    // 打开Mp3文件流
    mp3File = fopen(mp3FilePath, "wb");
    if(mp3File){
        // 初始化LAME
        lameClient = lame_init();
        // 设置被输入编码器的原始数据的采样率,默认44100hz
        lame_set_in_samplerate(lameClient, sampleRate);
        // 设置最终mp3编码输出的声音的采样率,如果不设置则和输入采样率一样
        lame_set_out_samplerate(lameClient, sampleRate);
        // 设置被输入编码器的原始数据的声道数,默认是2
        lame_set_num_channels(lameClient, channels);
        // 设置压缩比,默认11
        lame_set_brate(lameClient, bitRate / 1000);
        // 根据上面设置好的参数建立编码器
        lame_init_params(lameClient);
        ret = 0;
    }
    return ret;
}

void Mp3Encoder::Encode() {
    int bufferSize = 1024 * 256;
    auto buffer = new short[bufferSize / 2];
    auto leftBuffer = new short[bufferSize / 4];
    auto rightBuffer = new short[bufferSize / 4];

    auto mp3_buffer = new unsigned char [bufferSize];
    size_t readBufferSize = 0;
    while ((readBufferSize = fread(buffer, 2, bufferSize / 2, pcmFile)) > 0){
        for(int i = 0; i < readBufferSize; i++){
            if(i % 2 == 0){
                leftBuffer[i/2] = buffer[i];
            }else{
                rightBuffer[i/2] = buffer[i];
            }
        }
        size_t wroteSize = lame_encode_buffer(
                lameClient,
                leftBuffer,
                rightBuffer,
                (int)readBufferSize/2,
                mp3_buffer,
                bufferSize);
        fwrite(mp3_buffer, 1, wroteSize, mp3File);
    }
    delete[] buffer;
    delete [] leftBuffer;
    delete [] rightBuffer;
    delete [] mp3_buffer;
}

void Mp3Encoder::Destroy() {
    if(pcmFile) {
        fclose(pcmFile);
    }

    if(mp3File) {
        fclose(mp3File);
        lame_close(lameClient);
    }
}



/*
 * Class:     com_tal_mp3encoder_Mp3Encoder
 * Method:    init
 * Signature: (Ljava/lang/String;IIILjava/lang/String;)I
 */
extern "C" JNIEXPORT jint JNICALL
Java_com_tal_mp3encode_Mp3Encoder_init
        (JNIEnv* env, jobject, jstring pcmPathParam,
         jint audioChannels, jint bitRate, jint sampleRate,
         jstring mp3PathParam){
    LOGI("mp3Encoder开始执行初始化");
    const char* pcmPath = env->GetStringUTFChars(pcmPathParam, NULL);
    const char* mp3Path = env->GetStringUTFChars(mp3PathParam, NULL);
    return mp3Encoder.Init(pcmPath, mp3Path, sampleRate, audioChannels, bitRate);
}

/*
 * Class:     com_tal_mp3encoder_Mp3Encoder
 * Method:    destroy
 * Signature: ()V
 */
extern "C" JNIEXPORT void JNICALL
Java_com_tal_mp3encode_Mp3Encoder_destroy
        (JNIEnv *, jobject){
    mp3Encoder.Destroy();
}

编写CMakeLists

CMakeLists.txt

# CMake最小版本
cmake_minimum_required(VERSION 3.18.1)

# 工程名称
project("mp3encode")

# 定义目标库
add_library(
    mp3encode # 库名称
    SHARED # 动态库
    Mp3Encoder.cpp # 源文件
)

# 包含头文件
target_include_directories(
    mp3encode # 库名称
    PUBLIC # 头文件作用域
    include/Mp3Encoder.h include/lame.h # 具体哪些头文件
)

# 查找预编译库
find_library(
    log-lib
    log # 日志库
    ${CMAKE_SOURCE_DIR}/lib/${ANDROID_ABI}/liblame.so # lame库
)

# 目标库的链接
target_link_libraries(
    mp3encode # 库名称
    ${CMAKE_SOURCE_DIR}/lib/${ANDROID_ABI}/liblame.so # lame库
    ${log-lib} # 日志库
)

# 查看引入的lame库的路径
message("LibPath -> " ${CMAKE_SOURCE_DIR}/lib/${ANDROID_ABI}/liblame.so)

配置Gradle

build.gradle(app)

plugins {
    id 'com.android.application'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
        applicationId "com.xxx.mp3encode"
        minSdkVersion 22
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        // 设置cmake编译脚本
        externalNativeBuild {
            cmake {
                cppFlags '-std=c++11'
            }
        }

	ndk {
	    abiFilters "armeabi-v7a"
	}
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    externalNativeBuild {
        cmake {
            path file('src/main/cpp/CMakeLists.txt')
            version '3.18.1'
        }
    }
    buildFeatures {
        viewBinding true
    }
    ndkVersion '20.0.5594570'
    sourceSets {
        main {
            jniLibs.srcDirs = ['src/main/cpp/lib']
        }
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

编写上层代码

MainActivity.java

public class MainActivity extends AppCompatActivity {
  
    private static final String TAG = "MainActivity";
    // 申请权限请求码
    private static final int REQUEST_EXTERNAL_STORAGE = 1001;

    static {
        System.loadLibrary("lame");
        System.loadLibrary("mp3encode");
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        verifyStoragePermissions(this);
    }

    public void startEncodeMp3(View view) {
        File extDir = Environment.getExternalStorageDirectory();
        File pcmCaseFile = new File(extDir, "testcase.pcm");
        if(!pcmCaseFile.exists()) {
            Log.i(TAG, "PCM文件不存在");
            return;
        }
        File mp3File = new File(extDir, "testcase.mp3");
        Mp3Encoder mp3Encoder = new Mp3Encoder();
        mp3Encoder.init(
                pcmCaseFile.getAbsolutePath(),
                2,
                5000,
                44100,
                mp3File.getAbsolutePath()
        );

        mp3Encoder.encode();
        mp3Encoder.destroy();
        Toast.makeText(this, "编码完成", Toast.LENGTH_SHORT).show();
        TextView sampleTextView = (TextView) findViewById(R.id.sample_text);
        sampleTextView.setText("Mp3 Path = " + mp3File.getAbsolutePath());
    }

    public static void verifyStoragePermissions(Activity activity) {
        int writePermission = ActivityCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
        int readPermission = ActivityCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE);

        if (writePermission != PackageManager.PERMISSION_GRANTED
                || readPermission != PackageManager.PERMISSION_GRANTED) {
            // 如果没有权限需要动态地去申请权限
            ActivityCompat.requestPermissions(
                    activity,
                    // 权限数组
                    new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE},
                    // 权限请求码
                    REQUEST_EXTERNAL_STORAGE
            );
        }
    }
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.xxx.mp3encode">

    <!-- 在SDCard中创建与删除文件的权限 -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

    <application
        android:requestLegacyExternalStorage="true"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Mp3Encode">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

OK,点击按钮触发编码Mp3,Success!