題記
這個模組是我們兒童專案的一個小功能,佔一個迭代版本需求的40%左右,開發週期12.5day,當然包含了整個IM邏輯,語音錄製1天。
實現方案分析
需求:IM語音聊天,IM採用騰訊的IM,語音錄製部分有自己的UI設計,大概是這個樣子(彈出一個pop框,先用合成器說一段“現在給xxx留言吧”,馬上開始錄製音訊,5秒後出現可以傳送按鈕,最大30s傳送語音,留言過程中要展示聲音波形圖)
方案:1.用AVAudioRecorder,高度封裝,之前專案已經實踐過,有無聲音分貝未知。 2.用AudioUnit實現,過於細粒度,要處理音訊buffer。
根據需求來說方案1看起比較貼近。
實踐
我就直接貼程式碼了,程式碼中會做註釋,其中有寫CallBack,許可權使用到的其他類就不上了,理解就好。
#import <Foundation/Foundation.h>
#import "CallBackInterface.h" //一個通用的CallBack宣告頭
@interface AudioRecorder : NSObject
@property(nonatomic, copy) CallBackTwo callBack;
@property(nonatomic, copy) CallBackOne canSendCallBack;
@property(nonatomic, assign) NSInteger volume;
@property(readonly, assign) BOOL isRecording;
- (BOOL)startRecord:(NSString *)storeFile;
- (void)cancelRecord;
- (void)endRecord;
- (void)resetEnvironment;
@end
複製程式碼
#import "AudioRecorder.h"
#import <ReactiveCocoa.h>
#import <AVFoundation/AVFoundation.h>
#import "UALogger.h"
#import "PermissionUtils.h" //一些手機許可權呼叫類,包括這次的micro許可權請求
#import "HWWeakTimer.h" //這個就不說了搜一下
#import "NSFileManager+Message.h" //錄音檔案管理
@interface AudioRecorder () <AVAudioRecorderDelegate>
@property(nonatomic, strong) NSString *storeFile;
@property(nonatomic, strong) AVAudioRecorder *recorder;
@property(nonatomic, strong) NSTimer *timer;
@property(nonatomic, assign) NSTimeInterval time; ///< 記錄當前錄音時間
@end
@implementation AudioRecorder
- (void)dealloc {
[self endRecord];
}
- (BOOL)startRecord:(NSString *)storeFile {
self.storeFile = storeFile;
__block BOOL checkAuth = NO;
__block BOOL success = NO;
@weakify(self);
//錄音許可權開啟
[PermissionUtils openMic:^(BOOL auth) {
@strongify(self);
if (auth) {
[self setActive];
success = [self.recorder prepareToRecord];
[self.recorder record];
[self timer];
}
checkAuth = YES;
}];
//可以看看這種方式處理執行緒等待,這兒想起了一個故事,就略了
while (!checkAuth) {
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:.05]];
}
return success;
}
- (void)setActive {
@try {
NSError *error;
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryRecord error:&error];
if (error) {
UALog(@"[AudioRecorder][ERROR][StartRecord] %@", error);
}
error = nil;
[[AVAudioSession sharedInstance] setActive:YES error:&error];
if (error) {
UALog(@"[AudioRecorder][ERROR][CallBack] %@", error);
}
} @catch (NSException *e) {
}
}
- (void)cancelRecord {
self.callBack = NULL;
[self endRecord];
_recorder.delegate = nil; // cancel 時快速釋放,避免引用造成的AVAudioSession Deactive失敗
[self resetEnvironment];
}
- (void)endRecord {
self.canSendCallBack = NULL;
[self invalidateTimer];
if (_recorder) {
[self.recorder stop];
[self deactivedAudioSession];
_recorder = nil;
}
}
- (void)deactivedAudioSession {
NSError *error = nil;
if (![[AVAudioSession sharedInstance] setActive:NO error:&error]) {
NSLog(@"%@: AVAudioSession.setDeActive failed: %@\n", NSStringFromClass(self.class), error ? [error localizedDescription] : @"nil");
}
}
- (void)resetEnvironment {
//check doc path
NSFileManager *fm = [NSFileManager defaultManager];
if ([fm fileExistsAtPath:self.storeFile]) {
NSError *error;
// [fm removeItemAtPath:self.storeFile error:&error];
if (error) {
UALog(@"[AudioRecorder][FM]ERROR %@", error);
}
}
self.time = 0.0f;
}
#pragma mark - AVAudioRecorderDelegate
- (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag {
if (self.callBack) {
self.callBack(@(flag), nil);
}
}
- (void)audioRecorderEncodeErrorDidOccur:(AVAudioRecorder *)recorder error:(NSError *__nullable)error {
if (self.callBack) {
self.callBack(@(NO), error);
}
}
- (void)audioRecorderBeginInterruption:(AVAudioRecorder *)recorder {
}
- (void)audioRecorderEndInterruption:(AVAudioRecorder *)recorder withOptions:(NSUInteger)flags {
}
- (void)audioRecorderEndInterruption:(AVAudioRecorder *)recorder withFlags:(NSUInteger)flags {
}
- (void)audioRecorderEndInterruption:(AVAudioRecorder *)recorder {
}
#pragma mark - getter
- (AVAudioRecorder *)recorder {
if (!_recorder) {
//錄音設定
NSMutableDictionary *recordSetting = [[NSMutableDictionary alloc] init];
//設定錄音格式 AVFormatIDKey==kAudioFormatLinearPCM
[recordSetting setValue:[NSNumber numberWithInt:kAudioFormatMPEG4AAC] forKey:AVFormatIDKey];
//設定錄音取樣率(Hz) 如:AVSampleRateKey==8000/44100/96000(影響音訊的質量)
[recordSetting setValue:[NSNumber numberWithFloat:44100] forKey:AVSampleRateKey];
//錄音通道數 1 或 2
[recordSetting setValue:[NSNumber numberWithInt:2] forKey:AVNumberOfChannelsKey];
//線性取樣位數 8、16、24、32
[recordSetting setValue:[NSNumber numberWithInt:16] forKey:AVLinearPCMBitDepthKey];
//錄音的質量
[recordSetting setValue:[NSNumber numberWithInt:AVAudioQualityHigh] forKey:AVEncoderAudioQualityKey];
NSError *err;
[self resetEnvironment];
_recorder = [[AVAudioRecorder alloc] initWithURL:[NSURL fileURLWithPath:self.storeFile] settings:recordSetting error:&err];
_recorder.delegate = self;
_recorder.meteringEnabled = YES;
if (err) {
UALog(@"[AudioRecorder][Error] %@", err);
}
}
return _recorder;
}
- (NSString *)storeFile {
if (!_storeFile) {
_storeFile = [[NSFileManager defaultManager] temRecordAudioFile];
}
return _storeFile;
}
- (BOOL)isRecording {
return self.recorder.isRecording;
}
- (NSTimer *)timer {
if (!_timer) {
@weakify(self);
_timer = [HWWeakTimer scheduledTimerWithTimeInterval:.05 block:^(id userInfo) {
@strongify(self);
self.time += .05;
[self log];
if (self.canSendCallBack) {
self.canSendCallBack(@(self.time));
}
if (self.time >= 30.0f) {
[self endRecord];
}
} userInfo:nil repeats:YES];
}
return _timer;
}
- (void)log {
[_recorder updateMeters];
self.volume = (NSInteger) (pow(10, 0.05 * [_recorder peakPowerForChannel:0]) * 100);
}
- (void)invalidateTimer {
if (!_timer) {
[_timer invalidate];
_timer = nil;
}
}
@end
複製程式碼
上面說到,UI設計有一個音訊分貝的波形圖,開始寫的時候找到AVAudioRecoder有分貝最大值和平均值,
- (float)peakPowerForChannel:(NSUInteger)channelNumber;
- (float)averagePowerForChannel:(NSUInteger)channelNumber;
複製程式碼
但你何時取都是-160,讀文件發現構造recoder時候要設定 meteringEnabled = YES;
@property(getter=isMeteringEnabled) BOOL meteringEnabled;
複製程式碼
且在每次取分貝的時候要更新一次
- (void)updateMeters;
複製程式碼
轉化為 0~100的值作為波形圖的引數
self.volume = (NSInteger) (pow(10, 0.05 * [_recorder peakPowerForChannel:0]) * 100);
複製程式碼
主意事項
1.AudioSession 持有和釋放 由於在語音錄製前有段語音合成“現在給xxx留言吧”,導致後面總是會報"AVAudioSession I/O"的一個錯誤,開始以為是各處AVAudioSession.active 與 setDeActive
[[AVAudioSession sharedInstance] setActive:YES error:&error];
與
[[AVAudioSession sharedInstance] setActive:NO error:&error
不成對造成的
複製程式碼
排查了播放器,語音合成播放器(百度和訊飛的),以及錄音模組成對釋放問題,最後驗證顯示呼叫還是沒用,最後分析合成器哪一塊釋放是不是耗時比較多,而我很快進入錄製,哪邊還沒有結束,然後造成我錄製一到兩秒,那邊的釋放訊號過來了,而這回AVAudioSession中確實錄製。 最後給合成器那邊給了0.5s處理釋放,延遲錄製0.5s解決此問題,由於合成器是第三方的,這種妥協是沒有辦法的。
2.AVRecorder崩潰問題 AVRecorder頁面邏輯我是Present一個ViewController上來,在點選周邊灰色層是取消錄製,但往往我們會很快的點選灰色層,又很快的點選錄製,周而復始崩潰。 這塊處理有個缺陷,我的cancel釋放有一個是放到
- dismissViewControllerAnimated:completion:
複製程式碼
的completion中,其實往往沒有時間處理釋放,所以模態出來的頁面的釋放不要把耗時的操作放到這塊。 但所謂的使用者暴力開始錄製取消錄製任然會出現崩潰,這塊只能上防止重複點選大法一個category奉上:
#import <Foundation/Foundation.h>
@interface NSObject (MultiClick)
@property (nonatomic, assign) NSTimeInterval acceptInterval; ///< 間隔 s
@property (nonatomic, assign) NSTimeInterval acceptTime; ///< 上次觸發事件時間
/**
* 是否接受本次事件
*/
- (BOOL)canAcceptEvent;
@end
#import "NSObject+MultiClick.h"
#import <objc/runtime.h>
#import "UALogger.h"
const char *NSObject_acceptTime = "NSObject_acceptTime";
const char *NSObject_acceptInterval = "NSObject_acceptInterval";
@implementation NSObject (MultiClick)
- (NSTimeInterval)acceptTime {
return [objc_getAssociatedObject(self, NSObject_acceptTime) doubleValue];
}
- (void)setAcceptTime:(NSTimeInterval)acceptTime {
objc_setAssociatedObject(self, NSObject_acceptTime, @(acceptTime), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSTimeInterval)acceptInterval {
return [objc_getAssociatedObject(self, NSObject_acceptInterval) doubleValue];
}
- (void)setAcceptInterval:(NSTimeInterval)acceptInterval {
objc_setAssociatedObject(self, NSObject_acceptInterval, @(acceptInterval), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (BOOL)canAcceptEvent {
if (self.acceptInterval == 0) {
return NO;
}
if ([[NSDate date] timeIntervalSince1970] - self.acceptTime > self.acceptInterval) {
self.acceptTime = [[NSDate date] timeIntervalSince1970];
return YES;
}
return NO;
}
@end
複製程式碼
使用方法
具體某一個類初始化的時候寫入防重點間隔
self.acceptInterval = 1;
事件方法第一行加
if (![self canAcceptEvent]) return;
複製程式碼
最後,感謝!感謝身邊的coder能答疑解惑,感謝合作partner很給力,so 能愉快的寫幾行程式碼還是挺愉快的。