Android 實現能夠暫停的錄音功能


好久沒更新博客了,着實有點慚愧,以后不管工作是忙是閑都得堅持更新博客,持之以恆地做下去!

正式進入主題,今天我分享一個在工作中過程中遇到的一個技術難點以及我解決該難點的方案,該問題困擾了我許久,通過不斷地研究和翻閱資料,終於在滿足工作需求的情況下將該問題解決,希望我的經驗能夠對讀者有所幫助。我們知道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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
      * 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文件中的代碼,該類為主界面類,主要實現了界面的初始化、音頻的錄制和音頻播放的功能,具體的代碼如下:

 

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
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