// // KBVoiceRecordManager.m // #import "KBVoiceRecordManager.h" #import "KBConfig.h" #import "KBVoiceBridgeNotification.h" #import "KBHUD.h" #import static void KBVoiceBridgeDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo); @interface KBVoiceRecordManager () @property (nonatomic, strong) AVAudioRecorder *recorder; @property (nonatomic, strong) NSURL *recordURL; @property (nonatomic, assign, readwrite, getter=isRecording) BOOL recording; @property (nonatomic, assign) BOOL pendingStart; @end @implementation KBVoiceRecordManager + (instancetype)shared { static KBVoiceRecordManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBVoiceRecordManager new]; }); return m; } - (instancetype)init { if (self = [super init]) { CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), KBVoiceBridgeDarwinCallback, (__bridge CFStringRef)KBDarwinVoiceStartRequest, NULL, CFNotificationSuspensionBehaviorDeliverImmediately); CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), KBVoiceBridgeDarwinCallback, (__bridge CFStringRef)KBDarwinVoiceStopRequest, NULL, CFNotificationSuspensionBehaviorDeliverImmediately); } return self; } - (void)dealloc { CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinVoiceStartRequest, NULL); CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinVoiceStopRequest, NULL); } static void KBVoiceBridgeDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { KBVoiceRecordManager *self = (__bridge KBVoiceRecordManager *)observer; if (!self) { return; } NSString *n = (__bridge NSString *)name; NSLog(@"[KBVoiceBridge][App] Darwin received: %@", n); dispatch_async(dispatch_get_main_queue(), ^{ if ([n isEqualToString:KBDarwinVoiceStartRequest]) { [self startRecording]; } else if ([n isEqualToString:KBDarwinVoiceStopRequest]) { [self stopRecording]; } }); } #pragma mark - Public - (void)startRecording { if (self.isRecording) { NSLog(@"[KBVoiceBridge][App] startRecording already recording, stop then restart"); [self stopRecording]; // return; } NSLog(@"[KBVoiceBridge][App] startRecording begin"); [self kb_clearSharedState]; AVAudioSession *session = [AVAudioSession sharedInstance]; AVAudioSessionRecordPermission permission = session.recordPermission; if (permission == AVAudioSessionRecordPermissionDenied) { NSLog(@"[KBVoiceBridge][App] recordPermission denied"); [self kb_postFailed:KBLocalized(@"麦克风权限未开启")]; return; } if (permission == AVAudioSessionRecordPermissionUndetermined) { NSLog(@"[KBVoiceBridge][App] recordPermission undetermined, requesting"); self.pendingStart = YES; __weak typeof(self) weakSelf = self; [session requestRecordPermission:^(BOOL granted) { dispatch_async(dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) self = weakSelf; if (!self) { return; } if (!self.pendingStart) { return; } self.pendingStart = NO; if (!granted) { NSLog(@"[KBVoiceBridge][App] recordPermission request denied"); [self kb_postFailed:KBLocalized(@"麦克风权限未开启")]; return; } NSLog(@"[KBVoiceBridge][App] recordPermission request granted"); [self startRecording]; }); }]; return; } NSError *error = nil; if (@available(iOS 10.0, *)) { [session setCategory:AVAudioSessionCategoryPlayAndRecord mode:AVAudioSessionModeDefault options:AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetooth error:&error]; } else { [session setCategory:AVAudioSessionCategoryPlayAndRecord error:&error]; } if (error) { NSLog(@"[KBVoiceBridge][App] setCategory error: %@", error.localizedDescription); [self kb_postFailed:KBLocalized(@"麦克风初始化失败")]; return; } [session setActive:YES error:&error]; if (error) { NSLog(@"[KBVoiceBridge][App] setActive error: %@", error.localizedDescription); [self kb_postFailed:error.localizedDescription ?: KBLocalized(@"麦克风启动失败")]; return; } if (!session.isInputAvailable) { NSLog(@"[KBVoiceBridge][App] input not available"); [self kb_postFailed:KBLocalized(@"麦克风不可用")]; return; } NSURL *aacURL = [self kb_voiceFileURLWithExtension:@"m4a"]; if (!aacURL) { NSLog(@"[KBVoiceBridge][App] app group not configured"); [self kb_postFailed:KBLocalized(@"App Group 未配置,无法共享录音")]; return; } NSDictionary *aacSettings = [self kb_voiceRecordSettingsAAC]; if ([self kb_tryStartRecorderWithSettings:aacSettings fileURL:aacURL error:&error]) { NSLog(@"[KBVoiceBridge][App] recorder started (aac) url=%@", aacURL); self.recordURL = aacURL; self.recording = YES; return; } NSURL *pcmURL = [self kb_voiceFileURLWithExtension:@"caf"]; if (!pcmURL) { NSLog(@"[KBVoiceBridge][App] app group not configured"); [self kb_postFailed:KBLocalized(@"App Group 未配置,无法共享录音")]; return; } NSDictionary *pcmSettings = [self kb_voiceRecordSettingsPCM]; error = nil; if ([self kb_tryStartRecorderWithSettings:pcmSettings fileURL:pcmURL error:&error]) { NSLog(@"[KBVoiceBridge][App] recorder started (pcm) url=%@", pcmURL); self.recordURL = pcmURL; self.recording = YES; return; } NSLog(@"[KBVoiceBridge][App] recorder start failed: %@", error.localizedDescription); NSString *tip = error.localizedDescription ?: KBLocalized(@"录音启动失败,可能是系统限制或宿主 App 不允许录音"); [self kb_postFailed:tip]; } - (void)stopRecording { self.pendingStart = NO; NSLog(@"[KBVoiceBridge][App] stopRecording"); if (self.recorder.isRecording) { [self.recorder stop]; } } #pragma mark - AVAudioRecorderDelegate - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag { [[AVAudioSession sharedInstance] setActive:NO error:nil]; NSLog(@"[KBVoiceBridge][App] finishRecording flag=%d url=%@", flag, recorder.url); NSURL *fileURL = recorder.url ?: self.recordURL; self.recorder = nil; self.recordURL = nil; self.recording = NO; if (!flag || !fileURL) { [self kb_postFailed:KBLocalized(@"录音失败")]; return; } [self kb_saveSharedFileURL:fileURL]; [self kb_postDarwin:KBDarwinVoiceReady]; } - (void)audioRecorderEncodeErrorDidOccur:(AVAudioRecorder *)recorder error:(NSError *)error { [[AVAudioSession sharedInstance] setActive:NO error:nil]; NSLog(@"[KBVoiceBridge][App] encodeError: %@", error.localizedDescription); self.recorder = nil; self.recordURL = nil; self.recording = NO; [self kb_postFailed:error.localizedDescription ?: KBLocalized(@"录音失败")]; } #pragma mark - Helpers - (NSDictionary *)kb_voiceRecordSettingsAAC { return @{AVFormatIDKey: @(kAudioFormatMPEG4AAC), AVSampleRateKey: @(16000), AVNumberOfChannelsKey: @(1), AVEncoderAudioQualityKey: @(AVAudioQualityMedium)}; } - (NSDictionary *)kb_voiceRecordSettingsPCM { return @{AVFormatIDKey: @(kAudioFormatLinearPCM), AVSampleRateKey: @(16000), AVNumberOfChannelsKey: @(1), AVLinearPCMBitDepthKey: @(16), AVLinearPCMIsFloatKey: @(NO), AVLinearPCMIsBigEndianKey: @(NO)}; } - (NSURL *)kb_voiceFileURLWithExtension:(NSString *)ext { NSURL *dirURL = [self kb_voiceDirectoryURL]; if (!dirURL) { return nil; } NSTimeInterval ts = [[NSDate date] timeIntervalSince1970] * 1000; NSString *safeExt = (ext.length > 0) ? ext : @"m4a"; NSString *fileName = [NSString stringWithFormat:@"kb_voice_%lld.%@", (long long)ts, safeExt]; return [dirURL URLByAppendingPathComponent:fileName]; } - (NSURL *)kb_voiceDirectoryURL { NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup]; if (!containerURL) { return nil; } NSURL *dirURL = [containerURL URLByAppendingPathComponent:@"voice" isDirectory:YES]; if (dirURL && ![[NSFileManager defaultManager] fileExistsAtPath:dirURL.path]) { [[NSFileManager defaultManager] createDirectoryAtURL:dirURL withIntermediateDirectories:YES attributes:nil error:nil]; } return dirURL; } - (BOOL)kb_tryStartRecorderWithSettings:(NSDictionary *)settings fileURL:(NSURL *)fileURL error:(NSError **)error { AVAudioRecorder *recorder = [[AVAudioRecorder alloc] initWithURL:fileURL settings:settings error:error]; if (*error || !recorder) { NSLog(@"[KBVoiceBridge][App] create recorder failed: %@", (*error).localizedDescription); return NO; } recorder.delegate = self; recorder.meteringEnabled = YES; if (![recorder prepareToRecord]) { NSLog(@"[KBVoiceBridge][App] prepareToRecord failed"); return NO; } if (![recorder record]) { NSLog(@"[KBVoiceBridge][App] record returned NO"); return NO; } self.recorder = recorder; return YES; } - (NSUserDefaults *)kb_voiceUserDefaults { return [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; } - (void)kb_clearSharedState { NSUserDefaults *ud = [self kb_voiceUserDefaults]; [ud removeObjectForKey:KBVoiceBridgeFilePathKey]; [ud removeObjectForKey:KBVoiceBridgeErrorKey]; [ud removeObjectForKey:KBVoiceBridgeTimestampKey]; [ud synchronize]; } - (void)kb_saveSharedFileURL:(NSURL *)fileURL { if (!fileURL) { return; } NSUserDefaults *ud = [self kb_voiceUserDefaults]; [ud setObject:fileURL.path ?: @"" forKey:KBVoiceBridgeFilePathKey]; [ud setObject:@([[NSDate date] timeIntervalSince1970]) forKey:KBVoiceBridgeTimestampKey]; [ud removeObjectForKey:KBVoiceBridgeErrorKey]; [ud synchronize]; } - (void)kb_saveSharedError:(NSString *)message { NSUserDefaults *ud = [self kb_voiceUserDefaults]; [ud setObject:message ?: @"" forKey:KBVoiceBridgeErrorKey]; [ud setObject:@([[NSDate date] timeIntervalSince1970]) forKey:KBVoiceBridgeTimestampKey]; [ud removeObjectForKey:KBVoiceBridgeFilePathKey]; [ud synchronize]; } - (void)kb_postFailed:(NSString *)message { [self kb_saveSharedError:message]; [self kb_postDarwin:KBDarwinVoiceFailed]; if ([UIApplication sharedApplication].applicationState == UIApplicationStateActive) { NSString *tip = message.length > 0 ? message : KBLocalized(@"录音失败"); [KBHUD showInfo:tip]; } } - (void)kb_postDarwin:(NSString *)name { CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)name, NULL, NULL, true); } @end