This commit is contained in:
2026-01-15 18:16:56 +08:00
parent da62d4f411
commit 32c4138ae0
29 changed files with 1523 additions and 95 deletions

View File

@@ -0,0 +1,312 @@
//
// KBVoiceRecordManager.m
//
#import "KBVoiceRecordManager.h"
#import "KBConfig.h"
#import "KBVoiceBridgeNotification.h"
#import "KBHUD.h"
#import <AVFoundation/AVFoundation.h>
static void KBVoiceBridgeDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo);
@interface KBVoiceRecordManager () <AVAudioRecorderDelegate>
@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