1
This commit is contained in:
@@ -72,6 +72,7 @@
|
|||||||
#define API_AI_VOICE_TALK @"/chat/voice" // 语音对话(替换为后端真实路径)
|
#define API_AI_VOICE_TALK @"/chat/voice" // 语音对话(替换为后端真实路径)
|
||||||
#define API_AI_CHAT_SYNC @"/chat/sync" // 同步对话
|
#define API_AI_CHAT_SYNC @"/chat/sync" // 同步对话
|
||||||
#define API_AI_CHAT_MESSAGE @"/chat/message" // 文本润色
|
#define API_AI_CHAT_MESSAGE @"/chat/message" // 文本润色
|
||||||
|
#define API_AI_AUDIO_UPLOAD @"/chat/audio/upload" // 语音上传(替换为后端真实路径)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */; };
|
04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */; };
|
||||||
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */; };
|
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */; };
|
||||||
04E0B1022F300001002CA5A0 /* KBVoiceToTextManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */; };
|
04E0B1022F300001002CA5A0 /* KBVoiceToTextManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */; };
|
||||||
|
04E0B2022F300002002CA5A0 /* KBVoiceRecordManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */; };
|
||||||
04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; };
|
04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; };
|
||||||
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; };
|
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; };
|
||||||
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */; };
|
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */; };
|
||||||
@@ -653,6 +654,8 @@
|
|||||||
04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramStreamingManager.m; sourceTree = "<group>"; };
|
04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramStreamingManager.m; sourceTree = "<group>"; };
|
||||||
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = "<group>"; };
|
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = "<group>"; };
|
||||||
04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceToTextManager.m; sourceTree = "<group>"; };
|
04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceToTextManager.m; sourceTree = "<group>"; };
|
||||||
|
04E0B2002F300002002CA5A0 /* KBVoiceRecordManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceRecordManager.h; sourceTree = "<group>"; };
|
||||||
|
04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceRecordManager.m; sourceTree = "<group>"; };
|
||||||
04E038E62F20E877002CA5A0 /* DeepgramWebSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramWebSocketClient.h; sourceTree = "<group>"; };
|
04E038E62F20E877002CA5A0 /* DeepgramWebSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramWebSocketClient.h; sourceTree = "<group>"; };
|
||||||
04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = "<group>"; };
|
04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = "<group>"; };
|
||||||
04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = "<group>"; };
|
04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = "<group>"; };
|
||||||
@@ -1079,6 +1082,8 @@
|
|||||||
04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */,
|
04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */,
|
||||||
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */,
|
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */,
|
||||||
04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */,
|
04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */,
|
||||||
|
04E0B2002F300002002CA5A0 /* KBVoiceRecordManager.h */,
|
||||||
|
04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */,
|
||||||
04E038E62F20E877002CA5A0 /* DeepgramWebSocketClient.h */,
|
04E038E62F20E877002CA5A0 /* DeepgramWebSocketClient.h */,
|
||||||
04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */,
|
04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */,
|
||||||
04E038ED2F21F0EC002CA5A0 /* AiVM.h */,
|
04E038ED2F21F0EC002CA5A0 /* AiVM.h */,
|
||||||
@@ -2331,6 +2336,7 @@
|
|||||||
04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */,
|
04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */,
|
||||||
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */,
|
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */,
|
||||||
04E0B1022F300001002CA5A0 /* KBVoiceToTextManager.m in Sources */,
|
04E0B1022F300001002CA5A0 /* KBVoiceToTextManager.m in Sources */,
|
||||||
|
04E0B2022F300002002CA5A0 /* KBVoiceRecordManager.m in Sources */,
|
||||||
048908E32EBF821700FABA60 /* KBSkinDetailVC.m in Sources */,
|
048908E32EBF821700FABA60 /* KBSkinDetailVC.m in Sources */,
|
||||||
0477BDF32EBB7B850055D639 /* KBDirectionIndicatorView.m in Sources */,
|
0477BDF32EBB7B850055D639 /* KBDirectionIndicatorView.m in Sources */,
|
||||||
048FFD142F274342005D62AE /* KBPersonaChatCell.m in Sources */,
|
048FFD142F274342005D62AE /* KBPersonaChatCell.m in Sources */,
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
#import "KBPersonaChatCell.h"
|
#import "KBPersonaChatCell.h"
|
||||||
#import "KBPersonaModel.h"
|
#import "KBPersonaModel.h"
|
||||||
#import "KBVoiceInputBar.h"
|
#import "KBVoiceInputBar.h"
|
||||||
|
#import "KBVoiceRecordManager.h"
|
||||||
#import "KBVoiceToTextManager.h"
|
#import "KBVoiceToTextManager.h"
|
||||||
#import "AiVM.h"
|
#import "AiVM.h"
|
||||||
|
#import "KBHUD.h"
|
||||||
#import <Masonry/Masonry.h>
|
#import <Masonry/Masonry.h>
|
||||||
|
|
||||||
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate>
|
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate>
|
||||||
|
|
||||||
/// 人设列表容器
|
/// 人设列表容器
|
||||||
@property (nonatomic, strong) UICollectionView *collectionView;
|
@property (nonatomic, strong) UICollectionView *collectionView;
|
||||||
@@ -24,6 +26,9 @@
|
|||||||
/// 语音转写管理器
|
/// 语音转写管理器
|
||||||
@property (nonatomic, strong) KBVoiceToTextManager *voiceToTextManager;
|
@property (nonatomic, strong) KBVoiceToTextManager *voiceToTextManager;
|
||||||
|
|
||||||
|
/// 录音管理器
|
||||||
|
@property (nonatomic, strong) KBVoiceRecordManager *voiceRecordManager;
|
||||||
|
|
||||||
/// 人设数据
|
/// 人设数据
|
||||||
@property (nonatomic, strong) NSMutableArray<KBPersonaModel *> *personas;
|
@property (nonatomic, strong) NSMutableArray<KBPersonaModel *> *personas;
|
||||||
|
|
||||||
@@ -66,6 +71,7 @@
|
|||||||
|
|
||||||
[self setupUI];
|
[self setupUI];
|
||||||
[self setupVoiceToTextManager];
|
[self setupVoiceToTextManager];
|
||||||
|
[self setupVoiceRecordManager];
|
||||||
[self loadPersonas];
|
[self loadPersonas];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,9 +251,17 @@
|
|||||||
- (void)setupVoiceToTextManager {
|
- (void)setupVoiceToTextManager {
|
||||||
self.voiceToTextManager = [[KBVoiceToTextManager alloc] initWithInputBar:self.voiceInputBar];
|
self.voiceToTextManager = [[KBVoiceToTextManager alloc] initWithInputBar:self.voiceInputBar];
|
||||||
self.voiceToTextManager.delegate = self;
|
self.voiceToTextManager.delegate = self;
|
||||||
|
self.voiceToTextManager.deepgramEnabled = NO;
|
||||||
[self.voiceToTextManager prepareConnection];
|
[self.voiceToTextManager prepareConnection];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 5:录音管理
|
||||||
|
- (void)setupVoiceRecordManager {
|
||||||
|
self.voiceRecordManager = [[KBVoiceRecordManager alloc] init];
|
||||||
|
self.voiceRecordManager.delegate = self;
|
||||||
|
self.voiceRecordManager.minRecordDuration = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
- (NSInteger)currentCompanionId {
|
- (NSInteger)currentCompanionId {
|
||||||
if (self.personas.count == 0) {
|
if (self.personas.count == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -381,4 +395,38 @@
|
|||||||
NSLog(@"[KBAIHomeVC] 语音识别失败:%@", error.localizedDescription);
|
NSLog(@"[KBAIHomeVC] 语音识别失败:%@", error.localizedDescription);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)voiceToTextManagerDidBeginRecording:(KBVoiceToTextManager *)manager {
|
||||||
|
[self.voiceRecordManager startRecording];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)voiceToTextManagerDidEndRecording:(KBVoiceToTextManager *)manager {
|
||||||
|
[self.voiceRecordManager stopRecording];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)voiceToTextManagerDidCancelRecording:(KBVoiceToTextManager *)manager {
|
||||||
|
[self.voiceRecordManager cancelRecording];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - KBVoiceRecordManagerDelegate
|
||||||
|
|
||||||
|
- (void)voiceRecordManager:(KBVoiceRecordManager *)manager
|
||||||
|
didFinishRecordingAtURL:(NSURL *)fileURL
|
||||||
|
duration:(NSTimeInterval)duration {
|
||||||
|
NSDictionary *attributes = [[NSFileManager defaultManager]
|
||||||
|
attributesOfItemAtPath:fileURL.path
|
||||||
|
error:nil];
|
||||||
|
unsigned long long fileSize = [attributes[NSFileSize] unsignedLongLongValue];
|
||||||
|
NSLog(@"[KBAIHomeVC] 录音完成,时长: %.2fs,大小: %llu bytes", duration, fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)voiceRecordManagerDidRecordTooShort:(KBVoiceRecordManager *)manager {
|
||||||
|
NSLog(@"[KBAIHomeVC] 录音过短,已忽略");
|
||||||
|
[KBHUD showError:KBLocalized(@"录音时间过短,请重新录音")];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)voiceRecordManager:(KBVoiceRecordManager *)manager
|
||||||
|
didFailWithError:(NSError *)error {
|
||||||
|
NSLog(@"[KBAIHomeVC] 录音失败:%@", error.localizedDescription);
|
||||||
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ typedef void (^AiVMMessageCompletion)(KBAiMessageResponse *_Nullable response,
|
|||||||
|
|
||||||
typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL,
|
typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL,
|
||||||
NSError *_Nullable error);
|
NSError *_Nullable error);
|
||||||
|
typedef void (^AiVMUploadAudioCompletion)(NSString *_Nullable fileURL,
|
||||||
|
NSError *_Nullable error);
|
||||||
|
|
||||||
@interface AiVM : NSObject
|
@interface AiVM : NSObject
|
||||||
|
|
||||||
@@ -58,6 +60,10 @@ typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL,
|
|||||||
- (void)requestAudioWithAudioId:(NSString *)audioId
|
- (void)requestAudioWithAudioId:(NSString *)audioId
|
||||||
completion:(AiVMAudioURLCompletion)completion;
|
completion:(AiVMAudioURLCompletion)completion;
|
||||||
|
|
||||||
|
/// 上传语音文件(m4a)
|
||||||
|
- (void)uploadAudioFileAtURL:(NSURL *)fileURL
|
||||||
|
completion:(AiVMUploadAudioCompletion)completion;
|
||||||
|
|
||||||
#pragma mark - 人设相关接口
|
#pragma mark - 人设相关接口
|
||||||
|
|
||||||
/// 分页查询人设列表
|
/// 分页查询人设列表
|
||||||
|
|||||||
@@ -216,6 +216,51 @@ autoShowBusinessError:NO
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)uploadAudioFileAtURL:(NSURL *)fileURL
|
||||||
|
completion:(AiVMUploadAudioCompletion)completion {
|
||||||
|
if (!fileURL || !fileURL.isFileURL) {
|
||||||
|
NSError *error = [NSError errorWithDomain:@"AiVM"
|
||||||
|
code:-1
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey : @"invalid fileURL"}];
|
||||||
|
if (completion) {
|
||||||
|
completion(nil, error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[KBNetworkManager shared] uploadFile:API_AI_AUDIO_UPLOAD
|
||||||
|
fileURL:fileURL
|
||||||
|
name:@"file"
|
||||||
|
mimeType:@"audio/mp4"
|
||||||
|
parameters:nil
|
||||||
|
headers:nil
|
||||||
|
completion:^(NSDictionary *_Nullable json,
|
||||||
|
NSURLResponse *_Nullable response,
|
||||||
|
NSError *_Nullable error) {
|
||||||
|
if (error) {
|
||||||
|
if (completion) {
|
||||||
|
completion(nil, error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *fileURLString = nil;
|
||||||
|
id dataObj = json[@"data"];
|
||||||
|
if ([dataObj isKindOfClass:[NSString class]]) {
|
||||||
|
fileURLString = (NSString *)dataObj;
|
||||||
|
} else if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
id urlObj = dataObj[@"url"] ?: dataObj[@"audioUrl"];
|
||||||
|
if ([urlObj isKindOfClass:[NSString class]]) {
|
||||||
|
fileURLString = (NSString *)urlObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completion) {
|
||||||
|
completion(fileURLString, nil);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - 人设相关接口
|
#pragma mark - 人设相关接口
|
||||||
|
|
||||||
- (void)fetchPersonasWithPageNum:(NSInteger)pageNum
|
- (void)fetchPersonasWithPageNum:(NSInteger)pageNum
|
||||||
|
|||||||
47
keyBoard/Class/AiTalk/VM/KBVoiceRecordManager.h
Normal file
47
keyBoard/Class/AiTalk/VM/KBVoiceRecordManager.h
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// KBVoiceRecordManager.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Mac on 2026/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
typedef NS_ENUM(NSInteger, KBVoiceRecordManagerErrorCode) {
|
||||||
|
KBVoiceRecordManagerErrorPermissionDenied = 1,
|
||||||
|
KBVoiceRecordManagerErrorSessionFailed = 2,
|
||||||
|
KBVoiceRecordManagerErrorRecorderFailed = 3,
|
||||||
|
KBVoiceRecordManagerErrorTooShort = 4,
|
||||||
|
KBVoiceRecordManagerErrorInterrupted = 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
@class KBVoiceRecordManager;
|
||||||
|
|
||||||
|
@protocol KBVoiceRecordManagerDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
- (void)voiceRecordManagerDidStartRecording:(KBVoiceRecordManager *)manager;
|
||||||
|
- (void)voiceRecordManager:(KBVoiceRecordManager *)manager
|
||||||
|
didFinishRecordingAtURL:(NSURL *)fileURL
|
||||||
|
duration:(NSTimeInterval)duration;
|
||||||
|
- (void)voiceRecordManagerDidCancelRecording:(KBVoiceRecordManager *)manager;
|
||||||
|
- (void)voiceRecordManagerDidRecordTooShort:(KBVoiceRecordManager *)manager;
|
||||||
|
- (void)voiceRecordManager:(KBVoiceRecordManager *)manager
|
||||||
|
didFailWithError:(NSError *)error;
|
||||||
|
@end
|
||||||
|
|
||||||
|
/// 录音管理器(AAC/M4A,16k,mono)
|
||||||
|
@interface KBVoiceRecordManager : NSObject
|
||||||
|
|
||||||
|
@property(nonatomic, weak) id<KBVoiceRecordManagerDelegate> delegate;
|
||||||
|
@property(nonatomic, assign) NSTimeInterval minRecordDuration;
|
||||||
|
@property(nonatomic, assign, readonly, getter=isRecording) BOOL recording;
|
||||||
|
|
||||||
|
- (void)startRecording;
|
||||||
|
- (void)stopRecording;
|
||||||
|
- (void)cancelRecording;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
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
|
||||||
@@ -30,6 +30,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
|
|
||||||
@property(nonatomic, weak) id<KBVoiceToTextManagerDelegate> delegate;
|
@property(nonatomic, weak) id<KBVoiceToTextManagerDelegate> delegate;
|
||||||
@property(nonatomic, weak, readonly) KBVoiceInputBar *inputBar;
|
@property(nonatomic, weak, readonly) KBVoiceInputBar *inputBar;
|
||||||
|
@property(nonatomic, assign) BOOL deepgramEnabled;
|
||||||
|
|
||||||
- (instancetype)initWithInputBar:(KBVoiceInputBar *)inputBar;
|
- (instancetype)initWithInputBar:(KBVoiceInputBar *)inputBar;
|
||||||
- (void)prepareConnection;
|
- (void)prepareConnection;
|
||||||
|
|||||||
@@ -20,12 +20,26 @@
|
|||||||
|
|
||||||
@implementation KBVoiceToTextManager
|
@implementation KBVoiceToTextManager
|
||||||
|
|
||||||
|
- (void)setDeepgramEnabled:(BOOL)deepgramEnabled {
|
||||||
|
if (_deepgramEnabled == deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_deepgramEnabled = deepgramEnabled;
|
||||||
|
if (!deepgramEnabled) {
|
||||||
|
[self.deepgramManager cancel];
|
||||||
|
[self resetTranscript];
|
||||||
|
} else {
|
||||||
|
[self.deepgramManager prepareConnection];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
- (instancetype)initWithInputBar:(KBVoiceInputBar *)inputBar {
|
- (instancetype)initWithInputBar:(KBVoiceInputBar *)inputBar {
|
||||||
self = [super init];
|
self = [super init];
|
||||||
if (self) {
|
if (self) {
|
||||||
_inputBar = inputBar;
|
_inputBar = inputBar;
|
||||||
_inputBar.delegate = self;
|
_inputBar.delegate = self;
|
||||||
_fullText = [[NSMutableString alloc] init];
|
_fullText = [[NSMutableString alloc] init];
|
||||||
|
_deepgramEnabled = YES;
|
||||||
[self setupDeepgram];
|
[self setupDeepgram];
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
@@ -38,10 +52,16 @@
|
|||||||
#pragma mark - Public Methods
|
#pragma mark - Public Methods
|
||||||
|
|
||||||
- (void)prepareConnection {
|
- (void)prepareConnection {
|
||||||
|
if (!self.deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
[self.deepgramManager prepareConnection];
|
[self.deepgramManager prepareConnection];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)disconnect {
|
- (void)disconnect {
|
||||||
|
if (!self.deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
[self.deepgramManager disconnect];
|
[self.deepgramManager disconnect];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +90,12 @@
|
|||||||
|
|
||||||
- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar {
|
- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar {
|
||||||
[self resetTranscript];
|
[self resetTranscript];
|
||||||
inputBar.statusText = @"正在连接...";
|
if (self.deepgramEnabled) {
|
||||||
[self.deepgramManager start];
|
inputBar.statusText = @"正在连接...";
|
||||||
|
[self.deepgramManager start];
|
||||||
|
} else {
|
||||||
|
inputBar.statusText = @"正在录音...";
|
||||||
|
}
|
||||||
|
|
||||||
if ([self.delegate respondsToSelector:@selector
|
if ([self.delegate respondsToSelector:@selector
|
||||||
(voiceToTextManagerDidBeginRecording:)]) {
|
(voiceToTextManagerDidBeginRecording:)]) {
|
||||||
@@ -80,8 +104,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar {
|
- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar {
|
||||||
inputBar.statusText = @"正在识别...";
|
if (self.deepgramEnabled) {
|
||||||
[self.deepgramManager stopAndFinalize];
|
inputBar.statusText = @"正在识别...";
|
||||||
|
[self.deepgramManager stopAndFinalize];
|
||||||
|
} else {
|
||||||
|
inputBar.statusText = @"录音结束";
|
||||||
|
}
|
||||||
|
|
||||||
if ([self.delegate respondsToSelector:@selector
|
if ([self.delegate respondsToSelector:@selector
|
||||||
(voiceToTextManagerDidEndRecording:)]) {
|
(voiceToTextManagerDidEndRecording:)]) {
|
||||||
@@ -92,7 +120,9 @@
|
|||||||
- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar {
|
- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar {
|
||||||
inputBar.statusText = @"已取消";
|
inputBar.statusText = @"已取消";
|
||||||
[self resetTranscript];
|
[self resetTranscript];
|
||||||
[self.deepgramManager cancel];
|
if (self.deepgramEnabled) {
|
||||||
|
[self.deepgramManager cancel];
|
||||||
|
}
|
||||||
|
|
||||||
if ([self.delegate respondsToSelector:@selector
|
if ([self.delegate respondsToSelector:@selector
|
||||||
(voiceToTextManagerDidCancelRecording:)]) {
|
(voiceToTextManagerDidCancelRecording:)]) {
|
||||||
@@ -103,10 +133,16 @@
|
|||||||
#pragma mark - DeepgramStreamingManagerDelegate
|
#pragma mark - DeepgramStreamingManagerDelegate
|
||||||
|
|
||||||
- (void)deepgramStreamingManagerDidConnect {
|
- (void)deepgramStreamingManagerDidConnect {
|
||||||
|
if (!self.deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.inputBar.statusText = @"正在聆听...";
|
self.inputBar.statusText = @"正在聆听...";
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)deepgramStreamingManagerDidDisconnect:(NSError *_Nullable)error {
|
- (void)deepgramStreamingManagerDidDisconnect:(NSError *_Nullable)error {
|
||||||
|
if (!self.deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,10 +155,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)deepgramStreamingManagerDidUpdateRMS:(float)rms {
|
- (void)deepgramStreamingManagerDidUpdateRMS:(float)rms {
|
||||||
|
if (!self.deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
[self.inputBar updateVolumeRMS:rms];
|
[self.inputBar updateVolumeRMS:rms];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)deepgramStreamingManagerDidReceiveInterimTranscript:(NSString *)text {
|
- (void)deepgramStreamingManagerDidReceiveInterimTranscript:(NSString *)text {
|
||||||
|
if (!self.deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
NSString *displayText = text ?: @"";
|
NSString *displayText = text ?: @"";
|
||||||
if (self.fullText.length > 0 && displayText.length > 0) {
|
if (self.fullText.length > 0 && displayText.length > 0) {
|
||||||
displayText =
|
displayText =
|
||||||
@@ -141,6 +183,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
|
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
|
||||||
|
if (!self.deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
if (self.fullText.length > 0) {
|
if (self.fullText.length > 0) {
|
||||||
[self.fullText appendString:@" "];
|
[self.fullText appendString:@" "];
|
||||||
@@ -160,6 +205,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)deepgramStreamingManagerDidFail:(NSError *)error {
|
- (void)deepgramStreamingManagerDidFail:(NSError *)error {
|
||||||
|
if (!self.deepgramEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.inputBar.statusText = @"识别失败";
|
self.inputBar.statusText = @"识别失败";
|
||||||
if ([self.delegate respondsToSelector:@selector
|
if ([self.delegate respondsToSelector:@selector
|
||||||
(voiceToTextManager:didFailWithError:)]) {
|
(voiceToTextManager:didFailWithError:)]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user