Android 使用 lame wav 轉(zhuǎn) mp3 、pcm 轉(zhuǎn) mp3 (邊錄邊轉(zhuǎn));使用 mad mp3 轉(zhuǎn) wav、mp3 轉(zhuǎn) pcm (邊播邊轉(zhuǎn))

前言

最近在研究wav,mp3,pcm之間的相互轉(zhuǎn)換,發(fā)現(xiàn)mp3的相關(guān)操作,都需要解碼mp3或者編碼mp3,無(wú)法直接對(duì)mp3文件做操作。下面是本文的相關(guān)知識(shí)點(diǎn)。

  • wav 轉(zhuǎn) mp3
  • pcm 轉(zhuǎn) mp3 (邊錄邊轉(zhuǎn))
  • mp3 轉(zhuǎn) wav
  • mp3 轉(zhuǎn) pcm (邊播邊轉(zhuǎn))

1. Android 使用 lame wav 轉(zhuǎn)碼 mp3

1.1 準(zhǔn)備工作

下載 lame_x.xx.x 包

Lame
Lame 是最好的mp3編碼器,速度快,效果好,特別是中高碼率和VBR編碼方面。
http://lame.sourceforge.net/

1.2 創(chuàng)建 android 項(xiàng)目 lame

創(chuàng)建jni目錄 并 復(fù)制 lame-x.xx.x 包下的libmp3lame 目錄下的所有 .c和.h文件和 include目錄下的lame.h

1.2.1 在jni目錄下創(chuàng)建 Android.mk文件

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LAME_LIBMP3_DIR := lame_3.99.5_libmp3lame

LOCAL_MODULE    := mp3lame
LOCAL_SRC_FILES := $(LAME_LIBMP3_DIR)/bitstream.c $(LAME_LIBMP3_DIR)/fft.c $(LAME_LIBMP3_DIR)/id3tag.c $(LAME_LIBMP3_DIR)/mpglib_interface.c $(LAME_LIBMP3_DIR)/presets.c $(LAME_LIBMP3_DIR)/quantize.c $(LAME_LIBMP3_DIR)/reservoir.c $(LAME_LIBMP3_DIR)/tables.c $(LAME_LIBMP3_DIR)/util.c $(LAME_LIBMP3_DIR)/VbrTag.c $(LAME_LIBMP3_DIR)/encoder.c $(LAME_LIBMP3_DIR)/gain_analysis.c $(LAME_LIBMP3_DIR)/lame.c $(LAME_LIBMP3_DIR)/newmdct.c $(LAME_LIBMP3_DIR)/psymodel.c $(LAME_LIBMP3_DIR)/quantize_pvt.c $(LAME_LIBMP3_DIR)/set_get.c $(LAME_LIBMP3_DIR)/takehiro.c $(LAME_LIBMP3_DIR)/vbrquantize.c $(LAME_LIBMP3_DIR)/version.c lame_util.c

include $(BUILD_SHARED_LIBRARY)

1.2.2 在jni目錄下創(chuàng)建 Application.mk文件

APP_PLATFORM := android-9 
APP_ABI := all
APP_CFLAGS += -DSTDC_HEADERS

1.2.3 然后在gradle里面進(jìn)行配置(在使用該jni的gradle里進(jìn)行配置)

    sourceSets.main {
        jni.srcDirs = [] // This prevents the auto generation of Android.mk
        jniLibs.srcDir 'src/main/libs' // This is not necessary unless you have precompiled libraries in your project.
    }

1.2.4 最后ndk-build生成相應(yīng).so庫(kù)(ndk-build會(huì)生成相應(yīng)的.h頭文件)

1.3 編寫(xiě)wav轉(zhuǎn)mp3的lame_util.c

大致分為兩步

  1. 通過(guò)Jstring2CStr方法將java中的jstring類(lèi)型轉(zhuǎn)化成c語(yǔ)言的char字符串
  2. 然后再通過(guò)convert方法將wav轉(zhuǎn)碼成mp3文件
    (convert方法的參數(shù)為,wav路徑,mp3路徑,采樣率,聲道數(shù),比特率)
    下面為大家科普一下相關(guān)參數(shù)以及知識(shí)點(diǎn)

1.3.1 Lame相關(guān)參數(shù)

  • 采樣率(sampleRate):采樣率越高聲音的還原度越好。
  • 比特率(bitrate):每秒鐘的數(shù)據(jù)量,越高音質(zhì)越好。
  • 聲道數(shù)(channels):聲道的數(shù)量,通常只有單聲道和雙聲道,雙聲道即所謂的立體聲。
  • 比特率控制模式:ABR、VBR、CBR,這3中模式含義很容易查詢(xún)到,不在贅述

Lame采樣率支持(Hz)

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

Lame比特率支持(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

1.3.2 編碼流程

初始化編碼參數(shù)

  • lame_init:初始化一個(gè)編碼參數(shù)的數(shù)據(jù)結(jié)構(gòu),給使用者用來(lái)設(shè)置參數(shù)。

設(shè)置編碼參數(shù)

  • lame_set_in_samplerate:設(shè)置被輸入編碼器的原始數(shù)據(jù)的采樣率。
  • lame_set_out_samplerate:設(shè)置最終mp3編碼輸出的聲音的采樣率,如果不設(shè)置則和輸入采樣率一樣。
  • lame_set_num_channels :設(shè)置被輸入編碼器的原始數(shù)據(jù)的聲道數(shù)。
  • lame_set_mode :設(shè)置最終mp3編碼輸出的聲道模式,如果不設(shè)置則和輸入聲道數(shù)一樣。參數(shù)是枚舉,STEREO代表雙聲道,MONO代表單聲道。
  • lame_set_VBR:設(shè)置比特率控制模式,默認(rèn)是CBR,但是通常我們都會(huì)設(shè)置VBR。參數(shù)是枚舉,vbr_off代表CBR,vbr_abr代表ABR(因?yàn)锳BR不常見(jiàn),所以本文不對(duì)ABR做講解)vbr_mtrh代表VBR。
  • lame_set_brate:設(shè)置CBR的比特率,只有在CBR模式下才生效。
  • lame_set_VBR_mean_bitrate_kbps:設(shè)置VBR的比特率,只有在VBR模式下才生效。

其中每個(gè)參數(shù)都有默認(rèn)的配置,如非必要可以不設(shè)置。這里只介紹了幾個(gè)關(guān)鍵的設(shè)置接口,還有其他的設(shè)置接口可以參考lame.h(lame的文檔里只有命令行程序的用法,沒(méi)有庫(kù)接口的用法)。

初始化編碼器器

lame_init_params:根據(jù)上面設(shè)置好的參數(shù)建立編碼器

編碼PCM數(shù)據(jù)

  • lame_encode_bufferlame_encode_buffer_interleaved:將PCM數(shù)據(jù)送入編碼器,獲取編碼出的mp3數(shù)據(jù)。這些數(shù)據(jù)寫(xiě)入文件就是mp3文件。
  • 其中lame_encode_buffer輸入的參數(shù)中是雙聲道的數(shù)據(jù)分別輸入的,lame_encode_buffer_interleaved輸入的參數(shù)中雙聲道數(shù)據(jù)是交錯(cuò)在一起輸入的。具體使用哪個(gè)需要看采集到的數(shù)據(jù)是哪種格式的,不過(guò)現(xiàn)在的設(shè)備采集到的數(shù)據(jù)大部分都是雙聲道數(shù)據(jù)是交錯(cuò)在一起。
  • 單聲道輸入只能使用lame_encode_buffer,把單聲道數(shù)據(jù)當(dāng)成左聲道數(shù)據(jù)傳入,右聲道傳NULL即可。
  • 調(diào)用這兩個(gè)函數(shù)時(shí)需要傳入一塊內(nèi)存來(lái)獲取編碼器出的數(shù)據(jù),這塊內(nèi)存的大小lame給出了一種建議的計(jì)算方式:采樣率/20+7200。

結(jié)束編碼

lame_encode_flush:刷新編碼器緩沖,獲取殘留在編碼器緩沖里的數(shù)據(jù)。這部分?jǐn)?shù)據(jù)也需要寫(xiě)入mp3文件

銷(xiāo)毀編碼器

lame_close銷(xiāo)毀編碼器,釋放資源。

#include "lame_3.99.5_libmp3lame/lame.h"
#include "com_czt_mp3recorder_util_LameUtil.h"
#include <stdio.h>
#include <stdlib.h>
#include <jni.h>
#include <sys/stat.h>

/**
 * 返回值 char* 這個(gè)代表char數(shù)組的首地址
 *  Jstring2CStr 把java中的jstring的類(lèi)型轉(zhuǎn)化成一個(gè)c語(yǔ)言中的char 字符串
 */
char* Jstring2CStr(JNIEnv* env, jstring jstr) {
    char* rtn = NULL;
    jclass clsstring = (*env)->FindClass(env, "java/lang/String"); //String
    jstring strencode = (*env)->NewStringUTF(env, "GB2312"); // 得到一個(gè)java字符串 "GB2312"
    jmethodID mid = (*env)->GetMethodID(env, clsstring, "getBytes",
            "(Ljava/lang/String;)[B"); //[ String.getBytes("gb2312");
    jbyteArray barr = (jbyteArray)(*env)->CallObjectMethod(env, jstr, mid,
            strencode); // String .getByte("GB2312");
    jsize alen = (*env)->GetArrayLength(env, barr); // byte數(shù)組的長(zhǎng)度
    jbyte* ba = (*env)->GetByteArrayElements(env, barr, JNI_FALSE);
    if (alen > 0) {
        rtn = (char*) malloc(alen + 1); //"\0"
        memcpy(rtn, ba, alen);
        rtn[alen] = 0;
    }
    (*env)->ReleaseByteArrayElements(env, barr, ba, 0); //
    return rtn;
}

int flag = 0;
/**
 * wav轉(zhuǎn)換mp3
 */
JNIEXPORT void JNICALL Java_com_czt_mp3recorder_util_LameUtil_convert
(JNIEnv * env, jobject obj, jstring jwav, jstring jmp3, jint inSamplerate, jint inChannel, jint outBitrate) {

    char* cwav =Jstring2CStr(env,jwav) ;
    char* cmp3=Jstring2CStr(env,jmp3);

    //1.打開(kāi) wav,MP3文件
    FILE* fwav = fopen(cwav,"rb");
    FILE* fmp3 = fopen(cmp3,"wb+");

    int channel = inChannel;//聲道數(shù)

    short int wav_buffer[8192*channel];
    unsigned char mp3_buffer[8192];

    //1.初始化lame的編碼器
    lame_t lameConvert =  lame_init();

    //2. 設(shè)置lame mp3編碼的采樣率
    lame_set_in_samplerate(lameConvert , inSamplerate);
    lame_set_out_samplerate(lameConvert, inSamplerate);
    lame_set_num_channels(lameConvert,channel);
    lame_set_mode(lameConvert, MONO);
    // 3. 設(shè)置MP3的編碼方式
    lame_set_VBR(lameConvert, vbr_default);
    lame_init_params(lameConvert);
    int read ; int write; //代表讀了多少個(gè)次 和寫(xiě)了多少次
    int total=0; // 當(dāng)前讀的wav文件的byte數(shù)目
    do{
        if(flag==404){
            return;
        }
        read = fread(wav_buffer,sizeof(short int)*channel, 8192,fwav);
        total +=  read* sizeof(short int)*channel;
        if(read!=0){
            write = lame_encode_buffer(lameConvert, wav_buffer, NULL, read, mp3_buffer, 8192);
            //write = lame_encode_buffer_interleaved(lame,wav_buffer,read,mp3_buffer,8192);
        }else{
        write = lame_encode_flush(lameConvert,mp3_buffer,8192);
        }
        //把轉(zhuǎn)化后的mp3數(shù)據(jù)寫(xiě)到文件里
        fwrite(mp3_buffer,1,write,fmp3);

    }while(read!=0);
    lame_close(lameConvert);
    fclose(fwav);
    fclose(fmp3);
}

1.3.3 導(dǎo)入庫(kù)以及創(chuàng)建native方法

創(chuàng)建LameUtil類(lèi),并導(dǎo)入相應(yīng)的庫(kù),并創(chuàng)建convert方法

public class LameUtil {
    static {
        System.loadLibrary("mp3lame");
    }
    public native static void convert(String wavFile, String mp3File, int inSamplerate, int inChannel, int outBitrate);
}

1.3.4 調(diào)用native方法

                    new Thread(new Runnable() {
                        @Override
                        public void run() {
                            WavFileReader reader = new WavFileReader();
                            try {
                                if (reader.openFile(mWAVPathEt.getText().toString())) {
                                    //讀取wav文件的頭信息
                                    WavFileHeader wavFileHeader = reader.getmWavFileHeader();
                                    //把獲取到的wav頭信息傳入natvie方法
                                    LameUtil.convert(mWAVPathEt.getText().toString(), mMP3PathEt.getText().toString(), wavFileHeader.getmSampleRate(), wavFileHeader.getmNumChannel(), wavFileHeader.getmByteRate());
                                }


                                if (mFile.exists()) {
                                    runOnUiThread(new Runnable() {
                                        @Override
                                        public void run() {
                                            Toast.makeText(WAVTransMP3Activity.this, "轉(zhuǎn)碼成功:\t" + mFile.getAbsolutePath(), Toast.LENGTH_SHORT).show();
                                        }
                                    });
                                } else {
                                    runOnUiThread(new Runnable() {
                                        @Override
                                        public void run() {
                                            Toast.makeText(WAVTransMP3Activity.this, "轉(zhuǎn)碼失敗", Toast.LENGTH_SHORT).show();
                                        }
                                    });
                                }
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }
                    }).start();

那么轉(zhuǎn)碼成功后就大功告成了嗎?
遺憾的告訴你并沒(méi)有那么簡(jiǎn)單,因?yàn)檗D(zhuǎn)碼出來(lái)的音頻最開(kāi)始會(huì)啪的一聲,沒(méi)錯(cuò),每一個(gè)音頻都有,無(wú)一幸免,意不意外,驚不驚喜!!!


not_easy.jpg

這里啪的一聲是什么原因呢?
是因?yàn)檗D(zhuǎn)碼的時(shí)候把wav文件的頭信息也一起轉(zhuǎn)了,才會(huì)出現(xiàn)這種情況。那要怎么解決呢?
當(dāng)然是跳過(guò)這個(gè)頭信息,直接從數(shù)據(jù)開(kāi)始讀取。

fseek(fwav, 4*1024, SEEK_CUR);

對(duì),就是這一句話(huà)就可以了,完整代碼是這樣的

/**
 * 返回值 char* 這個(gè)代表char數(shù)組的首地址
 *  Jstring2CStr 把java中的jstring的類(lèi)型轉(zhuǎn)化成一個(gè)c語(yǔ)言中的char 字符串
 */
char* Jstring2CStr(JNIEnv* env, jstring jstr) {
    char* rtn = NULL;
    jclass clsstring = (*env)->FindClass(env, "java/lang/String"); //String
    jstring strencode = (*env)->NewStringUTF(env, "GB2312"); // 得到一個(gè)java字符串 "GB2312"
    jmethodID mid = (*env)->GetMethodID(env, clsstring, "getBytes",
            "(Ljava/lang/String;)[B"); //[ String.getBytes("gb2312");
    jbyteArray barr = (jbyteArray)(*env)->CallObjectMethod(env, jstr, mid,
            strencode); // String .getByte("GB2312");
    jsize alen = (*env)->GetArrayLength(env, barr); // byte數(shù)組的長(zhǎng)度
    jbyte* ba = (*env)->GetByteArrayElements(env, barr, JNI_FALSE);
    if (alen > 0) {
        rtn = (char*) malloc(alen + 1); //"\0"
        memcpy(rtn, ba, alen);
        rtn[alen] = 0;
    }
    (*env)->ReleaseByteArrayElements(env, barr, ba, 0); //
    return rtn;
}

int flag = 0;
/**
 * wav轉(zhuǎn)換mp3
 */
JNIEXPORT void JNICALL Java_com_czt_mp3recorder_util_LameUtil_convert
(JNIEnv * env, jobject obj, jstring jwav, jstring jmp3, jint inSamplerate, jint inChannel, jint outBitrate) {

    char* cwav =Jstring2CStr(env,jwav) ;
    char* cmp3=Jstring2CStr(env,jmp3);

    //1.打開(kāi) wav,MP3文件
    FILE* fwav = fopen(cwav,"rb");
    fseek(fwav, 4*1024, SEEK_CUR);
    FILE* fmp3 = fopen(cmp3,"wb+");

    int channel = inChannel;//單聲道

    short int wav_buffer[8192*channel];
    unsigned char mp3_buffer[8192];

    //1.初始化lame的編碼器
    lame_t lameConvert =  lame_init();

    //2. 設(shè)置lame mp3編碼的采樣率
    lame_set_in_samplerate(lameConvert , inSamplerate);
    lame_set_out_samplerate(lameConvert, inSamplerate);
    lame_set_num_channels(lameConvert,channel);
    lame_set_mode(lameConvert, MONO);
    // 3. 設(shè)置MP3的編碼方式
    lame_set_VBR(lameConvert, vbr_default);
    lame_init_params(lameConvert);
    int read ; int write; //代表讀了多少個(gè)次 和寫(xiě)了多少次
    int total=0; // 當(dāng)前讀的wav文件的byte數(shù)目
    do{
        if(flag==404){
            return;
        }
        read = fread(wav_buffer,sizeof(short int)*channel, 8192,fwav);
        total +=  read* sizeof(short int)*channel;
        if(read!=0){
            write = lame_encode_buffer(lameConvert, wav_buffer, NULL, read, mp3_buffer, 8192);
            //write = lame_encode_buffer_interleaved(lame,wav_buffer,read,mp3_buffer,8192);
        }else{
        write = lame_encode_flush(lameConvert,mp3_buffer,8192);
        }
        //把轉(zhuǎn)化后的mp3數(shù)據(jù)寫(xiě)到文件里
        fwrite(mp3_buffer,1,write,fmp3);

    }while(read!=0);
    lame_close(lameConvert);
    fclose(fwav);
    fclose(fmp3);
}

然后再轉(zhuǎn)碼之后,就沒(méi)有啪的一聲了,開(kāi)不開(kāi)心。
然而,你仔細(xì)觀察,發(fā)現(xiàn)是不是秒數(shù)不對(duì)了,想不想哭

cry.jpeg

那秒數(shù)沒(méi)有對(duì)是為啥呢?
因?yàn)樾枰獙?xiě)入相關(guān)的VBRTAG,也可以理解為mp3的頭信息。

寫(xiě)入VBRTAG

  • lame_mp3_tags_fid:向一個(gè)文件指針中寫(xiě)入規(guī)范的VBRTAG。

  • VBRTAG的作用是記錄整個(gè)mp3的一些信息,通常用于VBR模式下的編碼,因?yàn)閂BR模式下比特率不固定,無(wú)法直接計(jì)算出播放的時(shí)長(zhǎng)和跳躍點(diǎn),所以在mp3的開(kāi)頭部分插入一個(gè)VBRTAG。

  • VBRTAG有幾種規(guī)范,但是lame支持的是最通用的規(guī)范。

  • 注意lame_mp3_tags_fid函數(shù)的參數(shù)需要一個(gè)FILE *類(lèi)型代表要寫(xiě)入的文件,這個(gè)文件一定是之前編碼時(shí)寫(xiě)入了mp3數(shù)據(jù)的文件,VBRTAG是需要卸載mp3的開(kāi)頭的,之前的編碼過(guò)程中會(huì)自動(dòng)空出寫(xiě)入VBRTAG所需要的空間,這個(gè)函數(shù)內(nèi)會(huì)自動(dòng)尋找合適的文件偏移然后覆蓋,所以當(dāng)前的文件偏移是無(wú)關(guān)緊要的,但是打開(kāi)文件的時(shí)候一定要以讀寫(xiě)模式打開(kāi)。

  • 注意我提到了之前的編碼過(guò)程中會(huì)自動(dòng)空出寫(xiě)入VBRTAG所需要的空間,所以如果結(jié)束編碼后不調(diào)用lame_mp3_tags_fid寫(xiě)入VBRTAG就會(huì)導(dǎo)致這部分內(nèi)容為空,雖然不影響播放,但是會(huì)影響很多播放器對(duì)于時(shí)長(zhǎng)和跳躍點(diǎn)的計(jì)算。

  • 那么對(duì)于非VBR模式也需要寫(xiě)入VBRTAG嗎?是的,lame對(duì)于非VBR模式也會(huì)預(yù)留出VBRTAG的空間,所以非VBR模式的編碼最后也需要寫(xiě)入VBRTAG。

說(shuō)了那么多,意思就是這個(gè)函數(shù)是應(yīng)該在lame_encode_flush()之后調(diào), 當(dāng)所有數(shù)據(jù)都寫(xiě)入完畢了再調(diào)用。仔細(xì)想想也很合理, 這時(shí)才能確定文件的總幀數(shù)。

于是,我們就這樣寫(xiě)

/**
 * 返回值 char* 這個(gè)代表char數(shù)組的首地址
 *  Jstring2CStr 把java中的jstring的類(lèi)型轉(zhuǎn)化成一個(gè)c語(yǔ)言中的char 字符串
 */
char* Jstring2CStr(JNIEnv* env, jstring jstr) {
    char* rtn = NULL;
    jclass clsstring = (*env)->FindClass(env, "java/lang/String"); //String
    jstring strencode = (*env)->NewStringUTF(env, "GB2312"); // 得到一個(gè)java字符串 "GB2312"
    jmethodID mid = (*env)->GetMethodID(env, clsstring, "getBytes",
            "(Ljava/lang/String;)[B"); //[ String.getBytes("gb2312");
    jbyteArray barr = (jbyteArray)(*env)->CallObjectMethod(env, jstr, mid,
            strencode); // String .getByte("GB2312");
    jsize alen = (*env)->GetArrayLength(env, barr); // byte數(shù)組的長(zhǎng)度
    jbyte* ba = (*env)->GetByteArrayElements(env, barr, JNI_FALSE);
    if (alen > 0) {
        rtn = (char*) malloc(alen + 1); //"\0"
        memcpy(rtn, ba, alen);
        rtn[alen] = 0;
    }
    (*env)->ReleaseByteArrayElements(env, barr, ba, 0); //
    return rtn;
}

int flag = 0;
/**
 * wav轉(zhuǎn)換mp3
 */
JNIEXPORT void JNICALL Java_com_czt_mp3recorder_util_LameUtil_convert
(JNIEnv * env, jobject obj, jstring jwav, jstring jmp3, jint inSamplerate, jint inChannel, jint outBitrate) {

    char* cwav =Jstring2CStr(env,jwav) ;
    char* cmp3=Jstring2CStr(env,jmp3);

    //1.打開(kāi) wav,MP3文件
    FILE* fwav = fopen(cwav,"rb");
    fseek(fwav, 4*1024, SEEK_CUR);
    FILE* fmp3 = fopen(cmp3,"wb+");

    int channel = inChannel;//單聲道

    short int wav_buffer[8192*channel];
    unsigned char mp3_buffer[8192];

    //1.初始化lame的編碼器
    lame_t lameConvert =  lame_init();

    //2. 設(shè)置lame mp3編碼的采樣率
    lame_set_in_samplerate(lameConvert , inSamplerate);
    lame_set_out_samplerate(lameConvert, inSamplerate);
    lame_set_num_channels(lameConvert,channel);
    lame_set_mode(lameConvert, MONO);
    // 3. 設(shè)置MP3的編碼方式
    lame_set_VBR(lameConvert, vbr_default);
    lame_init_params(lameConvert);
    int read ; int write; //代表讀了多少個(gè)次 和寫(xiě)了多少次
    int total=0; // 當(dāng)前讀的wav文件的byte數(shù)目
    do{
        if(flag==404){
            return;
        }
        read = fread(wav_buffer,sizeof(short int)*channel, 8192,fwav);
        total +=  read* sizeof(short int)*channel;
        if(read!=0){
            write = lame_encode_buffer(lameConvert, wav_buffer, NULL, read, mp3_buffer, 8192);
            //write = lame_encode_buffer_interleaved(lame,wav_buffer,read,mp3_buffer,8192);
        }else{
        write = lame_encode_flush(lameConvert,mp3_buffer,8192);
        }
        //把轉(zhuǎn)化后的mp3數(shù)據(jù)寫(xiě)到文件里
        fwrite(mp3_buffer,1,write,fmp3);

    }while(read!=0);
    lame_mp3_tags_fid(lameConvert,fmp3);
    lame_close(lameConvert);
    fclose(fwav);
    fclose(fmp3);
}

再重新ndk-build,再編譯一次,重新安裝,再轉(zhuǎn)碼一次,大功終于告成了

2. Android 使用 lame pcm 轉(zhuǎn)碼 mp3(邊錄邊轉(zhuǎn))

邊錄邊轉(zhuǎn)的原理就是,拿到pcm數(shù)據(jù),馬上轉(zhuǎn)成mp3數(shù)據(jù)并寫(xiě)入相關(guān)文件,當(dāng)錄制結(jié)束,轉(zhuǎn)換也同時(shí)結(jié)束。

2.1 DataEncodeThread的編寫(xiě)

那么肯定需要跑一個(gè)線(xiàn)程來(lái)進(jìn)行解碼和寫(xiě)入的工作,但是每次寫(xiě)入和轉(zhuǎn)換肯定有很多次,這里使用HandlerThread來(lái)進(jìn)行。

public class DataEncodeThread extends HandlerThread implements AudioRecord.OnRecordPositionUpdateListener {
    private StopHandler mHandler;
    private static final int PROCESS_STOP = 1;
    private byte[] mMp3Buffer;
    private FileOutputStream mFileOutputStream;

    private static class StopHandler extends Handler {
        
        private DataEncodeThread encodeThread;
        
        public StopHandler(Looper looper, DataEncodeThread encodeThread) {
            super(looper);
            this.encodeThread = encodeThread;
        }

        @Override
        public void handleMessage(Message msg) {
            if (msg.what == PROCESS_STOP) {
                //處理緩沖區(qū)中的數(shù)據(jù)
                while (encodeThread.processData() > 0);
                // Cancel any event left in the queue
                removeCallbacksAndMessages(null);
                encodeThread.flushAndRelease();
                getLooper().quit();
            }
        }
    }

    /**
     * Constructor
     * @param file file
     * @param bufferSize bufferSize
     * @throws FileNotFoundException file not found
     */
    public DataEncodeThread(File file, int bufferSize) throws FileNotFoundException {
        super("DataEncodeThread");
        this.mFileOutputStream = new FileOutputStream(file);
        mMp3Buffer = new byte[(int) (7200 + (bufferSize * 2 * 1.25))];
    }

    @Override
    public synchronized void start() {
        super.start();
        mHandler = new StopHandler(getLooper(), this);
    }

    private void check() {
        if (mHandler == null) {
            throw new IllegalStateException();
        }
    }

    public void sendStopMessage() {
        check();
        mHandler.sendEmptyMessage(PROCESS_STOP);
    }
    public Handler getHandler() {
        check();
        return mHandler;
    }

    @Override
    public void onMarkerReached(AudioRecord recorder) {
        // Do nothing       
    }

    @Override
    public void onPeriodicNotification(AudioRecord recorder) {
        processData();
    }
    /**
     * 從緩沖區(qū)中讀取并處理數(shù)據(jù),使用lame編碼MP3
     * @return  從緩沖區(qū)中讀取的數(shù)據(jù)的長(zhǎng)度
     *          緩沖區(qū)中沒(méi)有數(shù)據(jù)時(shí)返回0 
     */
    private int processData() { 
        if (mTasks.size() > 0) {
            Task task = mTasks.remove(0);
            short[] buffer = task.getData();
            int readSize = task.getReadSize();
            int encodedSize = LameUtil.encode(buffer, buffer, readSize, mMp3Buffer);
            if (encodedSize > 0){
                try {
                    mFileOutputStream.write(mMp3Buffer, 0, encodedSize);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            return readSize;
        }
        return 0;
    }
    
    /**
     * Flush all data left in lame buffer to file
     */
    private void flushAndRelease() {
        //將MP3結(jié)尾信息寫(xiě)入buffer中
        final int flushResult = LameUtil.flush(mMp3Buffer);
        if (flushResult > 0) {
            try {
                mFileOutputStream.write(mMp3Buffer, 0, flushResult);
            } catch (IOException e) {
                e.printStackTrace();
            }finally{
                if (mFileOutputStream != null) {
                    try {
                        mFileOutputStream.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                LameUtil.close();
            }
        }
    }
    private List<Task> mTasks = Collections.synchronizedList(new ArrayList<Task>());
    public void addTask(short[] rawData, int readSize){
        mTasks.add(new Task(rawData, readSize));
    }
    private class Task{
        private short[] rawData;
        private int readSize;
        public Task(short[] rawData, int readSize){
            this.rawData = rawData.clone();
            this.readSize = readSize;
        }
        public short[] getData(){
            return rawData;
        }
        public int getReadSize(){
            return readSize;
        }
    }
}

下面是調(diào)用錄音的代碼

    /**
     * Start recording. Create an encoding thread. Start record from this
     * thread.
     * 
     * @throws IOException  initAudioRecorder throws
     */
    public void start() throws IOException {
        if (mIsRecording) {
            return;
        }
        mIsRecording = true; // 提早,防止init或startRecording被多次調(diào)用
        initAudioRecorder();
        mAudioRecord.startRecording();
        new Thread() {
            @Override
            public void run() {
                //設(shè)置線(xiàn)程權(quán)限
                android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
                while (mIsRecording) {
                    int readSize = mAudioRecord.read(mPCMBuffer, 0, mBufferSize);
                    if (readSize > 0) {
                        mEncodeThread.addTask(mPCMBuffer, readSize);
                        calculateRealVolume(mPCMBuffer, readSize);
                    }
                }
                // release and finalize audioRecord
                mAudioRecord.stop();
                mAudioRecord.release();
                mAudioRecord = null;
                // stop the encoding thread and try to wait
                // until the thread finishes its job
                mEncodeThread.sendStopMessage();
            }
            /**
             * 此計(jì)算方法來(lái)自samsung開(kāi)發(fā)范例
             * 
             * @param buffer buffer
             * @param readSize readSize
             */
            private void calculateRealVolume(short[] buffer, int readSize) {
                double sum = 0;
                for (int i = 0; i < readSize; i++) {  
                    // 這里沒(méi)有做運(yùn)算的優(yōu)化,為了更加清晰的展示代碼  
                    sum += buffer[i] * buffer[i]; 
                } 
                if (readSize > 0) {
                    double amplitude = sum / readSize;
                    mVolume = (int) Math.sqrt(amplitude);
                }
            }
        }.start();
    }

這樣也就實(shí)現(xiàn)了相關(guān)的功能,具體轉(zhuǎn)換的方法上面已經(jīng)提過(guò),這里就不再贅述了。

3. mp3 轉(zhuǎn) wav

既然是要實(shí)現(xiàn)mp3轉(zhuǎn)換為wav格式,那么必須先解碼mp3為pcm數(shù)據(jù),再將pcm數(shù)據(jù)寫(xiě)入相關(guān)的頭信息,這樣就實(shí)現(xiàn)了mp3轉(zhuǎn)換為wav。

下面是解碼mp3文件為pcm文件:

    public static String fenLiData(String path, String newPath) throws IOException {
        File file = new File(path);// 原文件
        File file1 = new File(path + "01");// 分離ID3V2后的文件,這是個(gè)中間文件,最后要被刪除
        File file2 = new File(newPath);// 分離id3v1后的文件
        RandomAccessFile rf = new RandomAccessFile(file, "rw");// 隨機(jī)讀取文件
        FileOutputStream fos = new FileOutputStream(file1);
        byte ID3[] = new byte[3];
        rf.read(ID3);
        String ID3str = new String(ID3);
        // 分離ID3v2
        if (ID3str.equals("ID3")) {
            rf.seek(6);
            byte[] ID3size = new byte[4];
            rf.read(ID3size);
            int size1 = (ID3size[0] & 0x7f) << 21;
            int size2 = (ID3size[1] & 0x7f) << 14;
            int size3 = (ID3size[2] & 0x7f) << 7;
            int size4 = (ID3size[3] & 0x7f);
            int size = size1 + size2 + size3 + size4 + 10;
            rf.seek(size);
            int lens = 0;
            byte[] bs = new byte[1024 * 4];
            while ((lens = rf.read(bs)) != -1) {
                fos.write(bs, 0, lens);
            }
            fos.close();
            rf.close();
        } else {// 否則完全復(fù)制文件
            int lens = 0;
            rf.seek(0);
            byte[] bs = new byte[1024 * 4];
            while ((lens = rf.read(bs)) != -1) {
                fos.write(bs, 0, lens);
            }
            fos.close();
            rf.close();
        }
        RandomAccessFile raf = new RandomAccessFile(file1, "rw");
        byte TAG[] = new byte[3];
        raf.seek(raf.length() - 128);
        raf.read(TAG);
        String tagstr = new String(TAG);
        if (tagstr.equals("TAG")) {
            FileOutputStream fs = new FileOutputStream(file2);
            raf.seek(0);
            byte[] bs = new byte[(int) (raf.length() - 128)];
            raf.read(bs);
            fs.write(bs);
            raf.close();
            fs.close();
        } else {// 否則完全復(fù)制內(nèi)容至file2
            FileOutputStream fs = new FileOutputStream(file2);
            raf.seek(0);
            byte[] bs = new byte[1024 * 4];
            int len = 0;
            while ((len = raf.read(bs)) != -1) {
                fs.write(bs, 0, len);
            }
            raf.close();
            fs.close();
        }
        if (file1.exists())// 刪除中間文件
        {
            file1.delete();

        }
        return file2.getAbsolutePath();
    }

然后再進(jìn)行pcm文件寫(xiě)入頭信息:

    /**
     * pcm文件轉(zhuǎn)wav文件
     *
     * @param inFilename  源文件路徑
     * @param outFilename 目標(biāo)文件路徑
     */
    public void pcmToWav(String inFilename, String outFilename) {
        FileInputStream in;
        FileOutputStream out;
        long totalAudioLen;
        long totalDataLen;
        long longSampleRate = mSampleRate;
        int channels = 2;
        long byteRate = 16 * mSampleRate * channels / 8;
        byte[] data = new byte[mBufferSize];
        try {
            in = new FileInputStream(inFilename);
            out = new FileOutputStream(outFilename);
            totalAudioLen = in.getChannel().size();
            totalDataLen = totalAudioLen + 36;

            writeWaveFileHeader(out, totalAudioLen, totalDataLen,
                    longSampleRate, channels, byteRate);
            while (in.read(data) != -1) {
                out.write(data);
            }
            in.close();
            out.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 加入wav文件頭
     */
    private void writeWaveFileHeader(FileOutputStream out, long totalAudioLen,
                                     long totalDataLen, long longSampleRate, int channels, long byteRate)
            throws IOException {
        byte[] header = new byte[44];
        header[0] = 'R'; // RIFF/WAVE header
        header[1] = 'I';
        header[2] = 'F';
        header[3] = 'F';
        header[4] = (byte) (totalDataLen & 0xff);
        header[5] = (byte) ((totalDataLen >> 8) & 0xff);
        header[6] = (byte) ((totalDataLen >> 16) & 0xff);
        header[7] = (byte) ((totalDataLen >> 24) & 0xff);
        header[8] = 'W';  //WAVE
        header[9] = 'A';
        header[10] = 'V';
        header[11] = 'E';
        header[12] = 'f'; // 'fmt ' chunk
        header[13] = 'm';
        header[14] = 't';
        header[15] = ' ';
        header[16] = 16;  // 4 bytes: size of 'fmt ' chunk
        header[17] = 0;
        header[18] = 0;
        header[19] = 0;
        header[20] = 1;   // format = 1
        header[21] = 0;
        header[22] = (byte) channels;
        header[23] = 0;
        header[24] = (byte) (longSampleRate & 0xff);
        header[25] = (byte) ((longSampleRate >> 8) & 0xff);
        header[26] = (byte) ((longSampleRate >> 16) & 0xff);
        header[27] = (byte) ((longSampleRate >> 24) & 0xff);
        header[28] = (byte) (byteRate & 0xff);
        header[29] = (byte) ((byteRate >> 8) & 0xff);
        header[30] = (byte) ((byteRate >> 16) & 0xff);
        header[31] = (byte) ((byteRate >> 24) & 0xff);
        header[32] = (byte) (2 * 16 / 8); // block align
        header[33] = 0;
        header[34] = 16;  // bits per sample
        header[35] = 0;
        header[36] = 'd'; //data
        header[37] = 'a';
        header[38] = 't';
        header[39] = 'a';
        header[40] = (byte) (totalAudioLen & 0xff);
        header[41] = (byte) ((totalAudioLen >> 8) & 0xff);
        header[42] = (byte) ((totalAudioLen >> 16) & 0xff);
        header[43] = (byte) ((totalAudioLen >> 24) & 0xff);
        out.write(header, 0, 44);
    }

以上便是mp3轉(zhuǎn)換為wav 的方法。

4. mp3 轉(zhuǎn) pcm (邊播邊轉(zhuǎn))

其實(shí)和pcm轉(zhuǎn)mp3邊錄邊轉(zhuǎn)的原理是一樣的,也是拿到數(shù)據(jù)再進(jìn)行解碼,不過(guò)這次要用到的mad庫(kù)來(lái)進(jìn)行解碼工作。

    private void startDecode() {
        if (ret == -1) {
            Log.i("conowen", "Couldn't open file '" + mMP3PathEt.getText().toString() + "'");

        } else {
            mThreadFlag = true;
            initAudioPlayer();

            audioBuffer = new short[1024 * 1024];
            mThread = new Thread(new Runnable() {

                @Override
                public void run() {
                    // TODO Auto-generated method stub
                    while (mThreadFlag) {
                        if (null != mAudioTrack && mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_PAUSED) {
                            // ****從libmad處獲取data******/
                            MP3Decoder.getAudioBuf(audioBuffer,
                                    mAudioMinBufSize);
                            if(null != mAudioTrack){
                                mAudioTrack.write(audioBuffer, 0, mAudioMinBufSize);
                            }
                        } else {
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                // TODO Auto-generated catch block
                                e.printStackTrace();
                            }
                        }
                    }

                }
            });

        }
    }

其中,MP3Decoder.getAudioBuf(audioBuffer,mAudioMinBufSize);是調(diào)用的mad的庫(kù)的方法,具體方法網(wǎng)上都有提供,這里只是貼出相應(yīng)的c代碼。

#define LOG_TAG "NativeMP3Decoder"
#include <fcntl.h>
#include <jni.h>
#include "mad/mad.h"
#include "NativeMP3Decoder.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <android/log.h>
#include "FileSystem.h"


#define INPUT_BUFFER_SIZE   (8192/4)
#define OUTPUT_BUFFER_SIZE  8192 /* Must be an integer multiple of 4. */


#define LOGI(fmt, args...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, fmt, ##args)
#define LOGD(fmt, args...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, fmt, ##args)
#define LOGE(fmt, args...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, fmt, ##args)
//int g_size;


/**
 * Struct holding the pointer to a wave file.
 */
typedef struct
{
    int size;
    int64_t fileStartPos;
    T_pFILE file;
    struct mad_stream stream;
    struct mad_frame frame;
    struct mad_synth synth;
    mad_timer_t timer;
    int leftSamples;
    int offset;
    unsigned char inputBuffer[INPUT_BUFFER_SIZE];
} MP3FileHandle;

/** static WaveFileHandle array **/
static inline int readNextFrame( MP3FileHandle* mp3 );

static MP3FileHandle* Handle;
unsigned int g_Samplerate;

/**
 * Seeks a free handle in the handles array and returns its index or -1 if no handle could be found
 */

extern int file_open(const char *filename, int flags);
extern int file_read(T_pFILE fd, unsigned char *buf, int size);
extern int file_write(T_pFILE fd, unsigned char *buf, int size);
extern int64_t file_seek(T_pFILE fd, int64_t pos, int whence);
extern int file_close(T_pFILE fd);

static inline void closeHandle()
{
    file_close( Handle->file);
    mad_synth_finish(&Handle->synth);
    mad_frame_finish(&Handle->frame);
    mad_stream_finish(&Handle->stream);
    free(Handle);
    Handle = NULL;
}

static inline signed short fixedToShort(mad_fixed_t Fixed)
{
    if(Fixed>=MAD_F_ONE)
        return(SHRT_MAX);
    if(Fixed<=-MAD_F_ONE)
        return(-SHRT_MAX);

    Fixed=Fixed>>(MAD_F_FRACBITS-15);
    return((signed short)Fixed);
}


int  NativeMP3Decoder_init(char * filepath,unsigned long start/*,unsigned long size*/)
{
    LOGI("bfp----->NativeMP3Decoder_init start filepath: %s",filepath);
    LOGI("bfp----->NativeMP3Decoder_init  start: %ld",start);
    T_pFILE fileHandle = file_open( filepath, _FMODE_READ);
    LOGI("bfp----->NativeMP3Decoder_init fileHandle: %ld",fileHandle);
    if( fileHandle <= 0 )
        return -1;

    MP3FileHandle* mp3Handle = (MP3FileHandle*)malloc(sizeof(MP3FileHandle));
    memset(mp3Handle, 0, sizeof(MP3FileHandle));
    mp3Handle->file = fileHandle;

    mp3Handle->fileStartPos= start;

    file_seek( mp3Handle->file, start, SEEK_SET);

    mad_stream_init(&mp3Handle->stream);
    mad_frame_init(&mp3Handle->frame);
    mad_synth_init(&mp3Handle->synth);
    mad_timer_reset(&mp3Handle->timer);

    Handle = mp3Handle;

    readNextFrame( Handle );

    g_Samplerate = Handle->frame.header.samplerate;
    LOGI("bfp----->NativeMP3Decoder_init fileHandle: end");
    return 1;
}

static inline int readNextFrame( MP3FileHandle* mp3 )
{
    do
    {
        if( mp3->stream.buffer == 0 || mp3->stream.error == MAD_ERROR_BUFLEN )
        {
            int inputBufferSize = 0;

            if( mp3->stream.next_frame != 0 )
            {

                int leftOver = mp3->stream.bufend - mp3->stream.next_frame;
                int i;
                for(  i= 0; i < leftOver; i++ )
                    mp3->inputBuffer[i] = mp3->stream.next_frame[i];
                int readBytes = file_read( mp3->file, mp3->inputBuffer + leftOver, INPUT_BUFFER_SIZE - leftOver);
                if( readBytes == 0 )
                    return 0;
                inputBufferSize = leftOver + readBytes;
            }
            else
            {

                int readBytes = file_read( mp3->file, mp3->inputBuffer, INPUT_BUFFER_SIZE);
                if( readBytes == 0 )
                    return 0;
                inputBufferSize = readBytes;
            }

            mad_stream_buffer( &mp3->stream, mp3->inputBuffer, inputBufferSize );
            mp3->stream.error = MAD_ERROR_NONE;

        }

        if( mad_frame_decode( &mp3->frame, &mp3->stream ) )
        {

            if( mp3->stream.error == MAD_ERROR_BUFLEN ||(MAD_RECOVERABLE(mp3->stream.error)))
                continue;
            else
                return 0;
        }
        else
            break;
    }
    while( 1 );

    mad_timer_add( &mp3->timer, mp3->frame.header.duration );
    mad_synth_frame( &mp3->synth, &mp3->frame );
    mp3->leftSamples = mp3->synth.pcm.length;
    mp3->offset = 0;

    return -1;
}



int NativeMP3Decoder_readSamples(short *target, int size)
{
    LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_getAudioBuf start size %d",size);
    MP3FileHandle* mp3 = Handle;
    int pos=0;
    int idx = 0;
    while( idx != size )
    {
        if( mp3->leftSamples > 0 )
        {
            for( ; idx < size && mp3->offset < mp3->synth.pcm.length; mp3->leftSamples--, mp3->offset++ )
            {
                int value = fixedToShort(mp3->synth.pcm.samples[0][mp3->offset]);

                if( MAD_NCHANNELS(&mp3->frame.header) == 2 )
                {
                    value += fixedToShort(mp3->synth.pcm.samples[1][mp3->offset]);
                    value /= 2;
                }

                target[idx++] = value;
            }
        }
        else
        {

            pos = file_seek( mp3->file, 0, SEEK_CUR);

            int result = readNextFrame( mp3);
            if( result == 0 )
                return 0;
        }

    }

    if( idx > size )
        return 0;
     LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_getAudioBuf end pos %d",pos);
     return pos;

}

int NativeMP3Decoder_getAduioSamplerate()
{
    LOGI("bfp----->NativeMP3Decoder_getAduioSamplerate g_Samplerate %d",g_Samplerate);
    return g_Samplerate;

}


void  NativeMP3Decoder_closeAduioFile()
{
    LOGI("bfp----->NativeMP3Decoder_closeAduioFile start Handle:%d",Handle->size);
    if( Handle != 0 )
    {
        closeHandle();
        Handle = 0;
    }
    LOGI("bfp----->NativeMP3Decoder_closeAduioFile end");
}

jint Java_com_czt_mp3recorder_NativeMP3Decoder_initAudioPlayer(JNIEnv *env, jobject obj, jstring file,jint startAddr)
{
     LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_initAudioPlayer start");
     char*  fileString = (*env)->GetStringUTFChars(env,file, 0);
     LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_initAudioPlayer end");
    return  NativeMP3Decoder_init(fileString,startAddr);

}

 jint Java_com_czt_mp3recorder_NativeMP3Decoder_getAudioBuf(JNIEnv *env, jobject obj ,jshortArray audioBuf,jint len)
{
    LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_getAudioBuf start len:%d",len);
    int bufsize = 0;
    int ret = 0;
    if (audioBuf != NULL) {
        bufsize = (*env)->GetArrayLength(env, audioBuf);
        jshort *_buf = (*env)->GetShortArrayElements(env, audioBuf, 0);
        memset(_buf, 0, bufsize*2);
        ret = NativeMP3Decoder_readSamples(_buf, len);
        (*env)->ReleaseShortArrayElements(env, audioBuf, _buf, 0);
    }
    else{
         LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_getAudioBuf getAudio failed");
        }
    LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_getAudioBuf end ret:%d",ret);
    return ret;
}

 jint Java_com_czt_mp3recorder_NativeMP3Decoder_getAudioSamplerate(JNIEnv *env, jobject obj)
{
     LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_getAudioSamplerate  start");
    return NativeMP3Decoder_getAduioSamplerate();
    LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_getAudioSamplerate  end");
}


 void Java_com_czt_mp3recorder_NativeMP3Decoder_closeAduioFile(JNIEnv *env, jobject obj)

{
    LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_closeAduioFile str");
    NativeMP3Decoder_closeAduioFile();
    LOGI("bfp----->Java_com_mediatek_factorymode_NativeMP3Decoder_closeAduioFile end");

}

 jint JNI_OnLoad(JavaVM* vm, void* reserved) {
     void *venv;
     LOGI("bfp----->JNI_OnLoad!");
     if ((*vm)->GetEnv(vm, (void**) &venv, JNI_VERSION_1_4) != JNI_OK) {
         LOGE("bfp--->ERROR: GetEnv failed");
         return -1;
     }
     return JNI_VERSION_1_4;
 }

最后

感謝大家的支持和閱讀,完整項(xiàng)目代碼已經(jīng)上傳,再次感謝大家
https://pan.baidu.com/s/1faWwLbvQhd7v1m-woXtHvA

十分感謝以下博客的分享:

https://www.imooc.com/article/27041?block_id=tuijian_wz
https://blog.csdn.net/aiyh0202/article/details/52815374
https://blog.csdn.net/qq634416025/article/details/51424556
http://www.cnblogs.com/ct2011/p/4080193.html
https://blog.csdn.net/bjrxyz/article/details/73435407?locationNum=15&fps=1
https://blog.csdn.net/haovip123/article/details/52356024
http://m.itdecent.cn/p/971fff236881

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容