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

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

编译Mp3实践

准备PCM文件

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

 1public class WAV2PCM {
 2    public static void main(String[] args) {
 3        wavToPcmFilePath("/Users/zchanglin/Downloads/lame-3.100/testcase.wav");
 4    }
 5  
 6    public static String wavToPcmFilePath(String wavFile){
 7        try {
 8            byte[] buffer= new byte[1024];
 9            // wav和PCM的区别就是wav在pcm的前面多了44字节
10            byte[] preBuffer= new byte[44];
11            int readByte = 0;
12            FileInputStream fis = new FileInputStream(wavFile);
13            String new_audio = wavFile.substring(0,wavFile.lastIndexOf(".")+1)+"pcm";
14            FileOutputStream fos = new FileOutputStream(new_audio);
15            // 提出44位的wav前缀
16            if (fis.read(preBuffer)==-1) {
17                return null;
18            }
19            // 复制PCM内容
20            while((readByte = fis.read(buffer)) != -1) {
21                fos.write(buffer,0,readByte);
22            }
23            fos.flush();
24            fos.close();
25            fis.close();
26            return new_audio;
27        } catch (IOException e) {
28            e.printStackTrace();
29        }
30        return null;
31    }
32}

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

编写JNI层代码

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

 1├── assert
 2├── build
 3├── build.gradle
 4└── src
 5    ├── androidTest
 6    ├── main
 7    │   ├── AndroidManifest.xml
 8    │   ├── cpp
 9    │   │   ├── CMakeLists.txt
10    │   │   ├── Mp3Encoder.cpp
11    │   │   ├── include
12    │   │   │   ├── Mp3Encoder.h
13    │   │   │   └── lame.h
14    │   │   └── lib
15    │   │       ├── arm64-v8a
16    │   │       │   └── liblame.so
17    │   │       └── armeabi-v7a
18    │   │           └── liblame.so
19    │   ├── java
20    │   │   └── com
21    │   │       └── xxx
22    │   │           └── mp3encode
23    │   │               ├── MainActivity.java
24    │   │               └── Mp3Encoder.java
25    │   └── res
26    │       └── layout
27    │           └── activity_main.xml
28    │   
29    └── test

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

Mp3Encoder.java

 1package com.xxx.mp3encode;
 2
 3/**
 4 * Mp3编码器
 5 */
 6public class Mp3Encoder {
 7    /**
 8     * 编码(PCM -> Mp3文件)
 9     */
10    public native void encode();
11
12    /**
13     * 初始化编码器
14     * @param pcmPath
15     * @param audioChannels
16     * @param bitRate
17     * @param sampleRate
18     * @param mp3Path
19     * @return
20     */
21    public native int init(String pcmPath, int audioChannels,
22                           int bitRate,
23                           int sampleRate, String mp3Path);
24    /**
25     * 销毁编码器
26     */
27    public native void destroy();
28}

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

 1#include <jni.h>
 2#include <cstdio>
 3// 引入Lame头文件
 4#include "lame.h"
 5
 6extern "C"
 7JNIEXPORT void JNICALL
 8Java_com_xxx_mp3encode_Mp3Encoder_encode
 9(JNIEnv *env, jobject);
10
11extern "C" JNIEXPORT jint JNICALL
12Java_com_xxx_mp3encode_Mp3Encoder_init
13        (JNIEnv *, jobject, jstring, jint, jint, jint, jstring);
14
15extern "C" JNIEXPORT void JNICALL
16Java_com_xxx_mp3encode_Mp3Encoder_destroy
17        (JNIEnv *, jobject);
18
19class Mp3Encoder {
20private:
21    FILE* pcmFile;
22    FILE* mp3File;
23    lame_t lameClient;
24public:
25    Mp3Encoder();
26    ~Mp3Encoder();
27    // 初始化准备工作
28    int Init(const char* pcmFilePath, const char *mp3FilePath,
29             int sampleRate, int channels, int bitRate);
30    // 编码
31    void Encode();
32    // 销毁
33    void Destroy();
34};
35
36Mp3Encoder mp3Encoder;

Mp3Encoder.cpp

  1#include "include/Mp3Encoder.h"
  2#include <android/log.h>
  3#include <string>
  4
  5//定义输出的TAG
  6#define LOG_TAG "MP3Encoder"
  7#define LOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
  8#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
  9#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
 10#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
 11#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
 12
 13extern "C"
 14JNIEXPORT void JNICALL
 15Java_com_tal_mp3encode_Mp3Encoder_encode
 16        (JNIEnv *env, jobject){
 17    mp3Encoder.Encode();
 18}
 19
 20Mp3Encoder::Mp3Encoder(){
 21
 22};
 23
 24Mp3Encoder::~Mp3Encoder() {
 25    mp3Encoder.Destroy();
 26}
 27
 28int Mp3Encoder::Init(const char* pcmFilePath, const char* mp3FilePath,
 29                     int sampleRate, int channels,
 30                     int bitRate) {
 31    int ret = -1;
 32    // 打开PCM文件流
 33    LOGI("打开PCM文件流, pcmFilePath = %s", pcmFilePath);
 34    pcmFile = fopen("/storage/emulated/0/testcase.pcm", "rb");
 35    if(pcmFile == nullptr){
 36        LOGE("PCM文件指针为NULL");
 37        return -1;
 38    }
 39
 40    // 打开Mp3文件流
 41    mp3File = fopen(mp3FilePath, "wb");
 42    if(mp3File){
 43        // 初始化LAME
 44        lameClient = lame_init();
 45        // 设置被输入编码器的原始数据的采样率,默认44100hz
 46        lame_set_in_samplerate(lameClient, sampleRate);
 47        // 设置最终mp3编码输出的声音的采样率,如果不设置则和输入采样率一样
 48        lame_set_out_samplerate(lameClient, sampleRate);
 49        // 设置被输入编码器的原始数据的声道数,默认是2
 50        lame_set_num_channels(lameClient, channels);
 51        // 设置压缩比,默认11
 52        lame_set_brate(lameClient, bitRate / 1000);
 53        // 根据上面设置好的参数建立编码器
 54        lame_init_params(lameClient);
 55        ret = 0;
 56    }
 57    return ret;
 58}
 59
 60void Mp3Encoder::Encode() {
 61    int bufferSize = 1024 * 256;
 62    auto buffer = new short[bufferSize / 2];
 63    auto leftBuffer = new short[bufferSize / 4];
 64    auto rightBuffer = new short[bufferSize / 4];
 65
 66    auto mp3_buffer = new unsigned char [bufferSize];
 67    size_t readBufferSize = 0;
 68    while ((readBufferSize = fread(buffer, 2, bufferSize / 2, pcmFile)) > 0){
 69        for(int i = 0; i < readBufferSize; i++){
 70            if(i % 2 == 0){
 71                leftBuffer[i/2] = buffer[i];
 72            }else{
 73                rightBuffer[i/2] = buffer[i];
 74            }
 75        }
 76        size_t wroteSize = lame_encode_buffer(
 77                lameClient,
 78                leftBuffer,
 79                rightBuffer,
 80                (int)readBufferSize/2,
 81                mp3_buffer,
 82                bufferSize);
 83        fwrite(mp3_buffer, 1, wroteSize, mp3File);
 84    }
 85    delete[] buffer;
 86    delete [] leftBuffer;
 87    delete [] rightBuffer;
 88    delete [] mp3_buffer;
 89}
 90
 91void Mp3Encoder::Destroy() {
 92    if(pcmFile) {
 93        fclose(pcmFile);
 94    }
 95
 96    if(mp3File) {
 97        fclose(mp3File);
 98        lame_close(lameClient);
 99    }
100}
101
102
103
104/*
105 * Class:     com_tal_mp3encoder_Mp3Encoder
106 * Method:    init
107 * Signature: (Ljava/lang/String;IIILjava/lang/String;)I
108 */
109extern "C" JNIEXPORT jint JNICALL
110Java_com_tal_mp3encode_Mp3Encoder_init
111        (JNIEnv* env, jobject, jstring pcmPathParam,
112         jint audioChannels, jint bitRate, jint sampleRate,
113         jstring mp3PathParam){
114    LOGI("mp3Encoder开始执行初始化");
115    const char* pcmPath = env->GetStringUTFChars(pcmPathParam, NULL);
116    const char* mp3Path = env->GetStringUTFChars(mp3PathParam, NULL);
117    return mp3Encoder.Init(pcmPath, mp3Path, sampleRate, audioChannels, bitRate);
118}
119
120/*
121 * Class:     com_tal_mp3encoder_Mp3Encoder
122 * Method:    destroy
123 * Signature: ()V
124 */
125extern "C" JNIEXPORT void JNICALL
126Java_com_tal_mp3encode_Mp3Encoder_destroy
127        (JNIEnv *, jobject){
128    mp3Encoder.Destroy();
129}

编写CMakeLists

CMakeLists.txt

 1# CMake最小版本
 2cmake_minimum_required(VERSION 3.18.1)
 3
 4# 工程名称
 5project("mp3encode")
 6
 7# 定义目标库
 8add_library(
 9    mp3encode # 库名称
10    SHARED # 动态库
11    Mp3Encoder.cpp # 源文件
12)
13
14# 包含头文件
15target_include_directories(
16    mp3encode # 库名称
17    PUBLIC # 头文件作用域
18    include/Mp3Encoder.h include/lame.h # 具体哪些头文件
19)
20
21# 查找预编译库
22find_library(
23    log-lib
24    log # 日志库
25    ${CMAKE_SOURCE_DIR}/lib/${ANDROID_ABI}/liblame.so # lame库
26)
27
28# 目标库的链接
29target_link_libraries(
30    mp3encode # 库名称
31    ${CMAKE_SOURCE_DIR}/lib/${ANDROID_ABI}/liblame.so # lame库
32    ${log-lib} # 日志库
33)
34
35# 查看引入的lame库的路径
36message("LibPath -> " ${CMAKE_SOURCE_DIR}/lib/${ANDROID_ABI}/liblame.so)

配置Gradle

build.gradle(app)

 1plugins {
 2    id 'com.android.application'
 3}
 4
 5android {
 6    compileSdkVersion 30
 7    buildToolsVersion "30.0.3"
 8
 9    defaultConfig {
10        applicationId "com.xxx.mp3encode"
11        minSdkVersion 22
12        targetSdkVersion 30
13        versionCode 1
14        versionName "1.0"
15
16        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
17
18        // 设置cmake编译脚本
19        externalNativeBuild {
20            cmake {
21                cppFlags '-std=c++11'
22            }
23        }
24
25	ndk {
26	    abiFilters "armeabi-v7a"
27	}
28    }
29
30    buildTypes {
31        release {
32            minifyEnabled false
33            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
34        }
35    }
36    compileOptions {
37        sourceCompatibility JavaVersion.VERSION_1_8
38        targetCompatibility JavaVersion.VERSION_1_8
39    }
40    externalNativeBuild {
41        cmake {
42            path file('src/main/cpp/CMakeLists.txt')
43            version '3.18.1'
44        }
45    }
46    buildFeatures {
47        viewBinding true
48    }
49    ndkVersion '20.0.5594570'
50    sourceSets {
51        main {
52            jniLibs.srcDirs = ['src/main/cpp/lib']
53        }
54    }
55}
56
57dependencies {
58    implementation 'androidx.appcompat:appcompat:1.3.0'
59    implementation 'com.google.android.material:material:1.4.0'
60    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
61    testImplementation 'junit:junit:4.13.2'
62    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
63    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
64}

编写上层代码

MainActivity.java

 1public class MainActivity extends AppCompatActivity {
 2  
 3    private static final String TAG = "MainActivity";
 4    // 申请权限请求码
 5    private static final int REQUEST_EXTERNAL_STORAGE = 1001;
 6
 7    static {
 8        System.loadLibrary("lame");
 9        System.loadLibrary("mp3encode");
10    }
11
12    @Override
13    protected void onCreate(Bundle savedInstanceState) {
14        super.onCreate(savedInstanceState);
15        setContentView(R.layout.activity_main);
16        verifyStoragePermissions(this);
17    }
18
19    public void startEncodeMp3(View view) {
20        File extDir = Environment.getExternalStorageDirectory();
21        File pcmCaseFile = new File(extDir, "testcase.pcm");
22        if(!pcmCaseFile.exists()) {
23            Log.i(TAG, "PCM文件不存在");
24            return;
25        }
26        File mp3File = new File(extDir, "testcase.mp3");
27        Mp3Encoder mp3Encoder = new Mp3Encoder();
28        mp3Encoder.init(
29                pcmCaseFile.getAbsolutePath(),
30                2,
31                5000,
32                44100,
33                mp3File.getAbsolutePath()
34        );
35
36        mp3Encoder.encode();
37        mp3Encoder.destroy();
38        Toast.makeText(this, "编码完成", Toast.LENGTH_SHORT).show();
39        TextView sampleTextView = (TextView) findViewById(R.id.sample_text);
40        sampleTextView.setText("Mp3 Path = " + mp3File.getAbsolutePath());
41    }
42
43    public static void verifyStoragePermissions(Activity activity) {
44        int writePermission = ActivityCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
45        int readPermission = ActivityCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE);
46
47        if (writePermission != PackageManager.PERMISSION_GRANTED
48                || readPermission != PackageManager.PERMISSION_GRANTED) {
49            // 如果没有权限需要动态地去申请权限
50            ActivityCompat.requestPermissions(
51                    activity,
52                    // 权限数组
53                    new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE},
54                    // 权限请求码
55                    REQUEST_EXTERNAL_STORAGE
56            );
57        }
58    }
59}

AndroidManifest.xml

 1<?xml version="1.0" encoding="utf-8"?>
 2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
 3    package="com.xxx.mp3encode">
 4
 5    <!-- 在SDCard中创建与删除文件的权限 -->
 6    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
 7    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
 8
 9    <application
10        android:requestLegacyExternalStorage="true"
11        android:allowBackup="true"
12        android:icon="@mipmap/ic_launcher"
13        android:label="@string/app_name"
14        android:roundIcon="@mipmap/ic_launcher_round"
15        android:supportsRtl="true"
16        android:theme="@style/Theme.Mp3Encode">
17        <activity android:name=".MainActivity">
18            <intent-filter>
19                <action android:name="android.intent.action.MAIN" />
20                <category android:name="android.intent.category.LAUNCHER" />
21            </intent-filter>
22        </activity>
23    </application>
24
25</manifest>

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