Android音頻開發

這篇博客 轉載自 https://www.jianshu.com/p/c0222de2faed

這裏涉及到ndk的一些知識,對於.mk文件不太熟悉的同學要自己去 官網 或者搜索一些博客瞭解基本知識。

1. 音頻基礎知識

音頻基礎知識

採樣和採樣頻率:
現在是數字時代,在音頻處理時要先把音頻的模擬信號變成數字信號,這叫A/D轉換。要把音頻的模擬信號變成數字信號,就需要採樣。一秒鐘內採樣的次數稱爲採樣頻率

採樣頻率越高,越接近原始信號,但是也加大了運算處理的複雜度。16000Hz和44.1kHZ(1

採樣位數/位寬:
數字信號是用0和1來表示的。採樣位數就是採樣值用多少位0和1來表示,也叫採樣精度,用的位數越多就越接近真實聲音。如用8位表示,採樣值取值範圍就是-128 ~ 127,如用16位表示,採樣值取值範圍就是-32768 ~ 32767。

聲道(channel):
通常語音只用一個聲道。而對於音樂來說,既可以是單聲道(mono),也可以是雙聲道(即左聲道右聲道,叫立體聲stereo),還可以是多聲道,叫環繞立體聲。

編解碼 :
通常把音頻採樣過程也叫做脈衝編碼調製編碼,即PCM(Pulse Code Modulation)編碼,採樣值也叫PCM值。 如果把採樣值直接保存或者發送,會佔用很大的存儲空間。以16kHz採樣率16位採樣位數單聲道爲例,一秒鐘就有16/8*16000 = 32000字節。爲了節省保存空間或者發送流量,會對PCM值壓縮。

目前主要有三大技術標準組織制定壓縮標準:

  1. ITU,主要制定有線語音的壓縮標準(g系列),有g711/g722/g726/g729等。
  2. 3GPP,主要制定無線語音的壓縮標準(amr系列等),有amr-nb/amr-wb。後來ITU吸納了amr-wb,形成了g722.2。
  3. MPEG,主要制定音樂的壓縮標準,有11172-3,13818-3/7,14496-3等。
    一些大公司或者組織也制定壓縮標準,比如iLBC,OPUS。
編碼過程:模擬信號->抽樣->量化->編碼->數字信號

壓縮:
對於自然界中的音頻信號,如果轉換成數字信號,進行音頻編碼,那麼只能無限接近,不可能百分百還原。所以說實際上任何信號轉換成數字信號都會“有損”。但是在計算機應用中,能夠達到最高保真水平的就是PCM編碼。因此,PCM約定俗成了無損編碼。我們而習慣性的把MP3列入有損音頻編碼範疇,是相對PCM編碼的。強調編碼的相對性的有損和無損

碼率:
碼率 = 採樣頻率 * 採樣位數 * 聲道個數; 例:採樣頻率44.1KHz,量化位數16bit,立體聲(雙聲道),未壓縮時的碼率 = 44.1KHz * 16 * 2 = 1411.2Kbps = 176.4KBps,即每秒要錄製的資源大小,理論上碼率和質量成正比。

800 bps – 能夠分辨的語音所需最低碼率(需使用專用的FS-1015語音編解碼器)
8 kbps —電話質量(使用語音編碼)
8-500 kbps --Ogg Vorbis和MPEG1 Player1/2/3中使用的有損音頻模式
500 kbps–1.4 Mbps —44.1KHz的無損音頻,解碼器爲FLAC Audio,WavPack或Monkey's Audio
1411.2 - 2822.4 Kbps —脈衝編碼調製(PCM)聲音格式CD光碟的數字音頻
5644.8 kbps —SACD使用的Direct Stream Digital格式

常用音頻格式

WAV 格式:音質高 無損格式 體積較大
AAC(Advanced Audio Coding) 格式:相對於 mp3,AAC 格式的音質更佳,文件更小,有損壓縮,一般蘋果或者Android SDK4.1.2(API 16)及以上版本支持播放,性價比高
AMR 格式:壓縮比比較大,但相對其他的壓縮格式質量比較差,多用於人聲,通話錄音

AMR分類:
AMR(AMR-NB): 語音帶寬範圍:3003400Hz,8KHz抽樣

mp3 格式:特點 使用廣泛, 有損壓縮,犧牲了12KHz到16KHz高音頻的音質

音頻開發的主要應用

  • 音頻播放器
  • 錄音機
  • 語音電話
  • 音視頻監控應用
  • 音視頻直播應用
  • 音頻編輯/處理軟件(ktv音效、變聲, 鈴聲轉換)
  • 藍牙耳機/音箱

音頻開發的具體內容

  • 音頻採集/播放
  • 音頻算法處理(去噪、靜音檢測、回聲消除、音效處理、功放/增強、混音/分離,等等)
  • 音頻的編解碼和格式轉換
  • 音頻傳輸協議的開發(SIP,A2DP、AVRCP,等等)

2. 使用AudioRecord錄製pcm格式音頻

AudioRecord類的介紹

1. AudioRecord構造函數:

 /**
 * @param audioSource :錄音源
 * 這裏選擇使用麥克風:MediaRecorder.AudioSource.MIC
 * @param sampleRateInHz: 採樣率
 * @param channelConfig:聲道數  
 * @param audioFormat: 採樣位數.
 *   See {@link AudioFormat#ENCODING_PCM_8BIT}, {@link AudioFormat#ENCODING_PCM_16BIT},
 *   and {@link AudioFormat#ENCODING_PCM_FLOAT}.
 * @param bufferSizeInBytes: 音頻錄製的緩衝區大小
 *   See {@link #getMinBufferSize(int, int, int)}  
 */
public AudioRecord(int audioSource, int sampleRateInHz, int channelConfig, int audioFormat,
        int bufferSizeInBytes)

2. getMinBufferSize()

/**
* 獲取AudioRecord所需的最小緩衝區大小
* @param sampleRateInHz: 採樣率
* @param channelConfig:聲道數  
* @param audioFormat: 採樣位數.
*/
public static int getMinBufferSize (int sampleRateInHz, 
            int channelConfig, 
            int audioFormat)

3. getRecordingState()

/**
* 獲取AudioRecord當前的錄音狀態 
*   @see AudioRecord#RECORDSTATE_STOPPED    
*   @see AudioRecord#RECORDSTATE_RECORDING
*/
public int getRecordingState()

4. startRecording()

 /**
 * 開始錄製
 */
 public int startRecording()

5. stop()

 /**
 * 停止錄製
 */
 public int stop()

6. read()

/**
 * 從錄音設備中讀取音頻數據
 * @param audioData 音頻數據寫入的byte[]緩衝區
 * @param offsetInBytes 偏移量
 * @param sizeInBytes 讀取大小
 * @return 返回負數則表示讀取失敗
 *      see {@link #ERROR_INVALID_OPERATION} -3 : 初始化錯誤
        {@link #ERROR_BAD_VALUE}  -3: 參數錯誤
        {@link #ERROR_DEAD_OBJECT} -6: 
        {@link #ERROR}  
 */
public int read(@NonNull byte[] audioData, int offsetInBytes, int sizeInBytes) 

實現

實現過程就是調用上面的API的方法,構造AudioRecord實例後再調用startRecording(),開始錄音,並通過read()方法不斷獲取錄音數據記錄下來,生成PCM文件。涉及耗時操作,所以最好在子線程中進行。

public class RecordHelper {
    //0.此狀態用於控制線程中的循環操作,應用volatile修飾,保持數據的一致性
    private volatile RecordState state = RecordState.IDLE;
    private AudioRecordThread audioRecordThread;
    private File tmpFile = null;

    public void start(String filePath, RecordConfig config) {
        if (state != RecordState.IDLE) {
            Logger.e(TAG, "狀態異常當前狀態: %s", state.name());
            return;
        }
        recordFile = new File(filePath);
        String tempFilePath = getTempFilePath();
        Logger.i(TAG, "tmpPCM File: %s", tempFilePath);
        tmpFile = new File(tempFilePath);
        //1.開啓錄音線程並準備錄音
        audioRecordThread = new AudioRecordThread();
        audioRecordThread.start();
    }

    public void stop() {
        if (state == RecordState.IDLE) {
            Logger.e(TAG, "狀態異常當前狀態: %s", state.name());
            return;
        }

        state = RecordState.STOP;
    }

    private class AudioRecordThread extends Thread {
        private AudioRecord audioRecord;
        private int bufferSize;

        AudioRecordThread() {
            //2.根據錄音參數構造AudioRecord實體對象
            bufferSize = AudioRecord.getMinBufferSize(currentConfig.getFrequency(),
                    currentConfig.getChannel(), currentConfig.getEncoding()) * RECORD_AUDIO_BUFFER_TIMES;
            audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, currentConfig.getFrequency(),
                    currentConfig.getChannel(), currentConfig.getEncoding(), bufferSize);
        }

        @Override
        public void run() {
            super.run();
            state = RecordState.RECORDING;
            Logger.d(TAG, "開始錄製");
            FileOutputStream fos = null;
            try {
                fos = new FileOutputStream(tmpFile);
                audioRecord.startRecording();
                byte[] byteBuffer = new byte[bufferSize];

                while (state == RecordState.RECORDING) {
                    //3.不斷讀取錄音數據並保存至文件中
                    int end = audioRecord.read(byteBuffer, 0, byteBuffer.length);
                    fos.write(byteBuffer, 0, end);
                    fos.flush();
                }
                //4.當執行stop()方法後state != RecordState.RECORDING,終止循環,停止錄音
                audioRecord.stop();
            } catch (Exception e) {
                Logger.e(e, TAG, e.getMessage());
            } finally {
                try {
                    if (fos != null) {
                        fos.close();
                    }
                } catch (IOException e) {
                    Logger.e(e, TAG, e.getMessage());
                }
            }
            state = RecordState.IDLE;
            Logger.d(TAG, "錄音結束");
        }
    }
}

其他

  • 這裏實現了PCM音頻的錄製,AudioRecord
    API中只有開始和停止的方法,在實際開發中可能還需要暫停/恢復的操作,以及PCM轉WAV的功能,下一篇再繼續完善。
  • 需要錄音及文件處理的動態權限

3. 使用AudioRecord實現錄音的暫停和恢復

上一部分主要寫了AudioRecord實現音頻錄製的開始和停止,AudioRecord並沒有暫停和恢復播放功能的API,所以需要手動實現。

解決辦法

思路很簡單,現在可以實現音頻的文件錄製和停止,並生成pcm文件,那麼暫停時將這次文件先保存下來,恢復播放後開始新一輪的錄製,那麼最後會生成多個pcm音頻,再將這些pcm文件進行合併,這樣就實現了暫停/恢復的功能了。

實現

  • 實現的重點在於如何控制錄音的狀態
public class RecordHelper {
    private volatile RecordState state = RecordState.IDLE;
    private AudioRecordThread audioRecordThread;

    private File recordFile = null;
    private File tmpFile = null;
    private List<File> files = new ArrayList<>();

    public void start(String filePath, RecordConfig config) {
        this.currentConfig = config;
        if (state != RecordState.IDLE) {
            Logger.e(TAG, "狀態異常當前狀態: %s", state.name());
            return;
        }
        recordFile = new File(filePath);
        String tempFilePath = getTempFilePath();
        Logger.i(TAG, "tmpPCM File: %s", tempFilePath);
        tmpFile = new File(tempFilePath);
        audioRecordThread = new AudioRecordThread();
        audioRecordThread.start();
    }

    public void stop() {
        if (state == RecordState.IDLE) {
            Logger.e(TAG, "狀態異常當前狀態: %s", state.name());
            return;
        }

        //若在暫停中直接停止,則直接合並文件即可
        if (state == RecordState.PAUSE) {
            makeFile();
            state = RecordState.IDLE;
        } else {
            state = RecordState.STOP;
        }
    }

    public void pause() {
        if (state != RecordState.RECORDING) {
            Logger.e(TAG, "狀態異常當前狀態: %s", state.name());
            return;
        }
        state = RecordState.PAUSE;
    }

    public void resume() {
        if (state != RecordState.PAUSE) {
            Logger.e(TAG, "狀態異常當前狀態: %s", state.name());
            return;
        }
        String tempFilePath = getTempFilePath();
        Logger.i(TAG, "tmpPCM File: %s", tempFilePath);
        tmpFile = new File(tempFilePath);
        audioRecordThread = new AudioRecordThread();
        audioRecordThread.start();
    }

    private class AudioRecordThread extends Thread {
        private AudioRecord audioRecord;
        private int bufferSize;

        AudioRecordThread() {
            bufferSize = AudioRecord.getMinBufferSize(currentConfig.getFrequency(),
                    currentConfig.getChannel(), currentConfig.getEncoding()) * RECORD_AUDIO_BUFFER_TIMES;
            audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, currentConfig.getFrequency(),
                    currentConfig.getChannel(), currentConfig.getEncoding(), bufferSize);
        }

        @Override
        public void run() {
            super.run();
            state = RecordState.RECORDING;
            notifyState();
            Logger.d(TAG, "開始錄製");
            FileOutputStream fos = null;
            try {
                fos = new FileOutputStream(tmpFile);
                audioRecord.startRecording();
                byte[] byteBuffer = new byte[bufferSize];

                while (state == RecordState.RECORDING) {
                        int end = audioRecord.read(byteBuffer, 0, byteBuffer.length);
                    fos.write(byteBuffer, 0, end);
                    fos.flush();
                }
                audioRecord.stop();
                //1. 將本次錄音的文件暫存下來,用於合併
                files.add(tmpFile);
                //2. 再此判斷終止循環的狀態是暫停還是停止,並做相應處理
                if (state == RecordState.STOP) {
                    makeFile();
                } else {
                    Logger.i(TAG, "暫停!");
                }
            } catch (Exception e) {
                Logger.e(e, TAG, e.getMessage());
            } finally {
                try {
                    if (fos != null) {
                        fos.close();
                    }
                } catch (IOException e) {
                    Logger.e(e, TAG, e.getMessage());
                }
            }
            if (state != RecordState.PAUSE) {
                state = RecordState.IDLE;
                notifyState();
                Logger.d(TAG, "錄音結束");
            }
        }
    }

    private void makeFile() {
        //合併文件
        boolean mergeSuccess = mergePcmFiles(recordFile, files);

        //TODO:轉換wav
        Logger.i(TAG, "錄音完成! path: %s ; 大小:%s", recordFile.getAbsoluteFile(), recordFile.length());
    }

    /**
     * 合併Pcm文件
     *
     * @param recordFile 輸出文件
     * @param files      多個文件源
     * @return 是否成功
     */
    private boolean mergePcmFiles(File recordFile, List<File> files) {
        if (recordFile == null || files == null || files.size() <= 0) {
            return false;
        }

        FileOutputStream fos = null;
        BufferedOutputStream outputStream = null;
        byte[] buffer = new byte[1024];
        try {
            fos = new FileOutputStream(recordFile);
            outputStream = new BufferedOutputStream(fos);

            for (int i = 0; i < files.size(); i++) {
                BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(files.get(i)));
                int readCount;
                while ((readCount = inputStream.read(buffer)) > 0) {
                    outputStream.write(buffer, 0, readCount);
                }
                inputStream.close();
            }
        } catch (Exception e) {
            Logger.e(e, TAG, e.getMessage());
            return false;
        } finally {
            try {
                if (fos != null) {
                    fos.close();
                }
                if (outputStream != null) {
                    outputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        //3. 合併後記得刪除緩存文件並清除list
        for (int i = 0; i < files.size(); i++) {
            files.get(i).delete();
        }
        files.clear();
        return true;
    }

}

其他

在此後如若需要添加錄音狀態回調,記得使用Handler做好線程切換。

4. PCM轉WAV格式音頻

前面幾部分已經介紹了PCM音頻文件的錄製,這一部分主要介紹下pcm轉wav。

wav 和 pcm

一般通過麥克風採集的錄音數據都是PCM格式的,即不包含頭部信息,播放器無法知道音頻採樣率、位寬等參數,導致無法播放,顯然是非常不方便的。pcm轉換成wav,我們只需要在pcm的文件起始位置加上至少44個字節的WAV頭信息即可。

RIFF

  • WAVE文件是以RIFF(Resource Interchange File Format, “資源交互文件格式”)格式來組織內部結構的。
    RIFF文件結構可以看作是樹狀結構,其基本構成是稱爲"塊"(Chunk)的單元.
  • WAVE文件是由若干個Chunk組成的。按照在文件中的出現位置包括:RIFF WAVE Chunk, Format Chunk, Fact Chunk(可選), Data Chunk。
Fact Chunk 在壓縮後或在非PCM編碼時存在

WAV頭文件

所有的WAV都有一個文件頭,這個文件頭記錄着音頻流的編碼參數。數據塊的記錄方式是little-endian字節順序。
在這裏插入圖片描述

偏移地址 命名 內容
00-03 ChunkId “RIFF”
04-07 ChunkSize 下個地址開始到文件尾的總字節數(此Chunk的數據大小)
08-11 fccType “WAVE”
12-15 SubChunkId1 "fmt ",最後一位空格。
16-19 SubChunkSize1 一般爲16,表示fmt Chunk的數據塊大小爲16字節,即20-35
20-21 FormatTag 1:表示是PCM 編碼
22-23 Channels 聲道數,單聲道爲1,雙聲道爲2
24-27 SamplesPerSec 採樣率
28-31 BytesPerSec 碼率 :採樣率 * 採樣位數 * 聲道個數,bytePerSecond = sampleRate * (bitsPerSample / 8) * channels
32-33 BlockAlign 每次採樣的大小:位寬*聲道數/8
34-35 BitsPerSample 位寬
36-39 SubChunkId2 “data”
40-43 SubChunkSize2 音頻數據的長度
44-… data 音頻數據

java 生成頭文件

WavHeader.class

public static class WavHeader {
    /**
     * RIFF數據塊
     */
    final String riffChunkId = "RIFF";
    int riffChunkSize;
    final String riffType = "WAVE";

    /**
     * FORMAT 數據塊
     */
    final String formatChunkId = "fmt ";
    final int formatChunkSize = 16;
    final short audioFormat = 1;
    short channels;
    int sampleRate;
    int byteRate;
    short blockAlign;
    short sampleBits;

    /**
     * FORMAT 數據塊
     */
    final String dataChunkId = "data";
    int dataChunkSize;

    WavHeader(int totalAudioLen, int sampleRate, short channels, short sampleBits) {
        this.riffChunkSize = totalAudioLen;
        this.channels = channels;
        this.sampleRate = sampleRate;
        this.byteRate = sampleRate * sampleBits / 8 * channels;
        this.blockAlign = (short) (channels * sampleBits / 8);
        this.sampleBits = sampleBits;
        this.dataChunkSize = totalAudioLen - 44;
    }

    public byte[] getHeader() {
        byte[] result;
        result = ByteUtils.merger(ByteUtils.toBytes(riffChunkId), ByteUtils.toBytes(riffChunkSize));
        result = ByteUtils.merger(result, ByteUtils.toBytes(riffType));
        result = ByteUtils.merger(result, ByteUtils.toBytes(formatChunkId));
        result = ByteUtils.merger(result, ByteUtils.toBytes(formatChunkSize));
        result = ByteUtils.merger(result, ByteUtils.toBytes(audioFormat));
        result = ByteUtils.merger(result, ByteUtils.toBytes(channels));
        result = ByteUtils.merger(result, ByteUtils.toBytes(sampleRate));
        result = ByteUtils.merger(result, ByteUtils.toBytes(byteRate));
        result = ByteUtils.merger(result, ByteUtils.toBytes(blockAlign));
        result = ByteUtils.merger(result, ByteUtils.toBytes(sampleBits));
        result = ByteUtils.merger(result, ByteUtils.toBytes(dataChunkId));
        result = ByteUtils.merger(result, ByteUtils.toBytes(dataChunkSize));
        return result;
    }
}

ByteUtils: https://github.com/zhaolewei/ZlwAudioRecorder/blob/master/recorderlib/src/main/java/com/zlw/main/recorderlib/utils/ByteUtils.java

PCM轉Wav

WavUtils.java

public class WavUtils {
    private static final String TAG = WavUtils.class.getSimpleName();
        /**
         * 生成wav格式的Header
         * wave是RIFF文件結構,每一部分爲一個chunk,其中有RIFF WAVE chunk,
         * FMT Chunk,Fact chunk(可選),Data chunk
         *
         * @param totalAudioLen 不包括header的音頻數據總長度
         * @param sampleRate    採樣率,也就是錄製時使用的頻率
         * @param channels      audioRecord的頻道數量
         * @param sampleBits    位寬
         */
        public static byte[] generateWavFileHeader(int totalAudioLen, int sampleRate, int channels, int sampleBits) {
            WavHeader wavHeader = new WavHeader(totalAudioLen, sampleRate, (short) channels, (short) sampleBits);
            return wavHeader.getHeader();
        }
    }

    /**
     * 將header寫入到pcm文件中 不修改文件名
     *
     * @param file   寫入的pcm文件
     * @param header wav頭數據
     */
    public static void writeHeader(File file, byte[] header) {
        if (!FileUtils.isFile(file)) {
            return;
        }

        RandomAccessFile wavRaf = null;
        try {
            wavRaf = new RandomAccessFile(file, "rw");
            wavRaf.seek(0);
            wavRaf.write(header);
            wavRaf.close();
        } catch (Exception e) {
            Logger.e(e, TAG, e.getMessage());
        } finally {
            try {
                if (wavRaf != null) {
                    wavRaf.close();
                }
            } catch (IOException e) {
                Logger.e(e, TAG, e.getMessage());
            }
        }

RecordHelper.java

private void makeFile() {
    mergePcmFiles(recordFile, files);

    //這裏實現上一篇未完成的工作
    byte[] header = WavUtils.generateWavFileHeader((int) resultFile.length(), currentConfig.getSampleRate(), currentConfig.getChannelCount(), currentConfig.getEncoding());
    WavUtils.writeHeader(resultFile, header);
    
    Logger.i(TAG, "錄音完成! path: %s ; 大小:%s", recordFile.getAbsoluteFile(), recordFile.length());
    }

參考鏈接:

http://soundfile.sapp.org/doc/WaveFormat/

5. Mp3的錄製 - 編譯Lame源碼

編譯 so包

1.下載lame

官網(科學上網): http://lame.sourceforge.net/download.php
lame-3.100https://pan.baidu.com/s/1U77GAq1nn3bVXFMEhRyo8g

2.使用ndk-build編譯源碼

2.1 在任意位置創建如下的目錄結構:
在這裏插入圖片描述
文件夾名稱隨意,與.mk 文件中路徑一致即可

2.2 解壓下載好的lame源碼
解壓後將其/lame-3.100/libmp3lame/目錄中.c和.h文件和/lame-3.100//include/中的 lame.h拷貝到/jni/lame-3.100_libmp3lame

3.100版本 有42個文件

2.3 修改部分文件

  1. 刪除fft.c文件的47行的#include "vector/lame_intrin.h"
  2. 刪除set_get.h文件的24行的#include <lame.h>
  3. 將util.h文件的570行的extern ieee754_float32_t fast_log2(ieee754_float32_t x); 替換爲 extern float fast_log2(float x);

2.4 編寫Mp3Encoder.c和Mp3Encoder.h對接java代碼

2.4.1 Mp3Encoder.c

注意修改包名
#include "lame-3.100_libmp3lame/lame.h"
#include "Mp3Encoder.h"

static lame_global_flags *glf = NULL;
//TODO這裏包名要與java中對接文件的路徑一致(這裏是路徑是com.zlw.main.recorderlib.recorder.mp3,java文件: Mp3Encoder.java),下同
JNIEXPORT void JNICALL Java_com_zlw_main_recorderlib_recorder_mp3_Mp3Encoder_init(
        JNIEnv *env, jclass cls, jint inSamplerate, jint outChannel,
        jint outSamplerate, jint outBitrate, jint quality) {
    if (glf != NULL) {
        lame_close(glf);
        glf = NULL;
    }
    glf = lame_init();
    lame_set_in_samplerate(glf, inSamplerate);
    lame_set_num_channels(glf, outChannel);
    lame_set_out_samplerate(glf, outSamplerate);
    lame_set_brate(glf, outBitrate);
    lame_set_quality(glf, quality);
    lame_init_params(glf);
}

JNIEXPORT jint JNICALL Java_com_zlw_main_recorderlib_recorder_mp3_Mp3Encoder_encode(
        JNIEnv *env, jclass cls, jshortArray buffer_l, jshortArray buffer_r,
        jint samples, jbyteArray mp3buf) {
    jshort* j_buffer_l = (*env)->GetShortArrayElements(env, buffer_l, NULL);

    jshort* j_buffer_r = (*env)->GetShortArrayElements(env, buffer_r, NULL);

    const jsize mp3buf_size = (*env)->GetArrayLength(env, mp3buf);
    jbyte* j_mp3buf = (*env)->GetByteArrayElements(env, mp3buf, NULL);

    int result = lame_encode_buffer(glf, j_buffer_l, j_buffer_r,
            samples, j_mp3buf, mp3buf_size);

    (*env)->ReleaseShortArrayElements(env, buffer_l, j_buffer_l, 0);
    (*env)->ReleaseShortArrayElements(env, buffer_r, j_buffer_r, 0);
    (*env)->ReleaseByteArrayElements(env, mp3buf, j_mp3buf, 0);

    return result;
}

JNIEXPORT jint JNICALL Java_com_zlw_main_recorderlib_recorder_mp3_Mp3Encoder_flush(
        JNIEnv *env, jclass cls, jbyteArray mp3buf) {
    const jsize mp3buf_size = (*env)->GetArrayLength(env, mp3buf);
    jbyte* j_mp3buf = (*env)->GetByteArrayElements(env, mp3buf, NULL);

    int result = lame_encode_flush(glf, j_mp3buf, mp3buf_size);

    (*env)->ReleaseByteArrayElements(env, mp3buf, j_mp3buf, 0);

    return result;
}

JNIEXPORT void JNICALL Java_com_zlw_main_recorderlib_recorder_mp3_Mp3Encoder_close(
        JNIEnv *env, jclass cls) {
    lame_close(glf);
    glf = NULL;
}

2.4.2 Mp3Encoder.h

注意修改包名
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>

#ifndef _Included_Mp3Encoder
#define _Included_Mp3Encoder
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com.zlw.main.recorderlib.recorder.mp3.Mp3Encoder
 * Method:    init
 */
JNIEXPORT void JNICALL Java_com_zlw_main_recorderlib_recorder_mp3_Mp3Encoder_init
  (JNIEnv *, jclass, jint, jint, jint, jint, jint);

JNIEXPORT jint JNICALL Java_com_zlw_main_recorderlib_recorder_mp3_Mp3Encoder_encode
  (JNIEnv *, jclass, jshortArray, jshortArray, jint, jbyteArray);

JNIEXPORT jint JNICALL Java_com_zlw_main_recorderlib_recorder_mp3_Mp3Encoder_flush
  (JNIEnv *, jclass, jbyteArray);

JNIEXPORT void JNICALL Java_com_zlw_main_recorderlib_recorder_mp3_Mp3Encoder_close
  (JNIEnv *, jclass);

#ifdef __cplusplus
}
#endif
#endif

2.5 編寫Android.mk 和Application.mk

路徑與創建的目錄應當一致

2.5.1 Android.mk

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LAME_LIBMP3_DIR := lame-3.100_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 \
MP3Encoder.c

include $(BUILD_SHARED_LIBRARY)

2.5.2 Application.mk

若只需要編譯armeabi的so包可將其他刪除
APP_ABI := armeabi  armeabi-v7a  arm64-v8a  x86  x86_64  mips  mips64
APP_MODULES := mp3lame
APP_CFLAGS += -DSTDC_HEADERS
APP_PLATFORM := android-21

編譯

到達這一步,所有的文件都已經準備好了
在命令行中切換到jni目錄中,執行ndk-build開始編譯
在這裏插入圖片描述

如果不能識別ndk-build命令 需要配置下環境變量

6. Mp3的錄製 - 使用Lame實時錄製MP3格式音頻

前言

上一篇介紹瞭如何去編譯so文件,這一篇主要介紹下如何實時將pcm數據轉換爲MP3數據。

實現過程:
AudioRecorder在開啓錄音後,通過read方法不斷獲取pcm的採樣數據,每次獲取到數據後交給lame去處理,處理完成後存入文件中。

這一篇相對之前代碼,增加了兩個類:Mp3Encoder.java 和 Mp3EncoderThread.java

  • Mp3Encoder: 通過Jni調用so文件的c代碼,將pcm轉換成mp3格式數據
  • Mp3EncodeThread: 將pcm轉換成mp3時需要開啓子線程進行統一管理,以及全部轉碼完成的回調

代碼實現

Mp3Encoder.java

public class Mp3Encoder {

    static {
        System.loadLibrary("mp3lame");
    }

    public native static void close();

    public native static int encode(short[] buffer_l, short[] buffer_r, int samples, byte[] mp3buf);

    public native static int flush(byte[] mp3buf);

    public native static void init(int inSampleRate, int outChannel, int outSampleRate, int outBitrate, int quality);

    public static void init(int inSampleRate, int outChannel, int outSampleRate, int outBitrate) {
        init(inSampleRate, outChannel, outSampleRate, outBitrate, 7);
    }
}

Mp3EncodeThread.java

每次有新的pcm數據後將數據打包成ChangeBuffer 類型,通過addChangeBuffer()存放到線程隊列當中,線程開啓後會不斷輪詢隊列內容,當有內容後開始轉碼,無內容時進入阻塞,直到數據全部處理完成後,關閉輪詢。

public class Mp3EncodeThread extends Thread {
    private static final String TAG = Mp3EncodeThread.class.getSimpleName();

    /**
     * mp3文件的碼率 32kbit/s = 4kb/s
     */
    private static final int OUT_BITRATE = 32;

    private List<ChangeBuffer> cacheBufferList = Collections.synchronizedList(new LinkedList<ChangeBuffer>());
    private File file;
    private FileOutputStream os;
    private byte[] mp3Buffer;
    private EncordFinishListener encordFinishListener;

    /**
     * 是否已停止錄音
     */
    private volatile boolean isOver = false;

    /**
     * 是否繼續輪詢數據隊列
     */
    private volatile boolean start = true;

    public Mp3EncodeThread(File file, int bufferSize) {
        this.file = file;
        mp3Buffer = new byte[(int) (7200 + (bufferSize * 2 * 1.25))];
        RecordConfig currentConfig = RecordService.getCurrentConfig();
        int sampleRate = currentConfig.getSampleRate();
        Mp3Encoder.init(sampleRate, currentConfig.getChannelCount(), sampleRate, OUT_BITRATE);
    }

    @Override
    public void run() {
        try {
            this.os = new FileOutputStream(file);
        } catch (FileNotFoundException e) {
            Logger.e(e, TAG, e.getMessage());
            return;
        }

        while (start) {
            ChangeBuffer next = next();
            Logger.v(TAG, "處理數據:%s", next == null ? "null" : next.getReadSize());
            lameData(next);
        }
    }

    public void addChangeBuffer(ChangeBuffer changeBuffer) {
        if (changeBuffer != null) {
            cacheBufferList.add(changeBuffer);
            synchronized (this) {
                notify();
            }
        }
    }

    public void stopSafe(EncordFinishListener encordFinishListener) {
        this.encordFinishListener = encordFinishListener;
        isOver = true;
        synchronized (this) {
            notify();
        }
    }

    private ChangeBuffer next() {
        for (; ; ) {
            if (cacheBufferList == null || cacheBufferList.size() == 0) {
                try {
                    if (isOver) {
                        finish();
                    }
                    synchronized (this) {
                        wait();
                    }
                } catch (Exception e) {
                    Logger.e(e, TAG, e.getMessage());
                }
            } else {
                return cacheBufferList.remove(0);
            }
        }
    }

    private void lameData(ChangeBuffer changeBuffer) {
        if (changeBuffer == null) {
            return;
        }
        short[] buffer = changeBuffer.getData();
        int readSize = changeBuffer.getReadSize();
        if (readSize > 0) {
            int encodedSize = Mp3Encoder.encode(buffer, buffer, readSize, mp3Buffer);
            if (encodedSize < 0) {
                Logger.e(TAG, "Lame encoded size: " + encodedSize);
            }
            try {
                os.write(mp3Buffer, 0, encodedSize);
            } catch (IOException e) {
                Logger.e(e, TAG, "Unable to write to file");
            }
        }
    }

    private void finish() {
        start = false;
        final int flushResult = Mp3Encoder.flush(mp3Buffer);
        if (flushResult > 0) {
            try {
                os.write(mp3Buffer, 0, flushResult);
                os.close();
            } catch (final IOException e) {
                Logger.e(TAG, e.getMessage());
            }
        }
        Logger.d(TAG, "轉換結束 :%s", file.length());
        if (encordFinishListener != null) {
            encordFinishListener.onFinish();
        }
    }

    public static class ChangeBuffer {
        private short[] rawData;
        private int readSize;

        public ChangeBuffer(short[] rawData, int readSize) {
            this.rawData = rawData.clone();
            this.readSize = readSize;
        }

        short[] getData() {
            return rawData;
        }

        int getReadSize() {
            return readSize;
        }
    }

    public interface EncordFinishListener {
        /**
         * 格式轉換完畢
         */
        void onFinish();
    }
}

使用

 private class AudioRecordThread extends Thread {
        private AudioRecord audioRecord;
        private int bufferSize;

        AudioRecordThread() {
            bufferSize = AudioRecord.getMinBufferSize(currentConfig.getSampleRate(),
                    currentConfig.getChannelConfig(), currentConfig.getEncodingConfig()) * RECORD_AUDIO_BUFFER_TIMES;
            audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, currentConfig.getSampleRate(),
                    currentConfig.getChannelConfig(), currentConfig.getEncodingConfig(), bufferSize);
            if (currentConfig.getFormat() == RecordConfig.RecordFormat.MP3 && mp3EncodeThread == null) {
                initMp3EncoderThread(bufferSize);
            }
        }

        @Override
        public void run() {
            super.run();
            startMp3Recorder();
        }

         private void initMp3EncoderThread(int bufferSize) {
            try {
                mp3EncodeThread = new Mp3EncodeThread(resultFile, bufferSize);
                mp3EncodeThread.start();
            } catch (Exception e) {
                Logger.e(e, TAG, e.getMessage());
            }
         }
         
        private void startMp3Recorder() {
            state = RecordState.RECORDING;
            notifyState();

            try {
                audioRecord.startRecording();
                short[] byteBuffer = new short[bufferSize];

                while (state == RecordState.RECORDING) {
                    int end = audioRecord.read(byteBuffer, 0, byteBuffer.length);
                    if (mp3EncodeThread != null) {
                        mp3EncodeThread.addChangeBuffer(new Mp3EncodeThread.ChangeBuffer(byteBuffer, end));
                    }
                    notifyData(ByteUtils.toBytes(byteBuffer));
                }
                audioRecord.stop();
            } catch (Exception e) {
                Logger.e(e, TAG, e.getMessage());
                notifyError("錄音失敗");
            }
            if (state != RecordState.PAUSE) {
                state = RecordState.IDLE;
                notifyState();
                if (mp3EncodeThread != null) {
                    mp3EncodeThread.stopSafe(new Mp3EncodeThread.EncordFinishListener() {
                        @Override
                        public void onFinish() {
                            notifyFinish();
                        }
                    });
                } else {
                    notifyFinish();
                }
            } else {
                Logger.d(TAG, "暫停");
            }
        }
    }
}

7. 音樂可視化-FFT頻譜圖

項目地址:https://github.com/zhaolewei/MusicVisualizer
視頻演示地址:https://www.bilibili.com/video/av30388154/

實現

實現流程:

  • 使用MediaPlayer播放傳入的音樂,並拿到mediaPlayerId
  • 使用Visualizer類拿到拿到MediaPlayer播放中的音頻數據(wave/fft)
  • 將數據用自定義控件展現出來

準備工作

使用Visualizer需要錄音的動態權限, 如果播放sd卡音頻需要STORAGE權限。

 private static final String[] PERMISSIONS = new String[]{
        Manifest.permission.RECORD_AUDIO,
        Manifest.permission.MODIFY_AUDIO_SETTINGS
  };
  
  ActivityCompat.requestPermissions(MainActivity.this, PERMISSIONS, 1);
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

開始播放

private MediaPlayer.OnPreparedListener preparedListener = new 
    /**
     * 播放音頻
     *
     * @param raw 資源文件id
     */
    private  void doPlay(final int raw) {
        try {
            mediaPlayer = MediaPlayer.create(MyApp.getInstance(), raw);
            if (mediaPlayer == null) {
                Logger.e(TAG, "mediaPlayer is null");
                return;
            }

            mediaPlayer.setOnErrorListener(errorListener);
            mediaPlayer.setOnPreparedListener(preparedListener);
        } catch (Exception e) {
            Logger.e(e, TAG, e.getMessage());
        }
    }
    
    /**
    * 獲取MediaPlayerId
    * 可視化類Visualizer需要此參數
    * @return  MediaPlayerId
    */
    public int getMediaPlayerId() {
        return mediaPlayer.getAudioSessionId();
    }

使用可視化類Visualizer獲取當前音頻數據

Visualizer 有兩個比較重要的參數

  • 設置可視化數據的數據大小 範圍[Visualizer.getCaptureSizeRange()[0]~Visualizer.getCaptureSizeRange()[1]]
  • 設置可視化數據的採集頻率 範圍[0~Visualizer.getMaxCaptureRate()]

OnDataCaptureListener 有2個回調,一個用於顯示FFT數據,展示不同頻率的振幅,另一個用於顯示聲音的波形圖。

private Visualizer.OnDataCaptureListener dataCaptureListener = new Visualizer.OnDataCaptureListener() {
        @Override
        public void onWaveFormDataCapture(Visualizer visualizer, final byte[] waveform, int samplingRate) {
            audioView.post(new Runnable() {
                @Override
                public void run() {
                    audioView.setWaveData(waveform);
                }
            });
        }

        @Override
        public void onFftDataCapture(Visualizer visualizer, final byte[] fft, int samplingRate) {
            audioView2.post(new Runnable() {
                @Override
                public void run() {
                    audioView2.setWaveData(fft);
                }
            });
        }
    };
    
private void initVisualizer() {
    try {
        int mediaPlayerId = mediaPlayer.getMediaPlayerId();
        if (visualizer != null) {
            visualizer.release();
        }
        visualizer = new Visualizer(mediaPlayerId);
        
        /**
         *可視化數據的大小: getCaptureSizeRange()[0]爲最小值,getCaptureSizeRange()[1]爲最大值
         */
        int captureSize = Visualizer.getCaptureSizeRange()[1];
        int captureRate = Visualizer.getMaxCaptureRate() * 3 / 4;
        
        visualizer.setCaptureSize(captureSize);
        visualizer.setDataCaptureListener(dataCaptureListener, captureRate, true, true);
        visualizer.setScalingMode(Visualizer.SCALING_MODE_NORMALIZED);
        visualizer.setEnabled(true);
    } catch (Exception e) {
        Logger.e(TAG, "請檢查錄音權限");
    }
}    

波形數據和傅里葉數據的關係如圖:
在這裏插入圖片描述
快速傅里葉轉換(FFT)詳細分析: https://zhuanlan.zhihu.com/p/19763358

編寫自定義控件,展示數據

1.處理數據: visualizer 回調中的數據中是存在負數的,需要轉換一下,用於顯示

byte-128時Math.abs(fft[i]) 計算出來的值會越界,需要手動處理一下
byte 的範圍: -128~127
  /**
     * 預處理數據
     *
     * @return
     */
    private static byte[] readyData(byte[] fft) {
        byte[] newData = new byte[LUMP_COUNT];
        byte abs;
        for (int i = 0; i < LUMP_COUNT; i++) {
            abs = (byte) Math.abs(fft[i]);
            //描述:Math.abs -128時越界
            newData[i] = abs < 0 ? 127 : abs;
        }
        return newData;
    }

2. 緊接着就是根據數據去繪製圖形

  @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        wavePath.reset();

        for (int i = 0; i < LUMP_COUNT; i++) {
            if (waveData == null) {
                canvas.drawRect((LUMP_WIDTH + LUMP_SPACE) * i,
                        LUMP_MAX_HEIGHT - LUMP_MIN_HEIGHT,
                        (LUMP_WIDTH + LUMP_SPACE) * i + LUMP_WIDTH,
                        LUMP_MAX_HEIGHT,
                        lumpPaint);
                continue;
            }

            switch (upShowStyle) {
                case STYLE_HOLLOW_LUMP:
                    drawLump(canvas, i, false);
                    break;
                case STYLE_WAVE:
                    drawWave(canvas, i, false);
                    break;
                default:
                    break;
            }

            switch (downShowStyle) {
                case STYLE_HOLLOW_LUMP:
                    drawLump(canvas, i, true);
                    break;
                case STYLE_WAVE:
                    drawWave(canvas, i, true);
                    break;
                default:
                    break;
            }
        }
    }
    
    /**
     * 繪製矩形條
     */
    private void drawLump(Canvas canvas, int i, boolean reversal) {
        int minus = reversal ? -1 : 1;

        if (waveData[i] < 0) {
            Logger.w("waveData", "waveData[i] < 0 data: %s", waveData[i]);
        }
        float top = (LUMP_MAX_HEIGHT - (LUMP_MIN_HEIGHT + waveData[i] * SCALE) * minus);

        canvas.drawRect(LUMP_SIZE * i,
                top,
                LUMP_SIZE * i + LUMP_WIDTH,
                LUMP_MAX_HEIGHT,
                lumpPaint);
    }

    /**
     * 繪製曲線
     * 這裏使用貝塞爾曲線來繪製
     */
    private void drawWave(Canvas canvas, int i, boolean reversal) {
        if (pointList == null || pointList.size() < 2) {
            return;
        }
        float ratio = SCALE * (reversal ? -1 : 1);
        if (i < pointList.size() - 2) {
            Point point = pointList.get(i);
            Point nextPoint = pointList.get(i + 1);
            int midX = (point.x + nextPoint.x) >> 1;
            if (i == 0) {
                wavePath.moveTo(point.x, LUMP_MAX_HEIGHT - point.y * ratio);
            }
            wavePath.cubicTo(midX, LUMP_MAX_HEIGHT - point.y * ratio,
                    midX, LUMP_MAX_HEIGHT - nextPoint.y * ratio,
                    nextPoint.x, LUMP_MAX_HEIGHT - nextPoint.y * ratio);

            canvas.drawPath(wavePath, lumpPaint);
        }
    }
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章