512 lines
14 KiB
Objective-C
512 lines
14 KiB
Objective-C
//
|
|
// DeepgramStreamingManager.m
|
|
// keyBoard
|
|
//
|
|
// Created by Mac on 2026/1/21.
|
|
//
|
|
|
|
#import "DeepgramStreamingManager.h"
|
|
#import "AudioCaptureManager.h"
|
|
#import "AudioSessionManager.h"
|
|
#import "DeepgramWebSocketClient.h"
|
|
#import <UIKit/UIKit.h>
|
|
|
|
static NSString *const kDeepgramStreamingManagerErrorDomain =
|
|
@"DeepgramStreamingManager";
|
|
|
|
@interface DeepgramStreamingManager () <AudioSessionManagerDelegate,
|
|
AudioCaptureManagerDelegate,
|
|
DeepgramWebSocketClientDelegate>
|
|
|
|
@property(nonatomic, strong) AudioSessionManager *audioSession;
|
|
@property(nonatomic, strong) AudioCaptureManager *audioCapture;
|
|
@property(nonatomic, strong) DeepgramWebSocketClient *client;
|
|
@property(nonatomic, strong) dispatch_queue_t stateQueue;
|
|
|
|
@property(nonatomic, assign) BOOL streaming;
|
|
@property(nonatomic, strong) NSMutableArray<NSData *> *pendingFrames;
|
|
@property(nonatomic, assign) NSUInteger pendingFrameLimit;
|
|
@property(nonatomic, assign) BOOL connecting;
|
|
@property(nonatomic, assign) BOOL pendingStart;
|
|
@property(nonatomic, assign) BOOL keepConnection;
|
|
@property(nonatomic, strong) dispatch_source_t keepAliveTimer;
|
|
@property(nonatomic, assign) NSInteger reconnectAttempts;
|
|
@property(nonatomic, assign) NSInteger maxReconnectAttempts;
|
|
@property(nonatomic, assign) BOOL reconnectScheduled;
|
|
@property(nonatomic, assign) BOOL appInBackground;
|
|
@property(nonatomic, assign) BOOL shouldReconnectOnForeground;
|
|
|
|
@end
|
|
|
|
@implementation DeepgramStreamingManager
|
|
|
|
- (instancetype)init {
|
|
self = [super init];
|
|
if (self) {
|
|
_stateQueue = dispatch_queue_create("com.keyboard.aitalk.deepgram.manager",
|
|
DISPATCH_QUEUE_SERIAL);
|
|
|
|
_audioSession = [AudioSessionManager sharedManager];
|
|
_audioSession.delegate = self;
|
|
|
|
_audioCapture = [[AudioCaptureManager alloc] init];
|
|
_audioCapture.delegate = self;
|
|
|
|
_client = [[DeepgramWebSocketClient alloc] init];
|
|
_client.delegate = self;
|
|
|
|
_serverURL = @"wss://api.deepgram.com/v1/listen";
|
|
_encoding = @"linear16";
|
|
_sampleRate = 16000.0;
|
|
_channels = 1;
|
|
_punctuate = YES;
|
|
_smartFormat = YES;
|
|
_interimResults = YES;
|
|
|
|
_pendingFrames = [[NSMutableArray alloc] init];
|
|
_pendingFrameLimit = 25;
|
|
_connecting = NO;
|
|
_pendingStart = NO;
|
|
_keepConnection = NO;
|
|
_reconnectAttempts = 0;
|
|
_maxReconnectAttempts = 5;
|
|
_reconnectScheduled = NO;
|
|
_appInBackground = NO;
|
|
_shouldReconnectOnForeground = NO;
|
|
|
|
[self setupNotifications];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)dealloc {
|
|
[self removeNotifications];
|
|
[self disconnect];
|
|
}
|
|
|
|
- (void)start {
|
|
dispatch_async(self.stateQueue, ^{
|
|
if (self.appInBackground) {
|
|
self.shouldReconnectOnForeground = YES;
|
|
return;
|
|
}
|
|
self.keepConnection = YES;
|
|
self.pendingStart = YES;
|
|
self.reconnectAttempts = 0;
|
|
if (self.apiKey.length == 0) {
|
|
[self reportErrorWithMessage:@"Deepgram API key is required"];
|
|
return;
|
|
}
|
|
|
|
if (![self.audioSession hasMicrophonePermission]) {
|
|
__weak typeof(self) weakSelf = self;
|
|
[self.audioSession requestMicrophonePermission:^(BOOL granted) {
|
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
|
if (!strongSelf) {
|
|
return;
|
|
}
|
|
if (!granted) {
|
|
[strongSelf reportErrorWithMessage:@"Microphone permission denied"];
|
|
return;
|
|
}
|
|
dispatch_async(strongSelf.stateQueue, ^{
|
|
[strongSelf start];
|
|
});
|
|
}];
|
|
return;
|
|
}
|
|
|
|
NSError *error = nil;
|
|
if (![self.audioSession configureForConversation:&error]) {
|
|
[self reportError:error];
|
|
return;
|
|
}
|
|
|
|
if (![self.audioSession activateSession:&error]) {
|
|
[self reportError:error];
|
|
return;
|
|
}
|
|
|
|
if (![self.audioCapture isCapturing]) {
|
|
NSError *captureError = nil;
|
|
if (![self.audioCapture startCapture:&captureError]) {
|
|
[self reportError:captureError];
|
|
return;
|
|
}
|
|
}
|
|
|
|
NSLog(@"[DeepgramStreamingManager] Start streaming, server: %@",
|
|
self.serverURL);
|
|
|
|
if (self.client.isConnected) {
|
|
[self beginStreamingIfReady];
|
|
return;
|
|
}
|
|
|
|
[self connectIfNeeded];
|
|
});
|
|
}
|
|
|
|
- (void)prepareConnection {
|
|
dispatch_async(self.stateQueue, ^{
|
|
if (self.appInBackground) {
|
|
self.shouldReconnectOnForeground = YES;
|
|
return;
|
|
}
|
|
self.keepConnection = YES;
|
|
self.pendingStart = NO;
|
|
self.reconnectAttempts = 0;
|
|
|
|
if (self.apiKey.length == 0) {
|
|
NSLog(@"[DeepgramStreamingManager] Prepare skipped: API key missing");
|
|
return;
|
|
}
|
|
|
|
if (self.client.isConnected) {
|
|
return;
|
|
}
|
|
|
|
[self connectIfNeeded];
|
|
});
|
|
}
|
|
|
|
- (void)stopAndFinalize {
|
|
dispatch_async(self.stateQueue, ^{
|
|
if (self.streaming) {
|
|
[self.audioCapture stopCapture];
|
|
self.streaming = NO;
|
|
}
|
|
[self.pendingFrames removeAllObjects];
|
|
self.pendingStart = NO;
|
|
if (self.client.isConnected) {
|
|
[self.client finish];
|
|
}
|
|
[self.client disableAudioSending];
|
|
[self startKeepAliveIfNeeded];
|
|
});
|
|
}
|
|
|
|
- (void)cancel {
|
|
dispatch_async(self.stateQueue, ^{
|
|
if (self.streaming) {
|
|
[self.audioCapture stopCapture];
|
|
self.streaming = NO;
|
|
}
|
|
[self.pendingFrames removeAllObjects];
|
|
self.pendingStart = NO;
|
|
self.keepConnection = NO;
|
|
[self.client disableAudioSending];
|
|
[self stopKeepAlive];
|
|
[self.client disconnect];
|
|
});
|
|
}
|
|
|
|
- (void)disconnect {
|
|
dispatch_async(self.stateQueue, ^{
|
|
if (self.streaming) {
|
|
[self.audioCapture stopCapture];
|
|
self.streaming = NO;
|
|
}
|
|
[self.pendingFrames removeAllObjects];
|
|
self.pendingStart = NO;
|
|
self.keepConnection = NO;
|
|
self.shouldReconnectOnForeground = NO;
|
|
[self.client disableAudioSending];
|
|
[self stopKeepAlive];
|
|
[self.client disconnect];
|
|
[self.audioSession deactivateSession];
|
|
});
|
|
}
|
|
|
|
#pragma mark - AudioCaptureManagerDelegate
|
|
|
|
- (void)audioCaptureManagerDidOutputPCMFrame:(NSData *)pcmFrame {
|
|
if (pcmFrame.length == 0) {
|
|
return;
|
|
}
|
|
|
|
dispatch_async(self.stateQueue, ^{
|
|
if (!self.streaming || !self.client.isConnected) {
|
|
[self.pendingFrames addObject:pcmFrame];
|
|
if (self.pendingFrames.count > self.pendingFrameLimit) {
|
|
[self.pendingFrames removeObjectAtIndex:0];
|
|
}
|
|
return;
|
|
}
|
|
|
|
[self.client sendAudioPCMFrame:pcmFrame];
|
|
});
|
|
}
|
|
|
|
- (void)audioCaptureManagerDidUpdateRMS:(float)rms {
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([self.delegate respondsToSelector:@selector
|
|
(deepgramStreamingManagerDidUpdateRMS:)]) {
|
|
[self.delegate deepgramStreamingManagerDidUpdateRMS:rms];
|
|
}
|
|
});
|
|
}
|
|
|
|
#pragma mark - AudioSessionManagerDelegate
|
|
|
|
- (void)audioSessionManagerDidInterrupt:(KBAudioSessionInterruptionType)type {
|
|
if (type == KBAudioSessionInterruptionTypeBegan) {
|
|
[self cancel];
|
|
}
|
|
}
|
|
|
|
- (void)audioSessionManagerMicrophonePermissionDenied {
|
|
[self reportErrorWithMessage:@"Microphone permission denied"];
|
|
}
|
|
|
|
#pragma mark - DeepgramWebSocketClientDelegate
|
|
|
|
- (void)deepgramClientDidConnect {
|
|
dispatch_async(self.stateQueue, ^{
|
|
self.connecting = NO;
|
|
self.reconnectAttempts = 0;
|
|
self.reconnectScheduled = NO;
|
|
[self beginStreamingIfReady];
|
|
[self startKeepAliveIfNeeded];
|
|
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([self.delegate respondsToSelector:@selector
|
|
(deepgramStreamingManagerDidConnect)]) {
|
|
[self.delegate deepgramStreamingManagerDidConnect];
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
- (void)deepgramClientDidDisconnect:(NSError *_Nullable)error {
|
|
dispatch_async(self.stateQueue, ^{
|
|
if (self.streaming) {
|
|
[self.audioCapture stopCapture];
|
|
self.streaming = NO;
|
|
}
|
|
self.connecting = NO;
|
|
[self.audioSession deactivateSession];
|
|
[self stopKeepAlive];
|
|
|
|
if (self.pendingStart || self.keepConnection) {
|
|
[self scheduleReconnectWithError:error];
|
|
}
|
|
});
|
|
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([self.delegate respondsToSelector:@selector
|
|
(deepgramStreamingManagerDidDisconnect:)]) {
|
|
[self.delegate deepgramStreamingManagerDidDisconnect:error];
|
|
}
|
|
});
|
|
}
|
|
|
|
- (void)deepgramClientDidReceiveInterimTranscript:(NSString *)text {
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([self.delegate respondsToSelector:@selector
|
|
(deepgramStreamingManagerDidReceiveInterimTranscript:)]) {
|
|
[self.delegate deepgramStreamingManagerDidReceiveInterimTranscript:text];
|
|
}
|
|
});
|
|
}
|
|
|
|
- (void)deepgramClientDidReceiveFinalTranscript:(NSString *)text {
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([self.delegate respondsToSelector:@selector
|
|
(deepgramStreamingManagerDidReceiveFinalTranscript:)]) {
|
|
[self.delegate deepgramStreamingManagerDidReceiveFinalTranscript:text];
|
|
}
|
|
});
|
|
}
|
|
|
|
- (void)deepgramClientDidFail:(NSError *)error {
|
|
[self reportError:error];
|
|
}
|
|
|
|
#pragma mark - Error Reporting
|
|
|
|
- (void)reportError:(NSError *)error {
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([self.delegate respondsToSelector:@selector
|
|
(deepgramStreamingManagerDidFail:)]) {
|
|
[self.delegate deepgramStreamingManagerDidFail:error];
|
|
}
|
|
});
|
|
}
|
|
|
|
- (void)reportErrorWithMessage:(NSString *)message {
|
|
NSError *error = [NSError errorWithDomain:kDeepgramStreamingManagerErrorDomain
|
|
code:-1
|
|
userInfo:@{
|
|
NSLocalizedDescriptionKey : message ?: @""
|
|
}];
|
|
[self reportError:error];
|
|
}
|
|
|
|
- (void)connectIfNeeded {
|
|
if (self.connecting || self.client.isConnected) {
|
|
return;
|
|
}
|
|
|
|
if (self.serverURL.length == 0) {
|
|
[self reportErrorWithMessage:@"Deepgram server URL is required"];
|
|
return;
|
|
}
|
|
|
|
self.client.serverURL = self.serverURL;
|
|
self.client.apiKey = self.apiKey;
|
|
self.client.language = self.language;
|
|
self.client.model = self.model;
|
|
self.client.punctuate = self.punctuate;
|
|
self.client.smartFormat = self.smartFormat;
|
|
self.client.interimResults = self.interimResults;
|
|
self.client.encoding = self.encoding;
|
|
self.client.sampleRate = self.sampleRate;
|
|
self.client.channels = self.channels;
|
|
[self.client disableAudioSending];
|
|
self.connecting = YES;
|
|
[self.client connect];
|
|
}
|
|
|
|
- (void)beginStreamingIfReady {
|
|
if (!self.pendingStart) {
|
|
return;
|
|
}
|
|
|
|
self.streaming = YES;
|
|
[self.client enableAudioSending];
|
|
[self stopKeepAlive];
|
|
|
|
if (self.pendingFrames.count > 0) {
|
|
NSArray<NSData *> *frames = [self.pendingFrames copy];
|
|
[self.pendingFrames removeAllObjects];
|
|
for (NSData *frame in frames) {
|
|
[self.client sendAudioPCMFrame:frame];
|
|
}
|
|
NSLog(@"[DeepgramStreamingManager] Flushed %lu pending frames",
|
|
(unsigned long)frames.count);
|
|
}
|
|
}
|
|
|
|
- (void)scheduleReconnectWithError:(NSError *_Nullable)error {
|
|
if (self.reconnectScheduled || self.connecting || self.client.isConnected) {
|
|
return;
|
|
}
|
|
|
|
if (self.appInBackground) {
|
|
self.shouldReconnectOnForeground = YES;
|
|
return;
|
|
}
|
|
|
|
if (self.reconnectAttempts >= self.maxReconnectAttempts) {
|
|
NSLog(@"[DeepgramStreamingManager] Reconnect failed %ld times, stop retry. %@",
|
|
(long)self.maxReconnectAttempts,
|
|
error.localizedDescription ?: @"");
|
|
self.pendingStart = NO;
|
|
self.keepConnection = NO;
|
|
return;
|
|
}
|
|
|
|
self.reconnectAttempts += 1;
|
|
self.reconnectScheduled = YES;
|
|
|
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)),
|
|
self.stateQueue, ^{
|
|
self.reconnectScheduled = NO;
|
|
if (self.appInBackground) {
|
|
self.shouldReconnectOnForeground = YES;
|
|
return;
|
|
}
|
|
if (!self.pendingStart && !self.keepConnection) {
|
|
return;
|
|
}
|
|
[self connectIfNeeded];
|
|
});
|
|
}
|
|
|
|
- (void)setupNotifications {
|
|
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
|
|
[center addObserver:self
|
|
selector:@selector(handleAppDidEnterBackground)
|
|
name:UIApplicationDidEnterBackgroundNotification
|
|
object:nil];
|
|
[center addObserver:self
|
|
selector:@selector(handleAppWillEnterForeground)
|
|
name:UIApplicationWillEnterForegroundNotification
|
|
object:nil];
|
|
}
|
|
|
|
- (void)removeNotifications {
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
}
|
|
|
|
- (void)handleAppDidEnterBackground {
|
|
dispatch_async(self.stateQueue, ^{
|
|
self.appInBackground = YES;
|
|
self.shouldReconnectOnForeground =
|
|
self.keepConnection || self.pendingStart;
|
|
self.pendingStart = NO;
|
|
self.keepConnection = NO;
|
|
|
|
if (self.streaming) {
|
|
[self.audioCapture stopCapture];
|
|
self.streaming = NO;
|
|
}
|
|
|
|
[self.pendingFrames removeAllObjects];
|
|
[self.client disableAudioSending];
|
|
[self stopKeepAlive];
|
|
[self.client disconnect];
|
|
[self.audioSession deactivateSession];
|
|
|
|
NSLog(@"[DeepgramStreamingManager] App entered background, socket closed");
|
|
});
|
|
}
|
|
|
|
- (void)handleAppWillEnterForeground {
|
|
dispatch_async(self.stateQueue, ^{
|
|
self.appInBackground = NO;
|
|
if (self.shouldReconnectOnForeground) {
|
|
self.keepConnection = YES;
|
|
self.reconnectAttempts = 0;
|
|
[self connectIfNeeded];
|
|
}
|
|
self.shouldReconnectOnForeground = NO;
|
|
});
|
|
}
|
|
|
|
- (void)startKeepAliveIfNeeded {
|
|
if (!self.keepConnection || !self.client.isConnected || self.streaming) {
|
|
return;
|
|
}
|
|
|
|
if (self.keepAliveTimer) {
|
|
return;
|
|
}
|
|
|
|
self.keepAliveTimer =
|
|
dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0,
|
|
self.stateQueue);
|
|
dispatch_source_set_timer(self.keepAliveTimer,
|
|
dispatch_time(DISPATCH_TIME_NOW, 15 * NSEC_PER_SEC),
|
|
15 * NSEC_PER_SEC, 1 * NSEC_PER_SEC);
|
|
__weak typeof(self) weakSelf = self;
|
|
dispatch_source_set_event_handler(self.keepAliveTimer, ^{
|
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
|
if (!strongSelf) {
|
|
return;
|
|
}
|
|
[strongSelf.client sendKeepAlive];
|
|
});
|
|
dispatch_resume(self.keepAliveTimer);
|
|
}
|
|
|
|
- (void)stopKeepAlive {
|
|
if (self.keepAliveTimer) {
|
|
dispatch_source_cancel(self.keepAliveTimer);
|
|
self.keepAliveTimer = nil;
|
|
}
|
|
}
|
|
|
|
@end
|