Files
keyboard/keyBoard/Class/AiTalk/VM/KBVoiceRecordManager.m
2026-01-27 13:57:32 +08:00

290 lines
8.6 KiB
Objective-C

//
// KBVoiceRecordManager.m
// keyBoard
//
// Created by Mac on 2026/1/26.
//
#import "KBVoiceRecordManager.h"
#import <AVFoundation/AVFoundation.h>
static NSString *const kKBVoiceRecordManagerErrorDomain =
@"KBVoiceRecordManager";
@interface KBVoiceRecordManager () <AVAudioRecorderDelegate>
@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