1
This commit is contained in:
289
keyBoard/Class/AiTalk/VM/KBVoiceRecordManager.m
Normal file
289
keyBoard/Class/AiTalk/VM/KBVoiceRecordManager.m
Normal file
@@ -0,0 +1,289 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user