当前位置:首页 > 技术 > 正文内容

Android NDK集成LAME编码器:实现PCM到MP3的音频转换

访客 技术 2026年7月10日 1

一、 交叉编译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" />

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。