// // KBVoiceRecordManager.m // keyBoard // // Created by Mac on 2026/1/26. // #import "KBVoiceRecordManager.h" #import static NSString *const kKBVoiceRecordManagerErrorDomain = @"KBVoiceRecordManager"; @interface KBVoiceRecordManager () @property(nonatomic, strong) AVAudioRecorder *recorder; @property(nonatomic, strong) NSURL *currentFileURL; @property(nonatomic, assign) BOOL recording; @property(nonatomic, assign) BOOL stopping; @property(nonatomic, assign) BOOL cancelled; @property(nonatomic, assign) BOOL interrupted; @property(nonatomic, assign) NSTimeInterval recordStartTime; @end @implementation KBVoiceRecordManager - (instancetype)init { self = [super init]; if (self) { _minRecordDuration = 1.0; [self setupNotifications]; } return self; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } #pragma mark - Public Methods - (void)startRecording { if (self.recording) { return; } AVAudioSession *session = [AVAudioSession sharedInstance]; if (session.recordPermission == AVAudioSessionRecordPermissionUndetermined) { __weak typeof(self) weakSelf = self; [session requestRecordPermission:^(BOOL granted) { dispatch_async(dispatch_get_main_queue(), ^{ if (granted) { [weakSelf startRecordingInternal]; } else { [weakSelf reportErrorWithCode:KBVoiceRecordManagerErrorPermissionDenied message:@"Microphone permission denied"]; } }); }]; return; } if (session.recordPermission != AVAudioSessionRecordPermissionGranted) { [self reportErrorWithCode:KBVoiceRecordManagerErrorPermissionDenied message:@"Microphone permission denied"]; return; } [self startRecordingInternal]; } - (void)stopRecording { if (!self.recording || !self.recorder) { return; } self.stopping = YES; self.cancelled = NO; self.interrupted = NO; [self.recorder stop]; } - (void)cancelRecording { if (!self.recording || !self.recorder) { return; } self.cancelled = YES; self.stopping = NO; self.interrupted = NO; [self.recorder stop]; } #pragma mark - Private Methods - (void)startRecordingInternal { NSError *sessionError = nil; if (![self configureSession:&sessionError]) { [self reportErrorWithCode:KBVoiceRecordManagerErrorSessionFailed message:sessionError.localizedDescription ?: @"Session error"]; return; } NSURL *fileURL = [self generateFileURL]; if (!fileURL) { [self reportErrorWithCode:KBVoiceRecordManagerErrorRecorderFailed message:@"Invalid file URL"]; return; } NSDictionary *settings = @{ AVFormatIDKey : @(kAudioFormatMPEG4AAC), AVSampleRateKey : @(16000), AVNumberOfChannelsKey : @(1), AVEncoderAudioQualityKey : @(AVAudioQualityMedium), AVEncoderBitRateKey : @(32000) }; NSError *recorderError = nil; self.recorder = [[AVAudioRecorder alloc] initWithURL:fileURL settings:settings error:&recorderError]; if (recorderError || !self.recorder) { [self reportErrorWithCode:KBVoiceRecordManagerErrorRecorderFailed message:recorderError.localizedDescription ?: @"Recorder error"]; return; } self.currentFileURL = fileURL; self.recorder.delegate = self; self.recorder.meteringEnabled = NO; self.stopping = NO; self.cancelled = NO; self.interrupted = NO; if (![self.recorder prepareToRecord] || ![self.recorder record]) { [self reportErrorWithCode:KBVoiceRecordManagerErrorRecorderFailed message:@"Recorder start failed"]; return; } self.recordStartTime = CACurrentMediaTime(); self.recording = YES; if ([self.delegate respondsToSelector:@selector(voiceRecordManagerDidStartRecording:)]) { [self.delegate voiceRecordManagerDidStartRecording:self]; } } - (BOOL)configureSession:(NSError **)error { AVAudioSession *session = [AVAudioSession sharedInstance]; BOOL success = [session setCategory:AVAudioSessionCategoryPlayAndRecord mode:AVAudioSessionModeVoiceChat options:(AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetooth) error:error]; if (!success) { return NO; } [session setPreferredSampleRate:16000 error:nil]; [session setPreferredIOBufferDuration:0.02 error:nil]; if ([session respondsToSelector:@selector(setPreferredInputNumberOfChannels:error:)]) { [session setPreferredInputNumberOfChannels:1 error:nil]; } return [session setActive:YES error:error]; } - (NSURL *)generateFileURL { NSString *fileName = [NSString stringWithFormat:@"kb_record_%@.m4a", @((NSInteger)([[NSDate date] timeIntervalSince1970] * 1000))]; NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; return [NSURL fileURLWithPath:path]; } - (void)cleanupRecorder { self.recording = NO; self.stopping = NO; self.cancelled = NO; self.interrupted = NO; self.recordStartTime = 0; self.recorder.delegate = nil; self.recorder = nil; self.currentFileURL = nil; [[AVAudioSession sharedInstance] setActive:NO error:nil]; } - (void)removeCurrentFileIfNeeded { if (!self.currentFileURL) { return; } [[NSFileManager defaultManager] removeItemAtURL:self.currentFileURL error:nil]; } - (void)reportErrorWithCode:(KBVoiceRecordManagerErrorCode)code message:(NSString *)message { NSError *error = [NSError errorWithDomain:kKBVoiceRecordManagerErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message ?: @""}]; if ([self.delegate respondsToSelector:@selector(voiceRecordManager:didFailWithError:)]) { [self.delegate voiceRecordManager:self didFailWithError:error]; } [self cleanupRecorder]; } #pragma mark - Notifications - (void)setupNotifications { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleInterruption:) name:AVAudioSessionInterruptionNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleRouteChange:) name:AVAudioSessionRouteChangeNotification object:nil]; } - (void)handleInterruption:(NSNotification *)notification { NSDictionary *info = notification.userInfo; AVAudioSessionInterruptionType type = [info[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; if (type == AVAudioSessionInterruptionTypeBegan && self.recording) { self.interrupted = YES; [self.recorder stop]; } } - (void)handleRouteChange:(NSNotification *)notification { NSDictionary *info = notification.userInfo; AVAudioSessionRouteChangeReason reason = [info[AVAudioSessionRouteChangeReasonKey] unsignedIntegerValue]; if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable && self.recording) { self.interrupted = YES; [self.recorder stop]; } } #pragma mark - AVAudioRecorderDelegate - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag { if (!self.recording) { [self cleanupRecorder]; return; } NSTimeInterval duration = recorder.currentTime; if (duration <= 0 && self.recordStartTime > 0) { duration = CACurrentMediaTime() - self.recordStartTime; } NSURL *fileURL = self.currentFileURL; if (self.cancelled || !flag) { [self removeCurrentFileIfNeeded]; if ([self.delegate respondsToSelector:@selector(voiceRecordManagerDidCancelRecording:)]) { [self.delegate voiceRecordManagerDidCancelRecording:self]; } [self cleanupRecorder]; return; } if (self.interrupted) { [self removeCurrentFileIfNeeded]; [self reportErrorWithCode:KBVoiceRecordManagerErrorInterrupted message:@"Audio session interrupted"]; return; } if (duration < self.minRecordDuration) { [self removeCurrentFileIfNeeded]; if ([self.delegate respondsToSelector:@selector(voiceRecordManagerDidRecordTooShort:)]) { [self.delegate voiceRecordManagerDidRecordTooShort:self]; } [self cleanupRecorder]; return; } if (fileURL && [self.delegate respondsToSelector:@selector(voiceRecordManager:didFinishRecordingAtURL:duration:)]) { [self.delegate voiceRecordManager:self didFinishRecordingAtURL:fileURL duration:duration]; } [self cleanupRecorder]; } @end