好久沒更新博客瞭,著實有點慚愧,以後不管工作是忙是閑都得堅持更新博客,持之以恒地做下去!
正式進入主題,今天我分享一個在工作中過程中遇到的一個技術難點以及我解決該難點的方案,該問題困擾瞭我許久,通過不斷地研究和翻閱資料,終於在滿足工作需求的情況下將該問題解決,希望我的經驗能夠對讀者有所幫助。我們知道Android ApI提供瞭MediaRecorder和AudioRecord兩個類給開發者來很方便地實現音視頻的錄制(前者可以實現音頻和視頻的錄制,後者隻能實現音頻的錄制)。這兩個類都提供瞭start()和stop()方法用於開始和結束音頻或視頻的錄制,但令人費解的是這兩個類都沒有提供pause()方法用於暫停錄制音視頻,因為在實際應用當中,暫停錄制的功能是非常有必要的,暫不清楚Google工程師們在設計API時是如何考量的而沒有添加這個方法,可能另有玄機吧。那既然Android自身沒有提供這樣一個方法,就隻有我們自己來實現瞭,那麼問題就來瞭,就是到底如何實現音頻錄制的暫停方法呢?別急,先講一下我在工作中所遇到的需求,如下:需實現音頻錄制的暫停功能,並且生成的音頻文件格式必須是m4a格式。為什麼項目中音頻文件一定要采用m4a格式的呢?有以下幾點原因:
1. 錄制相同時間的音頻,使用m4a格式存儲的文件的大小要比使用其它格式類型存儲的文件的大小要小(通過實驗多次,在相同采樣率16000的情況下,一般錄制5分鐘的音頻,采用m4a格式存儲的音頻文件隻有1.2Mb,而采用arm、mp3及其它格式的一般都有2-5Mb),這樣當用戶需要下載或上傳錄制的音頻文件時,可以節省流量,並且相同壓縮率的前提下,m4a格式音頻的音質相比其它格式的也更高;
2.產品同時擁有Android客戶端和IOS客戶端,那為瞭避免使用Android客戶端的用戶錄制的音頻上傳到服務器之後,使用IOS客戶端的用戶下載下來發生無法播放的問題,我們需統一錄制音頻的存儲格式。由於Iphone手機官方推薦的音頻格式是m4a且對m4a格式的音頻文件支持度較高,再綜合第一點來看,於是我們選擇m4a格式作為音頻文件的存儲格式。
好瞭,解釋瞭為什麼音頻錄制文件必須使用m4a存儲格式之後,接下來我們來解決如何實現音頻的錄制的暫停功能。前面講瞭,Android SDK API提供瞭MediaRecorder和AudioRecord兩個類來完成音視頻的錄制方法,我們看下它們兩者之間的特點和區別:
MediaRecorder:
特性:該類集成瞭錄音、編碼和壓縮等功能,可根據設置的編碼格式的參數直接生成各種格式的音頻文件(如arm、 mp3或m4a等),由於集成度較高,因此使用起來簡單,但靈活度不高,不能實現像AudioRecord那樣進行音 頻的實時處理。
AudioRecord:
特性:該類錄制的音頻為原始的PCM二進制音頻數據,沒有文件頭和文件尾,生成的PCM文件不能直接使用 Mediaplayer播放,隻能使用AudioTrack播放。使用AudioRecord可以實現邊錄邊播的音頻實時處理。
瞭解瞭這兩個類的特性之後,起初我決定使用MediaRecorder類來解決錄制暫停的問題,具體的思路如下:
(1)每次觸發開始錄制和暫停錄制音頻的事件時都單獨保存一個m4a格式的音頻文件,直到最後觸發停止錄制音頻的事件時,將之前錄制的若幹m4a格式的音頻文件合並成一個文件。如圖下:
這種方法比較好理解,也容易想到,不過在實現過程中遇到瞭一個技術難點,那就是多個m4a格式的音頻文件的合並並不是簡單地將文件的內容拷貝到一個文件中,而是要通過分析每一個m4a格式的音頻文件,計算出每個文件頭的結構大小,並將文件頭去掉,再將文件進行拷貝合並。通過查閱資料,發現m4a格式的音頻文件頭是由多個包含關系的ATOM結構組成,且每個不同的m4a格式的音頻文件的文件頭的大小都不一樣,這樣使得多個m4a文件頭文件解析和合並變得較為復雜,若有多個m4a文件需要合並,那麼會變得較為耗時。再者,對於沒有足夠音視頻文件解析和編解碼經驗的開發者來講,要精準地得解析一個m4a文件,挑戰性太大(網上這方面的資料也寥寥無幾),有興趣的讀者可以進行深入研究。
上述方法行不通,於是隻好作罷,後來又想到瞭另外一種方法,也是我解決問題的最終方案,具體的思路如下:
(2)由於使用AudioRecord類提供的方法錄制的音頻是原始的PCM格式的二進制數據,該格式的文件沒有文件頭信息,那麼我們在進行文件合並時就就無需解析文件結構去掉對應的文件頭,這樣就變成瞭二進制數據地簡單拷貝和合並。我在這裡實現的方式是在錄制音頻的過程中采用邊錄制邊寫入的方式不斷地向同一個文件寫入錄制的二進制音頻數據。當觸發暫停錄音事件時,停止錄制停止寫入二進制數據,當觸發繼續錄音事件時,則繼續錄制和向文件中寫入數據。最後停止寫入數據時,將PCM二進制音頻文件編碼成m4a格式的音頻文件。如圖下:
上面方法描述中,實現邊錄制邊寫入的功能倒比較簡單,關鍵難點是如何將PCM二進制數據編碼成目標的m4a格式的音頻數據,要實現音視頻的編解碼,一般都是使用第三方開源的編解碼庫,比較著名的有FFMpeg和Speex,這些庫都提供瞭錄制、轉換以及流化音視頻的完整解決方案,不過在此我的需求隻是需要簡單地實現編碼工作,使用這些開源庫體積太大,有點殺雞用牛刀的感覺。因此,通過研究和查閱資料,我在github上找到瞭一個非常有用的編解碼開源項目android-aac-enc(地址:https://github.com/timsu/android-aac-enc),該開源項目能完美地實現將原始的pcm格式的二進制數據編碼成m4a格式的數據文件,相比於FFmpeg庫,這個庫有以下幾點優點:
1. aac-enc庫的體積比FFmpeg庫的體積更小;
2. 相比FFMpeg, aac-enc實現格式轉換更加簡單和快速;
3. aac-enc比FFmpeg需要編譯更少的底層的代碼。
該開源項目使用起來也非常地簡單,通過分析其示例代碼我們可以通過以下四個步驟來實現音頻的編碼工作,代碼如下:
/** * 1.初始化編碼配置 * * 32000 : 音頻的比特率 * 2 : 音頻的聲道 * sampleRateInHz : 音頻采樣率 * 16 :音頻數據格式,PCM 16位每個樣本 * FileUtils.getAAcFilePath(mAudioRecordFileName) : aac音頻文件的存儲路徑 */ encoder.init(32000, 2, sampleRateInHz, 16, FileUtils. getAAcFilePath(mAudioRecordFileName)); /** * 2.對二進制代碼進行編碼 * * b :需要編碼的二進制音頻流 */ encoder.encode(b); /** * 3. 從pcm二進制數據轉aac音頻文件編碼完成 * */ encoder.uninit(); /** * 4. 將aac文件轉碼成m4a文件 * * FileUtils.getAAcFilePath(mAudioRecordFileName) :需要編碼的aac文件路徑 * FileUtils.getM4aFilePath(mAudioRecordFileName) :編碼成m4a文件的目標路徑 */ new AACToM4A().convert(mContext, FileUtils.getAAcFilePath(mAudioRecordFileName), FileUtils.getM4aFilePath(mAudioRecordFileName));
使用起來是不是很簡單方便,我們無需對音頻文件格式和文件頭進行判斷和解析,隻需要通過該開源項目封裝的api方法直接調用就可以很快速的將原始的二進制PCM音頻數據轉換成m4a格式的音頻數據文件。感興趣的讀者可以去研究一下該項目的源碼,瞭解一下其內部的實現,這裡暫且不深入探究。
基本上明確好思路和編碼的實現方法後,接下來就是具體的實現過程瞭,我們將依據上面的思路和方法來實現一個具有暫停功能的音頻錄制Demo。首先看下Demo的項目結構,如下圖:
如何使用AudioRecord類來實現音頻的錄制,這方面的資料很多,讀者可以先學習,簡單地入一下門。接下來我們先運行一下Demo,來看一下效果圖:
(1)初始界面 (2)正在錄制界面 (2)暫停界面
(4)播放界面 (5)暫停播放界面
粗略看瞭Demo的運行效果圖後,接下來我們就要來實現,這裡由於要使用aac-encode項目來實現音頻的編碼,則需將該項目以library的形式集成到我們的Demo中,做完該項工作後,我們就可以在Demo工程中寫其它相關的邏輯代碼瞭,下面看一下實現demo的關鍵代碼,首先是RecordAct.java文件中的代碼,該類為主界面類,主要實現瞭界面的初始化、音頻的錄制和音頻播放的功能,具體的代碼如下:
public class RecordAct extends Activity implements OnClickListener{ /** * Status:錄音初始狀態 */ private static final int STATUS_PREPARE = 0; /** * Status:正在錄音中 */ private static final int STATUS_RECORDING = 1; /** * Status:暫停錄音 */ private static final int STATUS_PAUSE = 2; /** * Status:播放初始狀態 */ private static final int STATUS_PLAY_PREPARE = 3; /** * Status:播放中 */ private static final int STATUS_PLAY_PLAYING = 4; /** * Status:播放暫停 */ private static final int STATUS_PLAY_PAUSE = 5; private int status = STATUS_PREPARE; /** * 錄音時間 */ private TextView tvRecordTime; /** * 錄音按鈕 */ private ImageView btnRecord;// 錄音按鈕 private PopupWindow popAddWindow; /** * 試聽界面 */ private LinearLayout layoutListen; /** * 錄音長度 */ private TextView tvLength; private TextView recordContinue; /** * 重置按鈕 */ private View resetRecord; /** * 結束錄音 */ private View recordOver; private ImageView audioRecordNextImage; private TextView audioRecordNextText; /** * 音頻播放進度 */ private TextView tvPosition; long startTime = 0; /** * 最大錄音長度 */ private static final int MAX_LENGTH = 300 * 1000; private Handler handler = new Handler(); private Runnable runnable; /** * 音頻錄音的總長度 */ private static int voiceLength; /** * 音頻錄音幫助類 */ private AudioRecordUtils mRecordUtils; /** * 播放進度條 */ private SeekBar seekBar; /** * 音頻播放類 */ private Player player; /** * 錄音文件名 */ private String audioRecordFileName; @Override protected void onCreate(Bundle savedInstanceState) { // TODO Auto-generated method stub super.onCreate(savedInstanceState); setContentView(R.layout.pop_add_record); initView(); } public void initView(){ //音頻錄音的文件名稱 audioRecordFileName = TimeUtils.getTimestamp(); //初始化音頻錄音對象 mRecordUtils = new AudioRecordUtils(this,audioRecordFileName); View view = LayoutInflater.from(this).inflate(R.layout.pop_add_record, null); tvRecordTime = (TextView)findViewById(R.id.tv_time); btnRecord = (ImageView)findViewById(R.id.iv_btn_record); btnRecord.setOnClickListener(this); recordContinue = (TextView)findViewById(R.id.record_continue_txt); resetRecord = findViewById(R.id.btn_record_reset); recordOver = findViewById(R.id.btn_record_complete); resetRecord.setOnClickListener(this); recordOver.setOnClickListener(this); audioRecordNextImage = (ImageView)findViewById(R.id.recrod_complete_img); audioRecordNextText = (TextView)findViewById(R.id.record_complete_txt); layoutListen = (LinearLayout)findViewById(R.id.layout_listen); tvLength = (TextView)findViewById(R.id.tv_length); tvPosition = (TextView)findViewById(R.id.tv_position); seekBar = (SeekBar)findViewById(R.id.seekbar_play); seekBar.setOnSeekBarChangeListener(new SeekBarChangeEvent()); seekBar.setEnabled(false); player = new Player(seekBar, tvPosition); player.setMyPlayerCallback(new MyPlayerCallback() { @Override public void onPrepared() { seekBar.setEnabled(true); } @Override public void onCompletion() { status = STATUS_PLAY_PREPARE; seekBar.setEnabled(false); seekBar.setProgress(0); tvPosition.setText(00:00); recordContinue.setBackgroundResource(R.drawable.record_audio_play); } }); popAddWindow = new PopupWindow(view, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); popAddWindow.setFocusable(true); popAddWindow.setAnimationStyle(R.style.pop_anim); popAddWindow.setBackgroundDrawable(new BitmapDrawable()); } public void handleRecord(){ switch(status){ case STATUS_PREPARE: mRecordUtils.startRecord(); btnRecord.setBackgroundResource(R.drawable.record_round_red_bg); status = STATUS_RECORDING; voiceLength = 0; timing(); break; case STATUS_RECORDING: pauseAudioRecord(); resetRecord.setVisibility(View.VISIBLE); recordOver.setVisibility(View.VISIBLE); btnRecord.setBackgroundResource(R.drawable.record_round_blue_bg); recordContinue.setVisibility(View.VISIBLE); status = STATUS_PAUSE; break; case STATUS_PAUSE: mRecordUtils.startRecord(); resetRecord.setVisibility(View.INVISIBLE); recordOver.setVisibility(View.INVISIBLE); btnRecord.setBackgroundResource(R.drawable.record_round_red_bg); recordContinue.setVisibility(View.INVISIBLE); status = STATUS_RECORDING; timing(); break; case STATUS_PLAY_PREPARE: player.playUrl(FileUtils.getM4aFilePath(audioRecordFileName)); recordContinue.setBackgroundResource(R.drawable.record_audio_play_pause); status = STATUS_PLAY_PLAYING; break; case STATUS_PLAY_PLAYING: player.pause(); recordContinue.setBackgroundResource(R.drawable.record_audio_play); status = STATUS_PLAY_PAUSE; break; case STATUS_PLAY_PAUSE: player.play(); recordContinue.setBackgroundResource(R.drawable.record_audio_play_pause); status = STATUS_PLAY_PLAYING; break; } } /** * 暫停錄音 */ public void pauseAudioRecord(){ mRecordUtils.pauseRecord(); if (handler != null && runnable != null) { handler.removeCallbacks(runnable); runnable = null; } } /** * 停止錄音 */ public void stopAudioRecord(){ pauseAudioRecord(); mRecordUtils.stopRecord(); status = STATUS_PLAY_PREPARE; showListen(); } /** * 重新錄音參數初始化 */ @SuppressLint(NewApi) public void resetAudioRecord(){ //停止播放音頻 player.stop(); pauseAudioRecord(); mRecordUtils.reRecord(); status = STATUS_PREPARE; voiceLength = 0; tvRecordTime.setTextColor(Color.WHITE); tvRecordTime.setText(TimeUtils.convertMilliSecondToMinute2(voiceLength)); recordContinue.setText(R.string.record_continue); recordContinue.setBackground(null); recordContinue.setVisibility(View.GONE); layoutListen.setVisibility(View.GONE); tvRecordTime.setVisibility(View.VISIBLE); audioRecordNextImage.setImageResource(R.drawable.btn_record_icon_complete); audioRecordNextText.setText(R.string.record_over); btnRecord.setBackgroundResource(R.drawable.record_round_blue_bg); resetRecord.setVisibility(View.INVISIBLE); recordOver.setVisibility(View.INVISIBLE); } /** * 計時功能 */ private void timing() { runnable = new Runnable() { @Override public void run() { voiceLength += 100; if (voiceLength >= (MAX_LENGTH - 10 * 1000)) { tvRecordTime.setTextColor(getResources().getColor( R.color.red_n)); } else { tvRecordTime.setTextColor(Color.WHITE); } if (voiceLength > MAX_LENGTH) { stopAudioRecord(); } else { tvRecordTime.setText(TimeUtils.convertMilliSecondToMinute2(voiceLength)); handler.postDelayed(this, 100); } } }; handler.postDelayed(runnable, 100); } @Override public void onClick(View v) { // TODO Auto-generated method stub switch (v.getId()) { case R.id.iv_btn_record: handleRecord(); break; case R.id.btn_record_reset: resetAudioRecord(); break; case R.id.btn_record_complete: stopAudioRecord(); break; default: break; } } /** * 顯示播放界面 */ private void showListen() { layoutListen.setVisibility(View.VISIBLE); tvLength.setText(TimeUtils.convertMilliSecondToMinute2(voiceLength)); tvRecordTime.setVisibility(View.GONE); resetRecord.setVisibility(View.VISIBLE); recordOver.setVisibility(View.INVISIBLE); recordContinue.setVisibility(View.VISIBLE); seekBar.setProgress(0); tvPosition.setText(00:00); btnRecord.setBackgroundResource(R.drawable.record_round_blue_bg); recordContinue.setText(null); recordContinue.setBackgroundResource(R.drawable.record_audio_play); } /** * * SeekBar進度條改變事件監聽類 */ class SeekBarChangeEvent implements SeekBar.OnSeekBarChangeListener { int progress; @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { if (null != player && player.mediaPlayer != null) { this.progress = progress * player.mediaPlayer.getDuration() / seekBar.getMax(); tvPosition.setText(TimeUtils .convertMilliSecondToMinute2(player.currentPosition)); } } @Override public void onStartTrackingTouch(SeekBar seekBar) { } @Override public void onStopTrackingTouch(SeekBar seekBar) { if (player.mediaPlayer != null) { player.mediaPlayer.seekTo(progress); } } } @Override protected void onDestroy() { // TODO Auto-generated method stub super.onDestroy(); player.stop(); } }
上面代碼註釋比較清楚,且好理解,因此不多分析,讀者自行學習。下面再來看一下AudioRecordUtils類的代碼,該類是音頻錄制功能的主要實現代碼,裡面簡單地封裝瞭開始錄音、暫停錄音、停止錄音和重新錄音幾個方法,在開發中隻要調用就行,來看看具體的實現代碼,如下:
public class AudioRecordUtils { private final int audioSource = MediaRecorder.AudioSource.MIC; // 設置音頻采樣率,44100是目前的標準,但是某些設備仍然支持22050,16000,11025 private final int sampleRateInHz = 16000; // 設置音頻的錄制的聲道CHANNEL_IN_STEREO為雙聲道,CHANNEL_CONFIGURATION_MONO為單聲道 private final int channelConfig = AudioFormat.CHANNEL_IN_STEREO; // 音頻數據格式:PCM 16位每個樣本。保證設備支持。PCM 8位每個樣本。不一定能得到設備支持。 private final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; private int inBufSize = 0; private AudioRecord audioRecord; private AACEncoder encoder = null; private ProgressDialog mProgressDialog = null; private boolean isRecord = false; private Context mContext; /** * 錄制的音頻文件名稱 */ private String mAudioRecordFileName; private static final int RECORDED_INIT_DELETE = 0; private static final int RECORDED_COMPLETED_DELETE = 1; public AudioRecordUtils(Context context,String audioRecordFileName){ mContext = context; mAudioRecordFileName = audioRecordFileName; initAudioRecord(); } /** * 初始化對象 */ private void initAudioRecord(){ inBufSize = AudioRecord.getMinBufferSize( sampleRateInHz, channelConfig, audioFormat); audioRecord = new AudioRecord( audioSource, sampleRateInHz, channelConfig, audioFormat, inBufSize); encoder = new AACEncoder(); deleteAllFiles(RECORDED_INIT_DELETE); mProgressDialog = new ProgressDialog(mContext); mProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); mProgressDialog.setCanceledOnTouchOutside(false); mProgressDialog.setCancelable(false); mProgressDialog.setTitle(提示); mProgressDialog.setMessage(正在保存錄音,請耐心等候......); } /** * 開始錄音 */ public void startRecord(){ new AudioRecordTask().execute(); } /** * 暫停錄音 */ public void pauseRecord(){ isRecord = false; } /** * 停止錄音 */ public void stopRecord(){ new AudioEncoderTask().execute(); } /** * 重新錄制 */ public void reRecord(){ //重新錄制時,刪除錄音文件夾中的全部文件 deleteAllFiles(RECORDED_INIT_DELETE); } private void encodeAudio(){ try { //讀取錄制的pcm音頻文件 DataInputStream mDataInputStream = new DataInputStream(new FileInputStream( FileUtils.getPcmFilePath(mAudioRecordFileName))); byte[] b = new byte[(int) new File(FileUtils. getPcmFilePath(mAudioRecordFileName)).length()]; mDataInputStream.read(b); //初始化編碼配置 encoder.init(32000, 2, sampleRateInHz, 16, FileUtils. getAAcFilePath(mAudioRecordFileName)); //對二進制代碼進行編碼 encoder.encode(b); //編碼完成 encoder.uninit(); //關閉流 mDataInputStream.close(); try { //將aac文件轉碼成m4a文件 new AACToM4A().convert(mContext, FileUtils.getAAcFilePath(mAudioRecordFileName), FileUtils.getM4aFilePath(mAudioRecordFileName)); } catch (IOException e) { Log.e(ERROR, error converting, e); } deleteAllFiles(RECORDED_COMPLETED_DELETE); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (IOException e1) { // TODO Auto-generated catch block e1.printStackTrace(); } } class AudioRecordTask extends AsyncTask{ @Override protected Void doInBackground(Void... params) { // TODO Auto-generated method stub if(audioRecord == null){ initAudioRecord(); } RandomAccessFile mRandomAccessFile = null; try { mRandomAccessFile = new RandomAccessFile(new File( FileUtils.getPcmFilePath(mAudioRecordFileName)), rw); byte[] b = new byte[inBufSize/4]; //開始錄制音頻 audioRecord.startRecording(); //判斷是否正在錄制 isRecord = true; while(isRecord){ audioRecord.read(b, 0, b.length); //向文件中追加內容 mRandomAccessFile.seek(mRandomAccessFile.length()); mRandomAccessFile.write(b, 0, b.length); } //停止錄制 audioRecord.stop(); mRandomAccessFile.close(); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } return null; } } class AudioEncoderTask extends AsyncTask{ @Override protected void onPreExecute() { // TODO Auto-generated method stub super.onPreExecute(); if(mProgressDialog != null && !mProgressDialog.isShowing()){ mProgressDialog.show(); } } @Override protected Long doInBackground(Void... params) { // TODO Auto-generated method stub encodeAudio(); return null; } @Override protected void onPostExecute(Long result) { // TODO Auto-generated method stub super.onPostExecute(result); if(mProgressDialog.isShowing()){ mProgressDialog.cancel(); mProgressDialog.dismiss(); } } } /** * 清空音頻錄制文件夾中的所有文件 * @param isRecorded */ public void deleteAllFiles(int isRecorded){ File[] files = new File(FileUtils.getAudioRecordFilePath()).listFiles(); switch (isRecorded) { case RECORDED_INIT_DELETE: for(File file: files){ file.delete(); } break; case RECORDED_COMPLETED_DELETE: for(File file: files){ if(!file.getName().equals(mAudioRecordFileName + Constants.M4A_SUFFIX)){ file.delete(); } } break; default: break; } } }
上面代碼關鍵處都有註釋,讀者可自行學習。自此,我們基本熟悉瞭實現能夠暫停錄音功能的關鍵代碼,代碼沒有全部貼出,想要完整的Demo可在文章末尾下載來仔細研究。最後我再補充一點,就是若讀者對錄制的音頻格式沒有嚴格的要求話,如錄制的音頻格式是arm格式,則沒有必要考慮到音頻的編解碼問題,因為arm格式的音頻文件的文件頭信息固定是6個字節的大小,那這種情況讀者可以采用文章開頭所說的第一種方法,就是每次點擊暫停事件都錄制成一個arm文件,在最後合並的時候,隻需要去掉第2至n個文件的前6個字節,然後進行文件的拷貝合並就行,