前言
最近在項目中, 做有關AVAudioRecorder的錄音開發, 需要把錄制的格式轉成 MP3, 遇到了轉碼之后的MP3文件, 無法獲取正確的時長問題.
為了解決這個問題, 真的是反復來修改錄音配置, 浪費了不知道多少的時間來分析這個問題.
中間我去某某群去找大神提問問題,結果遭到了鄙視, 都統統質疑我的錄音配置, 最后甩給我一個demo, 結果我一測試, 也是一樣的問題, 我就呵呵了.
所以, 我今天來寫一篇文章來認真剖析這個問題, 為什么起名 ?iOS 使用 Lame 轉碼 MP3 的最正確姿勢 !是因為我在百度搜索到的各種有關于Lame轉碼的代碼, 至少很大一部分 都是不完全正確的.
概述
我將會在本篇文章分析以下幾點內容
AVAudioRecorder 配置 和 Lame 編碼壓縮配置
解決錄音時長讀取不正確的問題
邊錄制邊轉碼的實現
測試 Demo
AVAudioRecorder 配置 和 Lame 編碼壓縮配置
AVAudioRecorder 配置的注意事項
關于 AVAudioRecorder 錄音的相關配置 和 Lame 包的編譯工作, 這里忽略不講, 主要是想說一下需要注意的地方
Lame 的轉碼壓縮, 是把錄制的 PCM 轉碼成 MP3, 所以錄制的AVFormatIDKey設置成kAudioFormatLinearPCM, 生成的文件可以是 caf 或者 wav.
caf文件是 Mac OS X 原本支持的眾多音頻格式中最新增加的一種. iPhone 短信就是這種格式, 錄制出的文件會比較大.
AVNumberOfChannelsKey必須設置為雙聲道, 不然轉碼生成的 MP3 會聲音尖銳變聲.
AVSampleRateKey必須保證和轉碼設置的相同.
Lame 編碼壓縮 的相關配置
我們需要錄音源文件路徑和生成MP3的路徑FILE *pcm和FILE *mp3,
//source 被轉換的音頻文件位置
FILE *pcm = fopen([cafFilePath cStringUsingEncoding:1], "rb");
//skip file header 跳過 PCM header 能保證錄音的開頭沒有噪音
fseek(pcm, 4*1024,? SEEK_CUR);
//output 輸出生成的Mp3文件位置
FILE *mp3 = fopen([mp3FilePath cStringUsingEncoding:1], "wb+");
通過fopen需要注意打開文件的模式. ?? 是擴展的 的 C 語言的 文件打開模式, 為什么要說這些, 比如 我使用 wb 來打開 mp3, 就意味著我只允許寫數據, 而如果你有對文件的讀取操作,將會出現錯誤, 這也是我被坑過的地方.
C 語言的 文件打開模式
w+以純文本方式讀寫,而wb+是以二進制方式進行讀寫。
mode說明:
w 打開只寫文件,若文件存在則文件長度清為0,即該文件內容會消失。若文件不存在則建立該文件。
w+ 打開可讀寫文件,若文件存在則文件長度清為零,即該文件內容會消失。若文件不存在則建立該文件。
wb 只寫方式打開或新建一個二進制文件,只允許寫數據。
wb+ 讀寫方式打開或建立一個二進制文件,允許讀和寫。
r 打開只讀文件,該文件必須存在,否則報錯。
r+ 打開可讀寫的文件,該文件必須存在,否則報錯。
rb+ 讀寫方式打開一個二進制文件,只允許讀寫數據。
a 以附加的方式打開只寫文件。若文件不存在,則會建立該文件,如果文件存在,寫入的數據會被加到文件尾,即文件原先的內容會被保留。(EOF符保留)
a+ 以附加方式打開可讀寫的文件。若文件不存在,則會建立該文件,如果文件存在,寫入的數據會被加到文件尾后,即文件原先的內容會被保留。 (原來的EOF符不保留)
ab+ 讀寫打開一個二進制文件,允許讀或在文件末追加數據。
加入b 字符用來告訴函數庫打開的文件為二進制文件,而非純文字文件。
然后是lame_init()來初始化,lame_set_num_channels(lame,1)默認轉碼為2雙通道, 設置單聲道會更大程度減少壓縮后文件的體積.
接下來 是執行一個 do while 的循環來反復讀取FILE* stream, 直到 read != 0 , 結束轉碼,釋放lame_close(lame); fclose(mp3);? fclose(pcm);
解決錄音時長讀取不正確的問題
Lame 的轉碼配置網上有很多, 網上可以搜到很多相關的代碼, 作為小白 copy 使用, 由于不懂源碼實現,直接拿來用就出現了不可預料的問題. 我出現的播放時間不準確的問題, 無論是 AVPlayer 或者 AVAudioPlayer 均無法讀取正確的長度, 要么是多幾秒, 要么是少幾秒, 還可能是超過10s的的誤差, 但是播放的過程中, 定時器的計數 會和 總時間顯示不吻合, 就比如 一個顯示 2:30 的錄音, 活生生 放到了 2:50, 你能想象是多么的尷尬Bug.
問題猜測
我把錄制完成的文件, 使用 iTunes 來播放可以顯示出正確的長度, 但是使用 QuickTime Player 會出現和 AVPlayer 一樣的錯誤時長 !!!
所以分析造成這個問題的原因可能是:
AVPlayer 不能正確讀取長度
MP3的編碼出現了錯誤...
然后網上也有人遇到了同樣的問題,給出的解決方法是換一種 AVPlayer 讀取方法:
我總結了 AVPlayer 獲取總時長的以下方法 ,結果測試 結果都是相近,
way 1
CMTime time = _player.currentItem.duration;
if (time.timescale == 0) {
return 0;
}
return time.value / time.timescale;
way 2
if (self.player && self.player.currentItem && self.player.currentItem.asset) {
return? CMTimeGetSeconds(self.player.currentItem.asset.duration);
} else{
return 0;
}
way 3
AVURLAsset* audioAsset = [AVURLAsset URLAssetWithURL:self.playingURL options:nil];
CMTime audioDuration = audioAsset.duration;
float audioDurationSeconds = CMTimeGetSeconds(audioDuration);
return (NSInteger)audioDurationSeconds;
其中 , 使用Asset可以解決獲取總時間是 NA 的這種錯誤情況. 實際中我并沒有出現過.
我的測試中 AVPlayer 使用這幾個方法, 均無法得到正確的值, 所以應該就是生成文件的問題了.
了解MP3編碼格式
然后,通過對MP3編碼格式調研, 了解到如下信息:
MP3使用的是動態碼率方式,而這種方式每一幀的長度應該是不等的。那會不會是AVPlayer是把文件當做每幀相等的方式來計算的總時間,所以才不對?
不斷輸出 AVPlayer duration來看, 每次都會有不同的結果, 而 AVPlayer 是支持Mp3 VBR格式文件播放的。所以應該還是我們的生成的文件有問題
了解到 MP3 VBR頭這個東西,有它記錄了整個文件的幀總數量,就能直接算出duration.所以是不是我們Lame編碼的時候,沒有寫入 VBR 頭 呢.
Lame 源碼分析
搜索 Lame 源碼VBR關鍵字可以得到
/*
1 = write a Xing VBR header frame.
default = 1
this variable must have been added by a Hungarian notation Windows programmer :-)
*/
int CDECL lame_set_bWriteVbrTag(lame_global_flags *, int);
int CDECL lame_get_bWriteVbrTag(const lame_global_flags *);
源碼寫的很簡單, 就是設置了gfp->write_lame_tag值, 看看所有調用write_lame_tag的地方吧。第一個就找到了lame_encode_mp3_frame(..)函數。這不就是用來每次灌buffer給lame做MP3編碼的方法嘛!也就是說每次都會給給幀添加VBR信息,這和之前看的編碼資料描述的一樣。
接下來, 就是需要找到寫入VBR頭的函數, 搜索源碼可得PutLameVBR()被調用在lame_get_lametag_frame()函數里, 然后我們來看看這個函數:
/*
* OPTIONAL:
* lame_mp3_tags_fid will rewrite a Xing VBR tag to the mp3 file with file
* pointer fid.? These calls perform forward and backwards seeks, so make
* sure fid is a real file.? Make sure lame_encode_flush has been called,
* and all mp3 data has been written to the file before calling this
* function.
* NOTE:
* if VBR? tags are turned off by the user, or turned off by LAME because
* the output is not a regular file, this call does nothing
* NOTE:
* LAME wants to read from the file to skip an optional ID3v2 tag, so
* make sure you opened the file for writing and reading.
* NOTE:
* You can call lame_get_lametag_frame instead, if you want to insert
* the lametag yourself.
*/
void CDECL lame_mp3_tags_fid(lame_global_flags *, FILE* fid);
原來這個函數是應該在lame_encode_flush()之后調, 當所有數據都寫入完畢了再調用。仔細想想也很合理, 這時才能確定文件的總幀數。
問題解決
現在的思路就比較清晰了, 由于在Lame編碼的過程中, 我們沒有對VBR頭進行寫入, 導致了 AVPlayer duration 以每幀相同的方式來計算出現的錯誤.
解決方法是, 在lame文件全部寫入之后, lame釋放之前, 使用lame_mp3_tags_fid寫入 VBR 頭文件, 測試通過, 讀取時間正常.
而這行代碼lame_mp3_tags_fid我在 網上搜索的各種配置中發現都沒有寫.
邊錄制邊轉碼的實現
通常我們是在錄制結束之后, 再進行轉碼; 當錄制的時間較長, 會消耗的時間比較長. 用戶需要等待轉碼結束后,才能操作; 但是如果我們使用邊錄制,邊轉碼的方式, 開另外一個線程同時進行轉碼,則幾乎沒有等待的時間,效率上會比較的高.
核心代碼實現
do {
curpos = ftell(pcm);
long startPos = ftell(pcm);
fseek(pcm, 0, SEEK_END);
long endPos = ftell(pcm);
long length = endPos - startPos;
fseek(pcm, curpos, SEEK_SET);
if (length > PCM_SIZE * 2 * sizeof(short int)) {
if (!isSkipPCMHeader) {
//Uump audio file header, If you do not skip file header
//you will heard some noise at the beginning!!!
fseek(pcm, 4 * 1024, SEEK_CUR);
isSkipPCMHeader = YES;
NSLog(@"skip pcm file header !!!!!!!!!!");
}
read = (int)fread(pcm_buffer, 2 * sizeof(short int), PCM_SIZE, pcm);
write = lame_encode_buffer_interleaved(lame, pcm_buffer, read, mp3_buffer, MP3_SIZE);
fwrite(mp3_buffer, write, 1, mp3);
NSLog(@"read %d bytes", write);
} else {
[NSThread sleepForTimeInterval:0.05];
NSLog(@"sleep");
}
} while (! weakself.stopRecord);
邊錄邊轉碼, 只是我們在錄制結果后,重新開一個線程來進行文件的轉碼,
當錄音進行中時, 會持續讀取到指定大小文件,進行編碼, 讀取不到,則線程休眠
在 while 的條件中, 我們收到 錄音結束的條件,則會結束 do while 的循環.
我們需要在錄制結束后發送一個信號, 讓 do while 跳出循環
測試 Demo
為了讓遇到相同問題的人, 能夠更加對這些問題有一點的了解, 我會 在這里貼一個我測試的Demo 這只是一個實例程序, 并不具備完整的邏輯功能, 請熟知.
關于Demo, 可以在 ViewController 中#define ENCODE_MP3 1使用 1 和 0 , 來測試普通轉碼 和 邊錄制 邊轉碼.
ConvertAudioFile是錄音轉碼封裝的源碼
邊錄邊轉的用法
[[ConvertAudioFile sharedInstance] conventToMp3WithCafFilePath:self.cafPath
mp3FilePath:self.mp3Path
sampleRate:ETRECORD_RATE callback:^(BOOL result)
{
NSLog(@"---- 轉碼完成? --- result %d? ---- ", result);
}];;
錄制完成轉碼的用法
[ConvertAudioFile conventToMp3WithCafFilePath:self.cafPath
mp3FilePath:self.mp3Path
sampleRate:ETRECORD_RATE callback:^(BOOL result)
{
NSLog(@"---- 轉碼完成? --- result %d? ---- ", result);
}];
Demo 見 文章底部, 如果Demo 有什么不理解 和 不準確的地方,還麻煩指正...
結語
由于時間有限, 我并不會 寫太多細致的內容, 只是對這幾天的研究做一個總結,和列舉一些注意事項,如果在做音頻錄制轉碼中遇到相同的問題,則會有比較大的幫助.
總結
這次解決這個問題,讓我受益匪淺, 很多地方的收獲是超過問題本身的:
在使用別人的示范代碼時,如果不進行一定的剖析;當出現問題的時間,會比較的難判斷問題的來源
iOS的相關技術博客,現在網上可以搜到很多相關示范代碼, 但是由于很多人可能也是貼出了并不是很準確的東西, 相關給別人帶來了錯誤的示范.
作為 iOS 開發者, 對很多東西,如果想要有更加深層次的理解,則需要 1. 計算機基礎扎實 2. iOS底層理解夠深 3.架構設計模式理解夠深 4.代碼平時寫的必須夠優雅
Google 會比 Baidu 靠譜呀; 雖然我之前也是這么想的,但這次對我有幫助的文章均來自 Google, 相反Baidu 給了很多錯誤的示范.
Link
致謝
對我有幫助的文章
http://www.lxweimin.com/p/57f38f075ba0
原文鏈接:http://www.lxweimin.com/p/971fff236881