Android NDK集成LAME编码器:实现PCM到MP3的音频转换
一、 交叉编译LAME静态库
LAME是业界广泛使用的高质量MP3编码库。要在Android应用中使用它,首先需要通过NDK将其交叉编译为对应ABI的静态库(.a)。
1. 获取源码与环境准备
从SourceForge下载LAME的最新稳定版源码(如3.100版本)。同时,确保本地已安装Android NDK。本文以NDK r22和Linux环境为例,目标架构为arm64-v8a。
2. 编写编译脚本
创建一个Shell脚本来配置NDK工具链环境变量。这里对变量命名进行了优化,使其更具可读性:
#!/bin/bash
# NDK工具链基础路径
NDK_ROOT=$HOME/Android/Sdk/ndk/22.1.7171670/toolchains/llvm/prebuilt/linux-x86_64
TARGET_ARCH=aarch64-linux-android
ANDROID_API=21
# 配置编译器与链接器
export AR=$NDK_ROOT/bin/llvm-ar
export CC=$NDK_ROOT/bin/$TARGET_ARCH$ANDROID_API-clang
export CXX=$NDK_ROOT/bin/$TARGET_ARCH$ANDROID_API-clang++
export AS=$CC
export LD=$NDK_ROOT/bin/ld
export RANLIB=$NDK_ROOT/bin/llvm-ranlib
export STRIP=$NDK_ROOT/bin/llvm-strip
# 编译选项:生成位置无关代码并开启优化
export CFLAGS="-fPIC -O2"
3. 配置与构建
解压源码后,在根目录执行configure脚本。我们禁用共享库和前端工具,仅构建静态库:
chmod +x build_lame.sh
./build_lame.sh
./configure \
--host=arm-linux \
--disable-shared \
--disable-frontend \
--enable-static \
--prefix=$(pwd)/../output/arm64-v8a
make clean
make -j$(nproc)
make install
构建完成后,在output/arm64-v8a目录下可找到所需的include/lame/lame.h头文件与lib/libmp3lame.a静态库。
二、 Android工程集成与CMake配置
将生成的头文件和静态库拷贝至Android项目的app/src/main/cpp/include(头文件)和app/src/main/jniLibs/arm64-v8a(库文件)目录中。
修改CMakeLists.txt以引入LAME库:
cmake_minimum_required(VERSION 3.10.2)
project("audio_converter")
# 引入LAME头文件
include_directories(${CMAKE_SOURCE_DIR}/cpp/include)
# 导入LAME静态库
add_library(mp3lame STATIC IMPORTED)
set_target_properties(mp3lame PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libmp3lame.a)
# 构建自定义Native库
add_library(audio_converter SHARED
cpp/native_bridge.cpp
cpp/lame_wrapper.cpp)
# 链接系统日志库与LAME库
find_library(log-lib log)
target_link_libraries(audio_converter
mp3lame
${log-lib})
三、 C++核心编码逻辑实现
重构C++封装类,使用现代C++特性优化内存管理,并增加尾部缓冲区刷新逻辑以防止音频截断。
lame_wrapper.h
#pragma once
#include <string>
#include "lame.h"
class AudioConverter {
public:
AudioConverter() = default;
~AudioConverter();
bool initialize(const std::string& inputPcm, const std::string& outputMp3,
int sampleRate, int channels, int bitRate);
void processConversion();
void releaseResources();
private:
FILE* inputFile = nullptr;
FILE* outputFile = nullptr;
lame_global_flags* lameContext = nullptr;
};
lame_wrapper.cpp
#include "lame_wrapper.h"
#include <vector>
#include <android/log.h>
#define LOG_TAG "AudioConverter"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
AudioConverter::~AudioConverter() {
releaseResources();
}
bool AudioConverter::initialize(const std::string& inputPcm, const std::string& outputMp3,
int sampleRate, int channels, int bitRate) {
inputFile = fopen(inputPcm.c_str(), "rb");
if (!inputFile) {
LOGE("Failed to open PCM file: %s", inputPcm.c_str());
return false;
}
outputFile = fopen(outputMp3.c_str(), "wb");
if (!outputFile) {
fclose(inputFile);
inputFile = nullptr;
LOGE("Failed to open MP3 file: %s", outputMp3.c_str());
return false;
}
lameContext = lame_init();
lame_set_in_samplerate(lameContext, sampleRate);
lame_set_out_samplerate(lameContext, sampleRate);
lame_set_num_channels(lameContext, channels);
lame_set_brate(lameContext, bitRate / 1000);
if (lame_init_params(lameContext) < 0) {
LOGE("LAME initialization failed");
releaseResources();
return false;
}
return true;
}
void AudioConverter::processConversion() {
if (!inputFile || !outputFile || !lameContext) return;
const int pcmBufferSize = 4096;
const int mp3BufferSize = pcmBufferSize * 1.25 + 7200; // LAME 推荐的安全缓冲区大小
std::vector<short> pcmBuffer(pcmBufferSize);
std::vector<short> leftChannel(pcmBufferSize / 2);
std::vector<short> rightChannel(pcmBufferSize / 2);
std::vector<unsigned char> mp3Buffer(mp3BufferSize);
size_t readSamples = 0;
while ((readSamples = fread(pcmBuffer.data(), sizeof(short), pcmBufferSize, inputFile)) > 0) {
// 分离左右声道 (假设16-bit stereo PCM)
for (size_t i = 0; i < readSamples; ++i) {
if (i % 2 == 0) {
leftChannel[i / 2] = pcmBuffer[i];
} else {
rightChannel[i / 2] = pcmBuffer[i];
}
}
int encodedBytes = lame_encode_buffer(
lameContext,
leftChannel.data(),
rightChannel.data(),
readSamples / 2,
mp3Buffer.data(),
mp3BufferSize
);
if (encodedBytes > 0) {
fwrite(mp3Buffer.data(), 1, encodedBytes, outputFile);
}
}
// 刷新LAME内部缓冲区,确保音频尾部数据不丢失
int flushBytes = lame_encode_flush(lameContext, mp3Buffer.data(), mp3BufferSize);
if (flushBytes > 0) {
fwrite(mp3Buffer.data(), 1, flushBytes, outputFile);
}
}
void AudioConverter::releaseResources() {
if (inputFile) {
fclose(inputFile);
inputFile = nullptr;
}
if (outputFile) {
fclose(outputFile);
outputFile = nullptr;
}
if (lameContext) {
lame_close(lameContext);
lameContext = nullptr;
}
}
native_bridge.cpp
#include <jni.h>
#include "lame_wrapper.h"
extern "C" JNIEXPORT jint JNICALL
Java_com_example_audio_AudioProcessor_convertPcmToMp3(
JNIEnv *env, jobject /* this */,
jstring pcmPath, jstring mp3Path,
jint sampleRate, jint channels, jint bitRate) {
const char *nativePcmPath = env->GetStringUTFChars(pcmPath, nullptr);
const char *nativeMp3Path = env->GetStringUTFChars(mp3Path, nullptr);
AudioConverter converter;
bool initSuccess = converter.initialize(nativePcmPath, nativeMp3Path, sampleRate, channels, bitRate);
jint result = -1;
if (initSuccess) {
converter.processConversion();
result = 0;
}
env->ReleaseStringUTFChars(pcmPath, nativePcmPath);
env->ReleaseStringUTFChars(mp3Path, nativeMp3Path);
return result;
}
四、 Android端音频采集与调用
在Android层,我们需要实现PCM音频的录制,并调用JNI接口进行转换。以下是重构后的Kotlin代码,优化了线程控制、流管理以及UI交互逻辑。
布局文件 (activity_main.xml)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<Button
android:id="@+id/btnStartRecord"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="开始录音 (PCM)" />
<Button
android:id="@+id/btnStopRecord"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="停止录音"
android:enabled="false" />
<Button
android:id="@+id/btnConvert"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="转换为 MP3" />
</LinearLayout>
AudioProcessor.kt (封装JNI与录音逻辑)
package com.example.audio
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.atomic.AtomicBoolean
class AudioProcessor {
companion object {
init {
System.loadLibrary("audio_converter")
}
private const val SAMPLE_RATE = 44100
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_STEREO
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
}
external fun convertPcmToMp3(pcmPath: String, mp3Path: String, sampleRate: Int, channels: Int, bitRate: Int): Int
private var audioRecord: AudioRecord? = null
private val isRecording = AtomicBoolean(false)
private var recordThread: Thread? = null
fun startRecording(outputFile: File) {
if (isRecording.get()) return
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT,
bufferSize
)
isRecording.set(true)
audioRecord?.startRecording()
recordThread = Thread {
FileOutputStream(outputFile).use { fos ->
val buffer = ByteArray(bufferSize)
while (isRecording.get()) {
val read = audioRecord?.read(buffer, 0, buffer.size) ?: -1
if (read > 0) {
fos.write(buffer, 0, read)
}
}
}
}.apply { start() }
}
fun stopRecording() {
if (!isRecording.get()) return
isRecording.set(false)
recordThread?.join(2000)
recordThread = null
audioRecord?.stop()
audioRecord?.release()
audioRecord = null
}
}
MainActivity.kt
package com.example.audio
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.io.File
class MainActivity : AppCompatActivity() {
private lateinit var processor: AudioProcessor
private lateinit var pcmFile: File
private lateinit var mp3File: File
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
processor = AudioProcessor()
val audioDir = File(getExternalFilesDir(null), "audio").apply { if (!exists()) mkdirs() }
pcmFile = File(audioDir, "record.pcm")
mp3File = File(audioDir, "record.mp3")
setupUI()
checkPermissions()
}
private fun setupUI() {
val btnStart = findViewById<Button>(R.id.btnStartRecord)
val btnStop = findViewById<Button>(R.id.btnStopRecord)
val btnConvert = findViewById<Button>(R.id.btnConvert)
btnStart.setOnClickListener {
processor.startRecording(pcmFile)
btnStart.isEnabled = false
btnStop.isEnabled = true
Toast.makeText(this, "录音中...", Toast.LENGTH_SHORT).show()
}
btnStop.setOnClickListener {
processor.stopRecording()
btnStart.isEnabled = true
btnStop.isEnabled = false
Toast.makeText(this, "录音已保存", Toast.LENGTH_SHORT).show()
}
btnConvert.setOnClickListener {
if (!pcmFile.exists()) {
Toast.makeText(this, "请先录制PCM文件", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
Thread {
// 参数:采样率44100,双声道,比特率128kbps
val result = processor.convertPcmToMp3(
pcmFile.absolutePath,
mp3File.absolutePath,
44100, 2, 128000
)
runOnUiThread {
val msg = if (result == 0) "转换成功: ${mp3File.absolutePath}" else "转换失败"
Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
}
}.start()
}
}
private fun checkPermissions() {
val perms = arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.WRITE_EXTERNAL_STORAGE)
val needed = perms.filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }
if (needed.isNotEmpty()) {
ActivityCompat.requestPermissions(this, needed.toTypedArray(), 1001)
}
}
}
五、 权限配置
在AndroidManifest.xml中声明必要的权限。对于Android 6.0及以上版本,必须在运行时动态申请RECORD_AUDIO权限(如上述Kotlin代码所示)。由于我们将文件写入应用私有目录(getExternalFilesDir),在Android 10及以上版本无需申请存储权限。
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />