0%

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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 
* 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 目录树如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
├── 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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,然后稍作修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#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 开始执行初始化 & quot;);
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 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)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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 文件不存在 & quot;);
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, " 编码完成 & quot;, 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?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!

欢迎关注我的其它发布渠道