美文网首页IOS调试
IOS企业:司机端APP行驶全程录音功能(上)

IOS企业:司机端APP行驶全程录音功能(上)

作者: 时光啊混蛋_97boy | 来源:发表于2020-11-23 18:43 被阅读0次

    原创:知识探索型文章
    创作不易,请珍惜,之后会持续更新,不断完善
    个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
    温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

    目录

    • 一、需求背景
      • 1、需求背景
      • 2、功能点
    • 二、使用录音工具类实现需求
      • 1、简单版的使用Demo
      • 2、工程中实际使用时的录音时机
    • 三、实现APP录音功能
      • 1、询问录音权限
      • 2、创建录音器
      • 3、生成录音文件名称
    • 四、录音过程
      • 1、配置参数
      • 2、开始录音
      • 3、结束录音
    • 五、转化为MP3格式
      • 1、LameTool工具类提供的接口方法
      • 2、使用到LameTool工具类的地方
      • 3、配置路径
      • 4、配置转码的文件属性
      • 5、转码过程
      • 6、转码完成后的处理
    • Demo
    • 参考文献

    一、需求

    1、需求背景

    1. 部分城市运管部门要求网约车需提供服务过程中录音功能,例如长沙。
    2. 神州与高德等聚合平台对账时,对于部分争议作弊订单,需要行程中录音证据,例如高德判定某订单乘客未上车,只要神州可提供行程中录音证明乘客上车,那该订单就可认定为正常订单, 否则该订单高德不与神州结算。
    3. 司乘发生纠纷时,有行程中录音可方便责任判定,并且行程中录音功能的存在,可在一定程度上避免一些恶意投诉或司机不规范服务。
    4. 便于业务部门抽查司机服务规范情况,例如人工抽查或语音质检。

    2、功能点

    录音权限
    • APP启动并且登录的情况下司机肯定已经同意隐私协议
    • 不同类型订单授权判定规则不同,存在要开启录音状态的订单
    开启录音
    • 司机点击【已到达】订单状态变为“已到达”后开始录音,根据订单状态的变化来判断是否开启录音
    结束录音
    • 订单状态变化不满足录音条件延迟指定时间后再结束,延迟时间可配置
    • 正常完结订单从已到达到结束服务+5分钟(可配置)这段时间为录音时段,在司机服务某订单期间,录音结束时间落在该时段的录音文件,将自动关联该订单号
    • 极端情况处理:如果某司机上一个订单结束服务后还未达到5分钟,下一张订单就开始服务了,那么新的录音文件将自动关联下一张订单
    • APP被杀
    • 司机掉线
    • 订单被改派
    中断录音
    • APP启动后检测,在服务过程中手机重启或程序被杀
    • 司机登录后检测,意外导致司机掉线
    音频格式
    • 采样率=11025.0hz,采样率可配置
    • 码率=96kbps,码率可配置
    • 音频文件为MP3格式,格式可配置
    音频文件生成
    • 录音文件每3分钟保存一个文件,保存成功则录制下一段,时长可配置
    • 结束录音时,把当前正在录制的音频保存,可能不足3分钟
    • 音频文件在司机端本地最多占用1024M存储空间,空间满时自动覆盖生成时间最早的文件,空间上限可配置
    • 生成的音频文件需要加密保存,司机端本地不可查看/检索/播放
    • 生成的音频文件需包含必要信息,以便上传后系统可关联到对应订单和司机:需包含订单号,司机端登录的司机id,开始录制时间和结束录制时间
    • 断网情况下,无法上传音频,需在司机端本地保存,待联网后自动上传
    音频文件上传
    • 音频文件生成后立即上传服务器,上传成功后删除司机端本地文件
    • 司机端APP运行时定时每5分钟自动检测并上传所有未上传的订单录音文件,上传成功后删除司机端本地文件,定时间隔可配置

    二、使用录音工具类实现需求

    DriverRecordSoundKit可通过CocoaPods获得。要安装它,只需在Podfile中添加以下行:

    pod 'DriverRecordSoundKit'
    

    也可以下载本文的Demo,使用源代码进行自定义。

    Pod库

    1、简单版的使用Demo

    简单版的使用Demo
    a、司机到达乘车点,开始录音
    - (void)startRecordClick
    {
        [UCARRecordSoundTool shareUCARRecordSoundTool].timeInterval = 5;
        [UCARRecordSoundTool shareUCARRecordSoundTool].maximumMemory = 0.1;
        [UCARRecordSoundTool shareUCARRecordSoundTool].delegate = self;
        [[UCARRecordSoundTool shareUCARRecordSoundTool] startRecordWithOrderNumber:@"35200505324217" driverID:@"2890893"];
    }
    

    输出结果为:

    2020-11-23 10:54:57.306265+0800 Demo[84637:3133118] 司机到达,开始录音
    2020-11-23 10:54:57.308745+0800 Demo[84637:3133118] 加密密钥成功写入Plist文件,路径为:/Users/xiejiapei/Library/Developer/CoreSimulator/Devices/B3334C09-878A-4178-A14F-242CCCFC8FF7/data/Containers/Data/Application/EE081013-572C-4F57-9B50-CC78C7385881/Documents/Recorder.plist
    2020-11-23 10:54:57.326445+0800 Demo[84637:3133118] 转换开始!!
    2020-11-23 10:54:57.326544+0800 Demo[84637:3142733] 当前录音时长:0.016780
    2020-11-23 10:54:57.657662+0800 Demo[84637:3142733] 当前录音时长:0.347483
    2020-11-23 10:54:58.269833+0800 Demo[84637:3133486] skip pcm file header !!!!!!!!!!,跳过 PCM header 能保证录音的开头没有噪音 
    2020-11-23 10:54:58.276620+0800 Demo[84637:3133486] read 2549 bytes
    ......
    2020-11-23 10:55:02.614905+0800 Demo[84637:3133118] 录音文件地址:/Users/xiejiapei/Library/Developer/CoreSimulator/Devices/B3334C09-878A-4178-A14F-242CCCFC8FF7/data/Containers/Data/Application/EE081013-572C-4F57-9B50-CC78C7385881/Library/Caches/Recorder/recording_35200505324217_2890893_1606100097000_1606100102000_MP3.caf
    2020-11-23 10:55:02.615295+0800 Demo[84637:3133118] 录音文件大小:237484
    2020-11-23 10:55:02.615388+0800 Demo[84637:3133118] 所有的音频文件限制大小为:0.100000MB
    2020-11-23 10:55:07.959050+0800 Demo[84637:3133118] 获得当前文件的所有子文件:(
        "35200505324217_2890893_1606100097000_1606100102000_MP3.UCAR",
        "recording_35200505324217_2890893_1606100102000_1606100107000_MP3.caf",
        ".DS_Store",
        "recording_35200505324217_2890893_1606100097000_1606100102000_MP3.mp3",
        "recording_35200505324217_2890893_1606100102000_1606100107000_MP3.mp3"
    )
    2020-11-23 10:55:07.967205+0800 Demo[84637:3133118] 所有的音频文件:(
        "35200505324217_2890893_1606100097000_1606100102000_MP3.UCAR"
    )
    2020-11-23 10:55:07.967444+0800 Demo[84637:3133118] 所有的音频文件大小为:0.020540MB
    2020-11-23 10:55:07.967606+0800 Demo[84637:3133118] 磁盘空闲空间为: 157642.89 MB == 153.95 GB
    2020-11-23 10:55:07.967667+0800 Demo[84637:3133118] 录音结束
    2020-11-23 10:55:07.971947+0800 Demo[84637:3142734] 删除源文件成功
    2020-11-23 10:55:07.972031+0800 Demo[84637:3142734] 转 MP3 成功
    2020-11-23 10:55:07.972183+0800 Demo[84637:3142734] 转为MP3后的路径 = /Users/xiejiapei/Library/Developer/CoreSimulator/Devices/B3334C09-878A-4178-A14F-242CCCFC8FF7/data/Containers/Data/Application/EE081013-572C-4F57-9B50-CC78C7385881/Library/Caches/Recorder/recording_35200505324217_2890893_1606100102000_1606100107000_MP3.mp3
    2020-11-23 10:55:07.986312+0800 Demo[84637:3142734] 成功在原文件夹生成加密后的文件
    2020-11-23 10:55:07.986443+0800 Demo[84637:3142734] 成功删除未加密的mp3原始录音文件
    .......
    

    生成的音频文件如下:

    加密后和处于录制中的音频文件 设置的录制间隔时间为5s 音频文件的属性信息
    b、结束行程,停止录音
    - (void)endTripClick
    {
        [[UCARRecordSoundTool shareUCARRecordSoundTool] endTrip];
    }
    

    输出结果如下:

    2020-11-23 10:55:21.524099+0800 Demo[84637:3142733] 结束行程时未满3分钟需要给录音文件重新命名,修改后地址为:/Users/xiejiapei/Library/Developer/CoreSimulator/Devices/B3334C09-878A-4178-A14F-242CCCFC8FF7/data/Containers/Data/Application/EE081013-572C-4F57-9B50-CC78C7385881/Library/Caches/Recorder/35200505324217_2890893_1606100118000_1606100121000_MP3.UCAR
    2020-11-23 10:55:21.524187+0800 Demo[84637:3142733] 需要即时上传的文件路径为:/Users/xiejiapei/Library/Developer/CoreSimulator/Devices/B3334C09-878A-4178-A14F-242CCCFC8FF7/data/Containers/Data/Application/EE081013-572C-4F57-9B50-CC78C7385881/Library/Caches/Recorder/35200505324217_2890893_1606100118000_1606100121000_MP3.UCAR
    2020-11-23 10:55:21.524329+0800 Demo[84637:3142733] 在这里进行3分钟文件的自动上传
    
    c、结束行程后立即开启新订单

    需要延时执行,否则会导致startRecord方法在endTrip方法的委托还没执行完成之前就调用了,调用顺序出错。

    - (void)restartTripClick
    {
        [[UCARRecordSoundTool shareUCARRecordSoundTool] endTrip];
        
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [UCARRecordSoundTool shareUCARRecordSoundTool].timeInterval = 10;
            [[UCARRecordSoundTool shareUCARRecordSoundTool] startRecordWithOrderNumber:@"35207737643077" driverID:@"65732"];
        });
    }
    

    2、工程中实际使用时的录音时机

    a、根据接口提供的配置参数开启录音

    在开启下一个订单之前,需要判断上一个订单是否已经结束录音。因为不是订单状态变更为已经结束则结束录音,而存在5分钟的缓存录音时间,所以存在下一个订单在5分钟的缓存时间内就来到的情况。在这样的情况下,需要判断两种情况,一种是用于开启录音的订单号和当前正在录音的订单号相同,则直接返回即可。另外一种是用于开启录音的订单号和当前正在录音的订单号不相同,则立即停止录音,并销毁延时计时器,开启下一个订单的录音。

    - (void)startRecordWithOrderNumber:(NSString *)orderNumber driverID:(NSString *)driverID
    {
        // 正在录音
        if ([[UCARRecordSoundTool shareUCARRecordSoundTool] carIsRecording])
        {
            // 正在录制的订单号
            NSString *exitNumber = [UCARRecordSoundTool shareUCARRecordSoundTool].recordingOrderNumber;
            // 该订单号正在录制中
            if ([exitNumber isEqualToString:orderNumber])
            {
                NSLog(@"该订单号正在录制中 %@,不做处理", orderNumber);
                return;
            }
        }
        // 停止录音
        [self stopRecordAudio];
        
        // 开启新录音
        // 因 stopRecordAudio 方法会调用系统代理,时间不确定, 暂时延后 0.5s 执行
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            NSLog(@"开启录音 %@", orderNumber);
            
            // 将后端提供的可配置参数与本地的参数关联起来
            [self configureRecordParams];
            [[UCARRecordSoundTool shareUCARRecordSoundTool] startRecordWithOrderNumber:orderNumber driverID:driverID];
        });
    }
    

    在司机已经到达的时候调用该方法开始进行录制,下面只是个粗糙的模版

    - (void)startRecordAudio
    {
        BOOL openRecord = YES;
        NSString *orderNo = @"35200505324217";
        NSString *driverId = @"2890893";
        
        // 若允许录音
        if (openRecord)
        {
            // 开启录音
            [[UCARDirverUploadFileTool shareUCARDirverUploadFileTool] startRecordWithOrderNumber:orderNo driverID:driverId];
        }
    }
    
    b、延迟结束录音

    配置结束录音延迟时间(s),默认为5分钟。

    - (void)endTripRecordAfterDelay
    {
        NSTimeInterval delay = 300;
        if (self.config.laterStopTime > 0)
        {
            delay = self.config.laterStopTime;
        }
        
        // 开启延时计时器
        if (!self.delayTimer || !self.delayTimer.isValid)
        {
            NSLog(@"开启延时计时器,倒计时%f秒", delay);
            
            // 用来存放弱对象的代理。它可以用来避免NSTimer或CADisplayLink导致的引用循环
            UCARWeakProxy *weakProxy = [UCARWeakProxy proxyWithTarget:self];
            self.delayTimer = [NSTimer scheduledTimerWithTimeInterval:delay target:weakProxy selector:@selector(stopRecordAudio) userInfo:nil repeats:NO];
            [[NSRunLoop currentRunLoop] addTimer:self.delayTimer forMode:NSRunLoopCommonModes];
        }
    }
    

    在更新服务状态为结束行程的时候进行调用

    - (void)updateServiceStatusRequestWithServiceStatus:(UCARDispatchServiceStatus)status
    {
        // 结束服务状态
        if (status == UCARDispatchServiceStatusEndService)
        {
            // 延迟结束录音
            [[UCARDirverUploadFileTool shareUCARDirverUploadFileTool] endTripRecordAfterDelay];
        }
    }
    
    c、立即停止录音,并销毁延时计时器
    - (void)stopRecordAudio
    {
        // 结束行程录音
        [[UCARRecordSoundTool shareUCARRecordSoundTool] endTrip];
        // 清空正在录制中的订单号
        [UCARRecordSoundTool shareUCARRecordSoundTool].recordingOrderNumber = @"";
        
        // 销毁延时结束订单计时器
        if (self.delayTimer)
        {
            [self.delayTimer invalidate];
            self.delayTimer = nil;
        }
    }
    

    在司机退出登录状态的时候进行调用

    - (void)driverLogoutRequest
    {
        // 结束录音
        [[UCARDirverUploadFileTool shareUCARDirverUploadFileTool] stopRecordAudio];
    }
    
    d、无网络则停止检测录音文件和上传

    监听网络连接状态

    - (void)viewDidLoad
    {
        [super viewDidLoad];
        
        // 监听网络连接状态
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(netWorkStatusChanged:) name:AFNetworkingReachabilityDidChangeNotification object:nil];
        
        // 定时检测录音文件并上传
        [[UCARDirverUploadFileTool shareUCARDirverUploadFileTool] uploadTaskWithFireTime];
    }
    

    当网络状态进行改变时候停止或者继续上传

    - (void)netWorkStatusChanged:(NSNotification *)notice
    {
        // 获取网络状态
        NSDictionary *dic = notice.userInfo;
        NSInteger status = [[dic objectForKey:AFNetworkingReachabilityNotificationStatusItem] integerValue];
        
        // 无网络则停止检测录音文件和上传
        if(status == AFNetworkReachabilityStatusNotReachable)
        {
            [[UCARDirverUploadFileTool shareUCARDirverUploadFileTool] stopUploadTask];
            return;
        }
        else
        {
            // 定时检测录音文件并上传
            [[UCARDirverUploadFileTool shareUCARDirverUploadFileTool] uploadTaskWithFireTime];
        }
    }
    

    三、实现APP录音功能

    1、询问录音权限

    a、询问录音权限的功能演示
    询问录音权限 提醒打开麦克风 点击设置后跳转到设置APP中录音APP的打开麦克风界面
    b、在info.plist中添加授权信息

    同时在info.plist中添加授权信息:Privacy - Microphone Usage Description 为“申请访问麦克风权限,您可以录制音频并分享给其他人”。

    info.plist
    c、检查授权状态的功能实现
    - (void)checkMicrophoneAuthorization:(void (^)(void))permissionGranted withNoPermission:(void (^)(BOOL error))noPermission
    {
        // 获取音频媒体授权状态
        AVAuthorizationStatus audioAuthorizationStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
        switch (audioAuthorizationStatus)
        {
            case AVAuthorizationStatusNotDetermined:
            {
                // 第一次进入APP提示用户授权
                [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
                    
                    granted ? permissionGranted() : noPermission(NO);
                }];
                break;
            }
            case AVAuthorizationStatusAuthorized:
            {
                // 通过授权
                permissionGranted();
                break;
            }
            case AVAuthorizationStatusRestricted:
            {
                // 拒绝授权
                noPermission(YES);
                break;
            }
            case AVAuthorizationStatusDenied:
            {
                // 提示跳转到相机设置(这里使用了blockits的弹窗方法)
                noPermission(NO);
                break;
            }
            default:
                break;
        }
    }
    
    d、未授权时候的提醒框和跳转到设置APP的方法实现
    [self checkMicrophoneAuthorization:^{
    ......
    } withNoPermission:^(BOOL error) {
        if (error)
        {
            NSLog(@"无法录音");
        }
        else
        {
            NSLog(@"没有录音权限,请前往 “设置” - “隐私” - “麦克风” 为APP开启权限");
            
            UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"麦克风未打开" message:@"录音功能需要录音权限,请到设置中开启" preferredStyle:UIAlertControllerStyleAlert];
            
            UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
                NSLog(@"点击了取消");
            }];
            [alertController addAction:cancelAction];
            
            UIAlertAction *firstAction = [UIAlertAction actionWithTitle:@"去设置" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
                
                [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil];
            }];
            [alertController addAction:firstAction];
            
            dispatch_async(dispatch_get_main_queue(), ^{
                [[[UIApplication sharedApplication].delegate window].rootViewController presentViewController:alertController animated:YES completion:^{NSLog(@"点击了取消");}];
            });
        }
    }];
    

    2、创建录音器

    a、设置录音会话

    音频会话是应用程序和操作系统之间的中间人。应用程序不需要具体知道怎样和音频硬件交互的细节,只需要把所需的音频行为委托给音频会话管理即可。

    录音会话包括以下Category
    • AVAudioSessionCategoryPlayAndRecord:录制和播放。打断不支持混音播放的APP,不会响应手机静音键开关
    • AVAudioSessionCategoryAmbient:用于非以语音为主的应用,随着静音键和屏幕关闭而静音
    • AVAudioSessionCategorySoloAmbient:类似AVAudioSessionCategoryAmbient不同之处在于它会中止其它应用播放声音
    • AVAudioSessionCategoryPlayback:用于以语音为主的应用,不会随着静音键和屏幕关闭而静音,可在后台播放声音
    • AVAudioSessionCategoryRecord:用于需要录音的应用,除了来电铃声,闹钟或日历提醒之外的其它系统声音都不会被播放,只提供单纯录音功能
    录音会话属性
    @property(readonly, getter=isRecording) BOOL recording; //是否正在录音,只读
    @property(readonly) NSURL *url //录音文件地址,只读
    @property(readonly) NSDictionary *settings //录音文件设置,只读
    @property(readonly) NSTimeInterval currentTime //录音时长,只读,注意仅仅在录音状态可用
    @property(readonly) NSTimeInterval deviceCurrentTime //输入设置的时间长度,只读,注意此属性一直可访问
    @property(getter=isMeteringEnabled) BOOL meteringEnabled; //是否启用录音测量,如果启用录音测量可以获得录音分贝等数据信息
    @property(nonatomic, copy) NSArray *channelAssignments //当前录音的通道
    
    录音会话对象方法
    - (instancetype)initWithURL:(NSURL *)url settings:(NSDictionary *)settings error:(NSError **)outError //录音机对象初始化方法,注意其中的url必须是本地文件url,settings是录音格式、编码等设置
    - (BOOL)prepareToRecord //准备录音,主要用于创建缓冲区,如果不手动调用,在调用record录音时也会自动调用
    - (BOOL)record //开始录音
    - (BOOL)recordAtTime:(NSTimeInterval)time //在指定的时间开始录音,一般用于录音暂停再恢复录音
    - (BOOL)recordForDuration:(NSTimeInterval) duration //按指定的时长开始录音
    - (BOOL)recordAtTime:(NSTimeInterval)time forDuration:(NSTimeInterval) duration //在指定的时间开始录音,并指定录音时长
    - (void)pause; //暂停录音
    - (void)stop; //停止录音
    - (BOOL)deleteRecording; //删除录音,注意要删除录音此时录音机必须处于停止状态
    - (void)updateMeters; //更新测量数据,注意只有meteringEnabled为YES此方法才可用
    - (float)peakPowerForChannel:(NSUInteger)channelNumber; //指定通道的测量峰值,注意只有调用完updateMeters才有值
    - (float)averagePowerForChannel:(NSUInteger)channelNumber //指定通道的测量平均值,注意只有调用完updateMeters才有值
    
    录音会话代理方法
    - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag //完成录音
    
    创建录音会话对象
    - (AVAudioRecorder *)audioRecorder
    {
        __weak typeof(self) weakSelf = self;
        
        if (!_audioRecorder)
        {
    //1. 启动会话
            [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayAndRecord error:nil];
            [[AVAudioSession sharedInstance] setActive:YES error:nil];
    
    //2. 设置录音参数
    .......
    
    //3. 创建录音对象
            NSError *error;
            // 确定录音存放的位置
            NSURL *url = [NSURL URLWithString:weakSelf.recordPath];
            _audioRecorder = [[AVAudioRecorder alloc] initWithURL:url settings:recordSettings error:&error];
            
            // 开启音量监测
            _audioRecorder.meteringEnabled = YES;
            // 设置录音完成委托回调
            _audioRecorder.delegate = self;
            
            if(error)
            {
                NSLog(@"创建录音对象时发生错误,错误信息:%@",error.localizedDescription);
            }
        }
        return _audioRecorder;
    }
    
    b、设置录音参数
    设置编码格式AVFormatIDKey
    • kAudioFormatLinearPCM:Lame 的转码压缩,是把录制的PCM转码成 MP3,所以录制的 AVFormatIDKey 设置成kAudioFormatLinearPCM(无损压缩,内容非常大) ,生成的文件可以是caf或者wavcaf文件是 Mac OS X 原本支持的众多音频格式中最新增加的一种。iPhone短信就是这种格式,录制出的文件会比较大。
    • kAudioFormatMPEG4AAC
    设置采样率AVSampleRateKey
    • 必须保证和转码设置的相同,否则会造成转换不成功
    • 采样率越高,文件越大,质量越好,反之,文件小,质量相对差一些,但是低于普通的音频,人耳并不能明显的分辨出好坏
    • 建议使用标准的采样率,8000、16000、22050、44100(11025.0)
    设置通道数AVNumberOfChannelsKey
    • 用于指定记录音频的通道数
    • 1为单声道,2为立体声。这里必须设置为双声道,不然转码生成的 MP3 会声音尖锐变声
    设置音频质量AVEncoderAudioQualityKey
    • 音频质量越高,文件的大小也就越大
    音频的编码比特率
    • BPS传输速率 一般为128kbps
    NSMutableDictionary *recordSettings = [[NSMutableDictionary alloc] init];
    [recordSettings setValue:[NSNumber numberWithInt:kAudioFormatLinearPCM] forKey:AVFormatIDKey];
    [recordSettings setValue:[NSNumber numberWithInt:self.sampleRate] forKey:AVSampleRateKey];
    [recordSettings setValue:[NSNumber numberWithInt:2] forKey:AVNumberOfChannelsKey];
    [recordSettings setValue:[NSNumber numberWithInt:AVAudioQualityMin] forKey:AVEncoderAudioQualityKey];
    [recordSettings setValue:[NSNumber numberWithInt:128000] forKey:AVEncoderBitRateKey];
    

    3、生成录音文件名称

    a、录音文件名称需要包含的信息
    • 生成的音频文件需包含必要信息,以便上传后系统可关联到对应订单和司机。
    • 可以将信息存储在文件名中,以便上传失败时候仍然能够找到音频文件对应信息。
    • 生成的文件名需包含:订单号____司机ID____开始毫秒时间戳____结束毫秒时间戳____音频文件原始后缀名。
    • 音频文件原始后缀名用于服务器对音频文件进行解密时需要知道该音频文件的原始类型是MP3文件类型,因为可能只后会调整为其他音频文件格式,不止MP3一种。

    需要生成录音文件名称如下

    35200505324217_2890893_1605781625000_1605781629000_MP3.UCAR
    
    b、生成录音文件名称的方法实现
    生成的录音文件名称
    - (NSString *)createRecordFileNameWithOrderNumber:(NSString *)orderNumber driverID:(NSString *)driverID
    {
    
        // 开始录制日期
        NSDate *startRecordDate = [self getCurrentDate];
        
        // 3分钟后录制下一段
        NSTimeInterval duration = self.timeInterval;
        // 结束录制日期
        NSDate *endRecordDate = [startRecordDate initWithTimeIntervalSinceNow: duration];
        
        // 开始录制时间的毫秒时间戳
        NSString *startRecordingTime = [NSString stringWithFormat:@"%ld", (long)[startRecordDate timeIntervalSince1970] * 1000];
        // 结束录制时间的毫秒时间戳
        NSString *endRecordingTime = [NSString stringWithFormat:@"%ld", (long)[endRecordDate timeIntervalSince1970] * 1000];
        
        // 音频文件原始后缀名
        NSString *originalSuffix = self.originalSuffix;
        
        NSString *newRecorderName = [NSString stringWithFormat:@"%@_%@_%@_%@_%@",orderNumber,driverID,startRecordingTime,endRecordingTime,originalSuffix];
        return newRecorderName;
    }
    
    c、结束行程时未满3分钟需要给录音文件重新命名
    • 3分钟指的是录音间隔时间,以秒为单位,默认单个录音文件的开始和结束时间的间隔为3分钟,即每3分钟录制一段音频。
    • 替换行程结束时间为准确的系统当前时间
    - (NSString *)renameEndTripRecordingFileWithFilePath:(NSString *)recorderFilePath
    {
        // 替换行程结束时间为准确的系统当前时间
        NSString *path = recorderFilePath;
        NSString *recordFileName = [path lastPathComponent];
        NSArray *fileComponent = [recordFileName componentsSeparatedByString:@"_"];
        NSString *endRecordTime = fileComponent[3];
        NSDate *endTripDate = [self getCurrentDate];
        NSString *endTripTime = [NSString stringWithFormat:@"%ld", (long)[endTripDate timeIntervalSince1970] * 1000];
        NSString *modifyTimeRecorderFilePath = [path stringByReplacingOccurrencesOfString:endRecordTime withString:endTripTime];
        
        // 在原文件夹给录音文件重新命名
        NSFileManager *fileManager = [NSFileManager defaultManager];
        [fileManager moveItemAtPath:recorderFilePath toPath:modifyTimeRecorderFilePath error:nil];
        
        return modifyTimeRecorderFilePath;
    }
    

    四、录音过程

    1、配置参数

    a、提供的可配置的参数接口
    /** 录音间隔时间,以秒为单位,可配置,默认3分钟 */
    @property(nonatomic,assign) NSTimeInterval timeInterval;
    
    /** 录音文件最大占用内存大小,以MB为单位,可配置,默认1024MB */
    @property(nonatomic,assign) double maximumMemory;
    
    /** 用于加密的key,可配置,默认为一串随机数  */
    @property (nonatomic, copy) NSString *encryptKey;
    
    /** 加密文件的后缀,可配置,默认为UCAR */
    @property (nonatomic, copy) NSString *modifySuffix;
    
    /** 音频文件原始后缀名,可配置,默认为MP3 */
    @property (nonatomic, copy) NSString *originalSuffix;
    
    /** 采样率,可配置,默认为11025 */
    @property (nonatomic, assign) int sampleRate;
    
    /** 比特率,可配置,默认为128kbps */
    @property (nonatomic, assign) int bitRate;
    
    b、设置默认参数
    - (void)defaultParameterConfiguration
    {
        if (!self.maximumMemory)
        {
            self.maximumMemory = 1024;
        }
        
        if (!self.timeInterval)
        {
            self.timeInterval = 180.0;
        }
        
        if (!self.encryptKey)
        {
            self.encryptKey = @"U2FsdGVkX1+21W0Epk68cW2rlAt/TuHcDO4A+UYtbjI=";
        }
        
        if (!self.modifySuffix)
        {
            self.modifySuffix = @"UCAR";
        }
        
        if (!self.originalSuffix)
        {
            self.originalSuffix = @"MP3";
        }
        
        if (!self.sampleRate)
        {
            self.sampleRate = 11025;
        }
        
        if (!self.bitRate)
        {
            self.bitRate = 128;
        }
    }
    
    c、在开始录音的方法中给录音工具类提供参数值
    -(void)startRecordWithOrderNumber:(NSString *)orderNumber driverID:(NSString *)driverID
    {
        NSLog(@"司机到达,开始录音");
    
        // 配置默认参数
        [self defaultParameterConfiguration];
    
        // 录音文件每3分钟保存一个文件,保存成功则录制下一段,时长可配置,以秒为单位
        [UCARAudioTool shareUCARAudioTool].timeInterval = self.timeInterval;
        // 音频文件在司机端本地最多占用1024M存储空间,空间满时自动覆盖生成时间最早的文件,空间上限可配置
        [UCARAudioTool shareUCARAudioTool].maximumMemory = self.maximumMemory;
        
        // 需要订单号 + 司机ID + 当前日期重新生成3分钟录音文件的文件名
        [UCARAudioTool shareUCARAudioTool].orderNumber = orderNumber;
        [UCARAudioTool shareUCARAudioTool].driverID = driverID;
        
        // 加密
        [UCARAudioTool shareUCARAudioTool].encryptKey = self.encryptKey;
        [UCARAudioTool shareUCARAudioTool].modifySuffix = self.modifySuffix;
        
        // 原始文件后缀名
        [UCARAudioTool shareUCARAudioTool].originalSuffix = self.originalSuffix;
        
        // 比特率和采样率
        [UCARAudioTool shareUCARAudioTool].sampleRate = self.sampleRate;
        [UCARAudioTool shareUCARAudioTool].bitRate = self.bitRate * 1000;
    }
    

    2、开始录音

    a、司机到达上车点开始录音

    司机点击【已到达】订单状态变为“已到达”后开始录音,但是以后可能改为从“已出发”就开始录音,为了便于调整录音节点,建议实现不要根据司机端按钮操作去判断是否开启录音,而是根据订单状态的变化来判断是否开启录音。

    -(void)startRecordWithOrderNumber:(NSString *)orderNumber driverID:(NSString *)driverID
    {
        NSLog(@"司机到达,开始录音");
    
        // 配置参数
        ......
        
        // 开始录音,以caf作为录音原始文件后缀
        NSString *newRecorderName = [[UCARAudioTool shareUCARAudioTool] createRecordFileNameWithOrderNumber:orderNumber driverID:driverID];
        [[UCARAudioTool shareUCARAudioTool] beginRecordWithRecordName:newRecorderName withRecordType:@"caf" withIsConventToMp3:YES];
    }
    
    b、判断当前订单录音状态

    判断当前是否正在录音,正在录制则直接返回。

    - (void)beginRecordWithRecordName:(NSString *)recordName withRecordType:(NSString *)type withIsConventToMp3:(BOOL)isConventToMp3
    {
        __weak __typeof(self) weakSelf = self;
        
        // 正在录制则直接返回
        if ([self carIsRecording])
        {
            return;
        }
    .......
    }
    

    根据系统录音器提供的方法进行判断录音状态,不能根据订单是否到达的状态来进行判断,因为订单可能存在中断情况,以及结束行程后会有5分钟的缓冲录音时间,在缓冲时间段中,订单状态为已经完成,但仍然在录音。

    - (BOOL)carIsRecording
    {
        if (_audioRecorder && _audioRecorder.isRecording)
        {
            return YES;
        }
        
        return NO;
    }
    

    必须进行这样的判断,因为在开启下一个订单之前,需要判断上一个订单是否已经结束录音。因为不是订单状态变更为已经结束则结束录音,而存在5分钟的缓存录音时间,所以存在下一个订单在5分钟的缓存时间内就来到的情况。在这样的情况下,需要判断两种情况,一种是用于开启录音的订单号和当前正在录音的订单号相同,则直接返回即可。另外一种是用于开启录音的订单号和当前正在录音的订单号不相同,则立即停止录音,并销毁延时计时器,开启下一个订单的录音。

    - (void)startRecordWithOrderNumber:(NSString *)orderNumber driverID:(NSString *)driverID
    {
        // 正在录音
        if ([[UCARRecordSoundTool shareUCARRecordSoundTool] carIsRecording])
        {
            // 正在录制的订单号
            NSString *exitNumber = [UCARRecordSoundTool shareUCARRecordSoundTool].recordingOrderNumber;
            // 该订单号正在录制中
            if ([exitNumber isEqualToString:orderNumber])
            {
                NSLog(@"该订单号正在录制中 %@,不做处理", orderNumber);
                return;
            }
        }
        [self stopRecordAudio];
    .......
    }
    
    c、配置录音文件的存在路径

    需要在录音文件的名称中添加recording标识表示该路径下的这个录音文件正处于录音状态中,以此区分APP沙盒中录音文件夹下的正在录音的文件和已经完成录音的文件。这样区分的目的是防止5分钟自动扫描上传会扫描掉正在录音中的文件将其进行上传,导致APP崩溃。5分钟自动扫描上传是因为录音中断或者文件转化/加密失败时,那些被中断录音的文件或者转化加密失败的文件会停留在沙盒中,需要及时上传。

    添加recording标识
    - (void)beginRecordWithRecordName:(NSString *)recordName withRecordType:(NSString *)type withIsConventToMp3:(BOOL)isConventToMp3
    {
        __weak __typeof(self) weakSelf = self;
    
        // 1. 检查授权状态
        [self checkMicrophoneAuthorization:^{
            
            // 初始化行程状态为未结束
            weakSelf.isEndTrip = NO;
    
            weakSelf.recordType = type;
            weakSelf.isConventMp3 = isConventToMp3;
            
            // 2. 录音的名字中已经包含录音的类型后缀则不再添加后缀
            if ([recordName containsString:[NSString stringWithFormat:@".%@",weakSelf.recordType]])
            {
                weakSelf.audioFileName = recordName;
            }
            else
            {
                weakSelf.audioFileName = [NSString stringWithFormat:@"%@.%@",recordName,weakSelf.recordType];
            }
            
            // 3. 创建录音文件存放路径
            if (![UCARAudioFilePathTool judgeFileOrFolderExists:cachesRecorderPath])
            {
                // 不存在则创建 /Library/Caches/Recorder 文件夹
                [UCARAudioFilePathTool createFolder:cachesRecorderPath];
            }
            // 给录制中的文件添加recording标识以区分录制完成的文件
            weakSelf.audioFileName = [NSString stringWithFormat:@"%@_%@",@"recording",weakSelf.audioFileName];
            weakSelf.recordPath = [cachesRecorderPath stringByAppendingPathComponent:weakSelf.audioFileName];
    
        } withNoPermission:^(BOOL error) {
            .......
        }];
    }
    
    d、开始录音

    自动调用录音器的懒加载方法创建录音器开始录音。

    - (void)beginRecordWithRecordName:(NSString *)recordName withRecordType:(NSString *)type withIsConventToMp3:(BOOL)isConventToMp3
    {
        // 1. 检查授权状态
        [self checkMicrophoneAuthorization:^{
            .......
            // 4. 准备录音
            // prepareToRecord方法根据URL创建文件,并且执行底层Audio Queue初始化的必要过程,将录制启动时的延迟降到最低
            if ([self.audioRecorder prepareToRecord])
            {
                // 开始录音
                // 首次使用应用时如果调用record方法会询问用户是否允许使用麦克风
                [self.audioRecorder record];
                ......
            }
        } withNoPermission:^(BOOL error) {
            .......
        }];
    }
    
    e、创建新的计时器时刻监测录制时长

    需要单独创建一个队列跑定时器,因为将录音原始caf文件转化为mp3文件的过程是边录音边转化,转化过程是在一个异步线程里面进行的,所以会导致处于主线程中的定时器NSTimer停止工作,必须要通过dispatch_source_t的方式来创建定时器,让其拥有自己的独立线程不被转化工作所影响。

    边录音边转化的原因是因为每3分钟录制一段音频,上段录制完成后立即开始录制下一段,假如使用同步的方式当上一段音频录制完成后再进行转化则会导致录制下一段音频的时候会因为转化过程浪费了一些时间,导致延迟了几秒后再开始录制。

    // 计时器
    @property (nonatomic, strong) dispatch_source_t timer;
    
    - (void)beginRecordWithRecordName:(NSString *)recordName withRecordType:(NSString *)type withIsConventToMp3:(BOOL)isConventToMp3
    {
        // 1. 检查授权状态
        [self checkMicrophoneAuthorization:^{
            .......
            // 4. 准备录音
            // prepareToRecord方法根据URL创建文件,并且执行底层Audio Queue初始化的必要过程,将录制启动时的延迟降到最低
            if ([self.audioRecorder prepareToRecord])
            {
                ......
                // 销毁之前的计时器
                // dispatch_cancel(self.timer);
                self.timer = nil;
                
                // 创建新的计时器时刻监测录制时长
                // 获取队列,这里获取全局队列(tips:可以单独创建一个队列跑定时器)
                dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
                // 创建定时器(dispatch_source_t本质还是个OC对象)
                self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
                // start参数控制计时器第一次触发的时刻,延迟0s
                dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, 0 * NSEC_PER_SEC);
                // 每隔0.33 s执行一次
                uint64_t interval = (uint64_t)(0.33 * NSEC_PER_SEC);
                dispatch_source_set_timer(self.timer, start, interval, 0);
                dispatch_source_set_event_handler(self.timer, ^{
                    [self autoEndRecordingWithTime];
                });
                // 开始执行定时器
                dispatch_resume(self.timer);
                ......
            }
        } withNoPermission:^(BOOL error) {
            .......
        }];
    }
    

    在计时器重复调用的方法中,通过判断当前录音时长和3分钟的录音间隔时间的大小,来进行3分钟的循环录音。结束录音的方法中包含了循环的处理,之后说明。

    - (void)autoEndRecordingWithTime
    {
        // 刷新音量数据
        [self.audioRecorder updateMeters];
        
        // 当前录音时长
        NSLog(@"当前录音时长:%f",self.audioRecorder.currentTime);
        
        // 录音文件每3分钟保存一个文件,保存成功则录制下一段,时长可配置
        if (self.audioRecorder.currentTime >= self.timeInterval)
        {
            // 结束录音
            [self endRecord];
        }
    }
    
    f、将原始录音文件边录制边转化为MP3文件
    2020-11-20 11:30:33.986396+0800 Demo[43609:1339682] 转 MP3 成功
    2020-11-20 11:30:33.986493+0800 Demo[43609:1339682] 转为MP3后的路径 = /Users/xiejiapei/Library/Developer/CoreSimulator/Devices/B3334C09-878A-4178-A14F-242CCCFC8FF7/data/Containers/Data/Application/A1AABF69-761A-45B1-A295-0A54DC3C5639/Library/Caches/Recorder/recording_35200505324217_2890893_1605843032000_1605843037000_MP3.mp3
    

    如果原始录音文件转化为mp3文件失败,则需要删除给录制中的文件添加的.recording后缀使其变成录制完成的文件,之后进行5分钟扫描的时候再对其进行转化上传。

    - (void)beginRecordWithRecordName:(NSString *)recordName withRecordType:(NSString *)type withIsConventToMp3:(BOOL)isConventToMp3
    {
        // 1. 检查授权状态
        [self checkMicrophoneAuthorization:^{
            .......
            // 4. 准备录音
            // prepareToRecord方法根据URL创建文件,并且执行底层Audio Queue初始化的必要过程,将录制启动时的延迟降到最低
            if ([self.audioRecorder prepareToRecord])
            {
                ......
                // 判断是否需要边录边转 MP3
                if (isConventToMp3)
                {
                    // 采样率
                    [UCARLameTool shareUCARLameTool].sampleRate = self.sampleRate;
                    
                    [[UCARLameTool shareUCARLameTool] audioRecodingToMP3:weakSelf.recordPath isDeleteSourchFile:YES withSuccessBack:^(NSString * _Nonnull resultPath) {
                        NSLog(@"转 MP3 成功");
                        NSLog(@"转为MP3后的路径 = %@",resultPath);
                        
                        [self successConvertToMP3WithFilePath:resultPath];
                    } withFailBack:^(NSString * _Nonnull error) {
                        NSLog(@"转 MP3 失败");
                        
                        // 删除给录制中的文件添加的.recording后缀变成录制完成的文件
                        NSString *failCafFilePath = weakSelf.recordPath;
                        if ([weakSelf.recordPath containsString:@"recording"])
                        {
                            failCafFilePath = [self deleteRecordingTagWithFilePath:weakSelf.recordPath];
                        }
                        
                        [self failConvertToMP3WithFilePath:failCafFilePath];
                    }];
                }
            }
        } withNoPermission:^(BOOL error) {
            .......
        }];
    }
    

    删除录制中的文件的.recording表示该文件已经录制完成。方法实现只是更改了一下文件名称。

    - (NSString *)deleteRecordingTagWithFilePath:(NSString *)recorderFilePath
    {
        NSString *recordFileName = [recorderFilePath lastPathComponent];
        NSString *recordingTag = [recordFileName substringWithRange:NSMakeRange(0, 10)];
        NSString *deleteRecordingTagFilePath = [recorderFilePath stringByReplacingOccurrencesOfString:recordingTag withString:@""];
        
        NSFileManager *fileManager = [NSFileManager defaultManager];
        [fileManager moveItemAtPath:recorderFilePath toPath:deleteRecordingTagFilePath error:nil];
        
        return deleteRecordingTagFilePath;
    }
    

    3、循环录音

    未结束行程则重新录音

    if (!self.isEndTrip)
    {
        [self restartRecord];
    }
    

    重新录音时候订单号和司机ID没有更改,但是开始和结束时间都发生了更改,所以文件名称也发生了改变。

    - (void)restartRecord
    {
        // 开始录音,以caf作为录音原始文件后缀
        NSString *newRecorderName = [self createRecordFileNameWithOrderNumber:self.orderNumber driverID:self.driverID];
        
        // 重新开始录音
        [self beginRecordWithRecordName:newRecorderName withRecordType:@"caf" withIsConventToMp3:YES];
    }
    

    3、结束录音

    a、抵达目的地,行程结束
    -(void)endTrip
    {
        NSLog(@"抵达目的地,行程结束");
        
        // 结束行程时,把当前正在录制的音频保存,可能不足3分钟
        [UCARAudioTool shareUCARAudioTool].isEndTrip = YES;
        [[UCARAudioTool shareUCARAudioTool] endRecord];
    }
    
    b、结束录音时需要销毁定时器和录音器

    当录音器self.audioRecorder调用stop方法停止录音的时候,系统会自动调用结束录音的委托方法audioRecorderDidFinishRecording:,但是如果是系统中断录音,则不会调用这个方法。

    - (void)endRecord
    {
        // 销毁计时器
        if (self.timer)
        {
            dispatch_cancel(self.timer);
            self.timer = nil;
        }
        
        if (_audioRecorder)
        {
            // 停止录音
            [self.audioRecorder stop];
    
            // 销毁录音器
            self.audioRecorder = nil;
        }
    }
    
    c、自动调用结束录音的委托方法
    2020-11-20 11:30:32.125215+0800 Demo[43609:1292692] 录音文件地址:/Users/xiejiapei/Library/Developer/CoreSimulator/Devices/B3334C09-878A-4178-A14F-242CCCFC8FF7/data/Containers/Data/Application/A1AABF69-761A-45B1-A295-0A54DC3C5639/Library/Caches/Recorder/recording_35200505324217_2890893_1605843026000_1605843031000_MP3.caf
    2020-11-20 11:30:32.125480+0800 Demo[43609:1292692] 录音文件大小:237388
    

    在委托方法中向LameTool转化MP3的工具类发送了结束录音的通知,即调用了sendEndRecord方法。

    - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag
    {
        
        if (flag)// 录音正常结束
        {
            NSLog(@"录音文件地址:%@",recorder.url.path) ;
            NSLog(@"录音文件大小:%@",[[NSFileManager defaultManager] attributesOfItemAtPath:recorder.url.path error:nil][NSFileSize]) ;
              
            // 判断是否需要转 MP3
            if (self.isConventMp3)
            {
                [[UCARLameTool shareUCARLameTool] sendEndRecord];
            }
        }
        else// 未正常结束
        {
            if ([recorder deleteRecording])// 录音文件删除成功
            {
                NSLog(@"录音文件删除成功");
            }
            else// 录音文件删除失败
            {
                NSLog(@"录音文件删除失败");
            }
        }
    
        NSLog(@"录音结束");
    }
    

    sendEndRecord方法在录音完成的时候调用,只做了一件事,将判断录音状态的属性设置为YES

    - (void)sendEndRecord
    {
        self.stopRecord = YES;
    }
    

    因为边录音边转化为MP3的过程中需要在录制结束后发送一个信号, 让do while跳出循环,所以才有了sendEndRecord方法。

    - (void)audioRecodingToMP3:(NSString *)sourcePath isDeleteSourchFile:(BOOL)isDelete withSuccessBack:(void (^)(NSString * _Nonnull))success withFailBack:(void (^)(NSString * _Nonnull))fail
    {
        NSLog(@"转换开始!!");
    
        // 边录边转码,只是在可以录制后,重新开一个线程来进行文件的转码
        __weak typeof(self) weakself = self;
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            ........
            // 录音完成的调用,这里表示尚未完成
            // 需要在录制结束后发送一个信号, 让 do while 跳出循环
            weakself.stopRecord = NO;
            ........
            @try {
    
                do {
                      ........
                } while (!weakself.stopRecord);// 在while的条件中,当收到录音结束的判断,则会结束 do while 的循环
    
                ........
                // 释放
                lame_close(lame);
                fclose(mp3);
                fclose(pcm);
                
            } @catch (NSException *exception) {
                if (fail)
                {
                    fail([exception description]);
                }
            } @finally {
                ........
                NSLog(@"转换结束!!");
            }
        });
    }
    

    五、转化为MP3格式

    2020-11-20 14:22:12.536704+0800 Demo[43609:1409708] 转换开始!!
    2020-11-20 14:22:12.536787+0800 Demo[43609:1444842] 当前录音时长:0.004626
    ......
    2020-11-20 11:30:33.986302+0800 Demo[43609:1339682] 删除源文件成功
    2020-11-20 11:30:34.001979+0800 Demo[43609:1339682] 转换结束!!
    

    1、LameTool工具类提供的接口方法

    /** 采样率,可配置,默认为11025.0 */
    @property (nonatomic, assign) int sampleRate;
    
    /** 录音完成的调用 */
    - (void)sendEndRecord;
    
    /**caf 转 mp3 :录音完成后根据用户需要去调用转码
     * @param sourcePath 需要转mp3的caf路径
     * @param isDelete 是否删除原来的caf文件,YES:删除、NO:不删除
     * @param success 成功的回调
     * @param fail 失败的回调
     */
    - (void)audioToMP3:(NSString *)sourcePath isDeleteSourchFile: (BOOL)isDelete withSuccessBack:(void(^)(NSString *resultPath))success withFailBack:(void(^)(NSString *error))fail;
    
    /**caf 转 mp3 : 录音的同时转码
     * @param sourcePath 需要转mp3的caf路径
     * @param isDelete 是否删除原来的caf文件,YES:删除、NO:不删除
     * @param success 成功的回调
     * @param fail 失败的回调
     */
    - (void)audioRecodingToMP3:(NSString *)sourcePath isDeleteSourchFile: (BOOL)isDelete withSuccessBack:(void(^)(NSString *resultPath))success withFailBack:(void(^)(NSString *error))fail;
    

    2、使用到LameTool工具类的地方

    a、录音的同时转码

    在行驶过程中需要进行录音文件的实时转化。边录音边转化的原因是因为每3分钟录制一段音频,上段录制完成后立即开始录制下一段,假如使用同步的方式当上一段音频录制完成后再进行转化则会导致录制下一段音频的时候会因为转化过程浪费了一些时间,导致延迟了几秒后再开始录制。

    - (void)beginRecordWithRecordName:(NSString *)recordName withRecordType:(NSString *)type withIsConventToMp3:(BOOL)isConventToMp3
    {
    .......
                if (isConventToMp3)
                {
                    // 采样率
                    [UCARLameTool shareUCARLameTool].sampleRate = self.sampleRate;
                    
                    [[UCARLameTool shareUCARLameTool] audioRecodingToMP3:weakSelf.recordPath isDeleteSourchFile:YES withSuccessBack:^(NSString * _Nonnull resultPath) {
                        NSLog(@"转 MP3 成功");
                        NSLog(@"转为MP3后的路径 = %@",resultPath);
                        
                        [self successConvertToMP3WithFilePath:resultPath];
                    } withFailBack:^(NSString * _Nonnull error) {
                        NSLog(@"转 MP3 失败");
                    }];
                }
    }
    
    b、录音完成后根据用户需要去调用转码

    用于将因中断等原因未自动转换成功的caf文件进行转化为mp3文件。因为这些caf文件已经录制完成了,所以不能进行实时的转化,只能进行一次性的转化。

    - (NSArray *)convertAudioToUCARWithEncryptKey:(NSString *)encryptKey modifySuffix:(NSString *)modifySuffix sampleRate:(int)sampleRate
    {
    .......
                // 采样率
                [UCARLameTool shareUCARLameTool].sampleRate = sampleRate;
                
                // 转为MP3
                [[UCARLameTool shareUCARLameTool] audioToMP3:cafRecordFilePath isDeleteSourchFile:YES withSuccessBack:^(NSString * _Nonnull resultPath) {
                    NSLog(@"转为MP3后的路径 = %@",resultPath);
                    
                    // 将mp3文件进行加密
                    NSString *encryptedRecorderDataWithFilePath = [self encryptedRecorderDataWithFilePath:resultPath encryptKey:encryptKey modifySuffix:modifySuffix];
                    if (encryptedRecorderDataWithFilePath && ![encryptedRecorderDataWithFilePath isEqualToString:@""])
                    {
                        [UCARAudioPathList addObject:encryptedRecorderDataWithFilePath];
                    }
                    
                } withFailBack:^(NSString * _Nonnull error) {
                    
                    NSLog(@"将caf文件转换为mp3文件失败:%@",error);
                }];
    }
    

    3、配置路径

    需要注意fopen 打开文件的模式,下面是扩展的 C 语言的文件打开模式(mode)说明。为什么要说这些?比如笔者使用 wb 来打开 mp3,就意味着只允许笔者写数据, 而如果有对文件的读取操作,那么将会出现错误, 这也是笔者被坑过的地方。

    //加入b 字符用来告诉函数库打开的文件为二进制文件,而非纯文字文件。
    
    w+ //以纯文本方式读写,而wb+是以二进制方式进行读写。
    w  //打开只写文件,若文件存在则文件长度清为0,即该文件内容会消失。若文件不存在则建立该文件。
    w+ //打开可读写文件,若文件存在则文件长度清为零,即该文件内容会消失。若文件不存在则建立该文件。
    wb //只写方式打开或新建一个二进制文件,只允许写数据。
    wb+ //读写方式打开或建立一个二进制文件,允许读和写。
    
    r //打开只读文件,该文件必须存在,否则报错。
    r+ //打开可读写的文件,该文件必须存在,否则报错。
    rb+ //读写方式打开一个二进制文件,只允许读写数据。
    
    a //以附加的方式打开只写文件。若文件不存在,则会建立该文件,如果文件存在,写入的数据会被加到文件尾,即文件原先的内容会被保留。(EOF符保留)
    a+ //以附加方式打开可读写的文件。若文件不存在,则会建立该文件,如果文件存在,写入的数据会被加到文件尾后,即文件原先的内容会被保留。 (原来的EOF符不保留)
    ab+ //读写打开一个二进制文件,允许读或在文件末追加数据。
    

    确定caf文件的输入路径和mp3文件的输出路径。

    - (void)audioRecodingToMP3:(NSString *)sourcePath isDeleteSourchFile:(BOOL)isDelete withSuccessBack:(void (^)(NSString * _Nonnull))success withFailBack:(void (^)(NSString * _Nonnull))fail
    {
        NSLog(@"转换开始!!");
        
        // 1. 输入路径
        NSString *inPath = sourcePath;
        
        // 判断输入路径是否存在
        NSFileManager *fileManager = [NSFileManager defaultManager];
        if (![fileManager fileExistsAtPath:sourcePath])
        {
            if (fail)
            {
                fail(@"文件不存在");
            }
            return;
        }
        
        // 2. 输出路径
        NSString *outPath = [[sourcePath stringByDeletingPathExtension] stringByAppendingString:@".mp3"];
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                // source 被转换的音频文件位置
                // 打开只读二进制文件,该文件必须存在,否则报错
                FILE *pcm = fopen([inPath cStringUsingEncoding:NSASCIIStringEncoding], "rb");
                // output 输出生成的Mp3文件位置
                // 写方式打开或建立一个二进制文件,允许读和写
                FILE *mp3 = fopen([outPath cStringUsingEncoding:NSASCIIStringEncoding], "wb+");
                .......
        });
    }
    

    4、配置转码的文件属性

    a、进行配置

    需要注意的是lame的配置要跟AVAudioRecorder的配置一致,否则会造成转换不成功。

    - (void)audioRecodingToMP3:(NSString *)sourcePath isDeleteSourchFile:(BOOL)isDelete withSuccessBack:(void (^)(NSString * _Nonnull))success withFailBack:(void (^)(NSString * _Nonnull))fail
    {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                const int PCM_SIZE = 8192;
                const int MP3_SIZE = 8192;
                short int pcm_buffer[PCM_SIZE * 2];
                unsigned char mp3_buffer[MP3_SIZE];
    
                // 初始化
                lame_t lame = lame_init();
                // 设置1为单通道,默认为2双通道,设置单声道会更大程度减少压缩后文件的体积,但是会造成MP3声音尖锐变声
                lame_set_num_channels(lame,2);
                
                // lame_set_quality(lame,2);// 设置转码质量高
                // lame_set_brate(lame, 128);// 设置码率
                // lame_set_VBR_mean_bitrate_kbps(lame, 128);
                lame_set_in_samplerate(lame, self.sampleRate);// 设置采样率
                lame_set_VBR(lame,vbr_mtrh);// 设置码率为变量
                lame_init_params(lame);
                .......
        });
    }
    
    b、查看配置属性

    可以通过以下工具查看码率和采样率。

    查看音频文件属性的工具 查看码率和采样率
    c、配置采样率

    采样率会影响录制的音频文件的时长,也就是说不同的采样率会导致录制的音频文件时长可能少于或者多于3分钟, 要么是多几秒, 要么是少几秒, 还可能是超过10s的的误差,其中多于3分钟的文件3分钟以后的音频部分并不能播放。这个是坑点之一,当时我也是折腾了好久才发现了这个问题。11,025 Hz是经过我测试可以准确录制3分钟的采样率,如果没有必要,最好不要去随意配置这个数值。

    采样频率定义了每秒从连续信号中提取并组成离散信号的采样个数,采样频率的倒数是采样周期或者叫作采样时间,它是采样之间的时间间隔。

    • 8,000 Hz - 电话所用采样率, 对于人的说话已经足够
    • 11,025 Hz
    • 22,050 Hz - 无线电广播所用采样率
    • 32,000 Hz - miniDV 数码视频 camcorder、DAT (LP mode)所用采样率
    • 44,100 Hz - 音频CD, 也常用于MPEG-1 音频(VCD, SVCD, MP3)所用采样率

    MP3一帧可以解析出的音频时长
    mp3 每帧均为1152个字节, 则:frame_duration = 1152 * 1000000 / sample_rate
    sample_rate = 44100HZ时, 计算出的时长为26.122ms,这就是经常听到的mp3每帧播放时间固定为26ms的由来。

    d、配置码率
    动态比特率

    VBRVariable Bitrate)动态比特率。也就是没有固定的比特率,压缩软件在压缩时根据音频数据即时确定使用什么比特率。使用lame_set_VBR系统会将码率设置为变量,也就是说无论我们怎么更改码率的值都不会按照预期的效果产生相应的码率。不过系统会自动进行最优化的码率处理。

    你也许想通过lame_set_brate(lame, 128)设置码率,但是经过我的尝试根本不会起作用。也尝试过其他各种方案,下载过别人的项目,经过千方百计的探寻,终于发现.......什么鬼,这就是个Bug,根本不能生效。所以最好不要设置为定值,而是让系统自动去适配。

    通过码率计算时长

    MP3编码格式使用的是动态码率方式,而这种方式每一帧的长度应该是不等的。那会不会是 AVPlayer 是把文件当做每帧相等的方式来计算的总时间,所以才不对?不断输出AVPlayer duration来看,每次都会有不同的结果,而 AVPlayer 是支持Mp3 VBR格式文件播放的。所以应该还是我们的生成的文件有问题。了解到 MP3 VBR 头这个东西,有它记录了整个文件的帧总数量,就能直接算出duration,所以是不是我们Lame编码的时候,没有写入 VBR 头呢?

    搜索 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(..)函数。这不就是用来每次灌bufferlameMP3编码的方法嘛!也就是说每次都会给给帧添加VBR信息,这和之前看的编码资料描述的一样。

    接下来就是需要找到写入VBR头的函数,搜索源码可得 PutLameVBR()被调用在lame_get_lametag_frame()函数里,然后我们来看看这个函数:

    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我在网上搜索的各种配置中发现都没有写。


    5、转码过程

    a、边录边转码

    通常我们是在录制结束之后再进行转码,但当录制的时间较长 ,会消耗的时间比较长,用户需要等待转码结束后才能操作。所以如果我们使用边录制边转码的方式,开启另外一个线程同时进行转码,则几乎没有等待的时间。

    - (void)audioRecodingToMP3:(NSString *)sourcePath isDeleteSourchFile:(BOOL)isDelete withSuccessBack:(void (^)(NSString * _Nonnull))success withFailBack:(void (^)(NSString * _Nonnull))fail
    {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        });
    }
    
    b、转码的循环
    • 当录音进行中时,会持续读取到指定大小文件,进行编码, 读取不到,则线程休眠。
    • 在录音没有完成前,循环读取PCM文件,当读取到的字节大于我们规定的一个单位后,我们将这些字节交给lamelame会把转码后的二进制数据输出到目标MP3文件里
    • 从文件流每次读取一定数量buffer转码MP3写入,直到全部读取完文件流
    - (void)audioRecodingToMP3:(NSString *)sourcePath isDeleteSourchFile:(BOOL)isDelete withSuccessBack:(void (^)(NSString * _Nonnull))success withFailBack:(void (^)(NSString * _Nonnull))fail
    {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                long curpos;
                BOOL isSkipPCMHeader = NO;
                
                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);
                    
                    // 当录音进行中时, 会持续读取到指定大小文件,进行编码, 读取不到,则线程休眠
                    // 在录音没有完成前,循环读取PCM文件,当读取到的字节大于我们规定的一个单位后,我们将这些字节交给lame,lame会把转码后的二进制数据输出到目标MP3文件里
                    if (length > PCM_SIZE * 2 * sizeof(short int))
                    {
                        
                        if (!isSkipPCMHeader)
                        {
                            // PCM数据头有四个字节的头信息,skip file header 跳过 PCM header 能保证录音的开头没有噪音
                            // 如果不跳过这一部分,转换成的mp3在播放的最初一秒内会听到一个明显的噪音
                            fseek(pcm, 4 * 1024, SEEK_CUR);
                            isSkipPCMHeader = YES;
                            NSLog(@"skip pcm file header !!!!!!!!!!,跳过 PCM header 能保证录音的开头没有噪音 ");
                        }
                        
                        // 从文件流每次读取一定数量buffer转码MP3写入,直到全部读取完文件流
                        // 将文件读进内存
                        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 的循环
    
                // 从文件流每次读取一定数量buffer转码MP3写入,直到全部读取完文件流
                // 从文件流每次读取两个字节的数据,依次存入buffer,demo处理的是16位PCM数据,所以左右声道各占两个字节
                read = (int)fread(pcm_buffer, 2 * sizeof(short int), PCM_SIZE, pcm);
                write = lame_encode_flush(lame, mp3_buffer, MP3_SIZE);
                
                NSLog(@"read %d bytes and flush to mp3 file", write);
                // 写入Mp3 VBR Tag,可解决获取时长不准的问题
                lame_mp3_tags_fid(lame, mp3);
                
                // 释放
                lame_close(lame);
                fclose(mp3);
                fclose(pcm);
        });
    }
    

    6、转码完成后的处理

    删除掉原始的caf文件,只保留转化后的mp3文件。

    - (void)audioRecodingToMP3:(NSString *)sourcePath isDeleteSourchFile:(BOOL)isDelete withSuccessBack:(void (^)(NSString * _Nonnull))success withFailBack:(void (^)(NSString * _Nonnull))fail
    {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            do {
                ......
            } @catch (NSException *exception) {
                if (fail)
                {
                    fail([exception description]);
                }
            } @finally {
                if (isDelete)
                {
                    NSError *error;
                    [fileManager removeItemAtPath:sourcePath error:&error];
                    if (error == nil)
                    {
                        NSLog(@"删除源文件成功");
                    }
                }
                
                if (success)
                {
                    success(outPath);
                }
                
                NSLog(@"转换结束!!");
            }
        });
    }
    

    六、录音文件加密

    续文见下篇:IOS企业:司机端APP行驶全程录音功能(上)


    Demo

    Demo在我的Github上,欢迎下载。
    EnterpriseDemo

    参考文献

    相关文章

      网友评论

        本文标题:IOS企业:司机端APP行驶全程录音功能(上)

        本文链接:https://www.haomeiwen.com/subject/ufxluktx.html