303 lines
9.7 KiB
Objective-C
303 lines
9.7 KiB
Objective-C
//
|
|
// TTSServiceClient.m
|
|
// keyBoard
|
|
//
|
|
// Created by Mac on 2026/1/15.
|
|
//
|
|
|
|
#import "TTSServiceClient.h"
|
|
|
|
@interface TTSServiceClient () <NSURLSessionDataDelegate,
|
|
NSURLSessionWebSocketDelegate>
|
|
|
|
@property(nonatomic, strong) NSURLSession *urlSession;
|
|
@property(nonatomic, strong)
|
|
NSMutableDictionary<NSString *, NSURLSessionTask *> *activeTasks;
|
|
@property(nonatomic, strong) dispatch_queue_t networkQueue;
|
|
@property(nonatomic, assign) BOOL requesting;
|
|
|
|
@end
|
|
|
|
@implementation TTSServiceClient
|
|
|
|
- (instancetype)init {
|
|
self = [super init];
|
|
if (self) {
|
|
_networkQueue = dispatch_queue_create("com.keyboard.aitalk.tts.network",
|
|
DISPATCH_QUEUE_SERIAL);
|
|
_activeTasks = [[NSMutableDictionary alloc] init];
|
|
_expectedPayloadType = TTSPayloadTypeURL; // 默认 URL 模式
|
|
// TODO: 替换为实际的 TTS 服务器地址
|
|
_serverURL = @"https://your-tts-server.com/api/tts";
|
|
|
|
[self setupSession];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)setupSession {
|
|
NSURLSessionConfiguration *config =
|
|
[NSURLSessionConfiguration defaultSessionConfiguration];
|
|
config.timeoutIntervalForRequest = 30;
|
|
config.timeoutIntervalForResource = 120;
|
|
|
|
self.urlSession = [NSURLSession sessionWithConfiguration:config
|
|
delegate:self
|
|
delegateQueue:nil];
|
|
}
|
|
|
|
- (void)dealloc {
|
|
[self cancel];
|
|
}
|
|
|
|
#pragma mark - Public Methods
|
|
|
|
- (void)requestTTSForText:(NSString *)text segmentId:(NSString *)segmentId {
|
|
if (!text || text.length == 0 || !segmentId) {
|
|
return;
|
|
}
|
|
|
|
dispatch_async(self.networkQueue, ^{
|
|
self.requesting = YES;
|
|
|
|
switch (self.expectedPayloadType) {
|
|
case TTSPayloadTypeURL:
|
|
[self requestURLMode:text segmentId:segmentId];
|
|
break;
|
|
case TTSPayloadTypePCMChunk:
|
|
case TTSPayloadTypeAACChunk:
|
|
case TTSPayloadTypeOpusChunk:
|
|
[self requestStreamMode:text segmentId:segmentId];
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
- (void)cancel {
|
|
dispatch_async(self.networkQueue, ^{
|
|
for (NSURLSessionTask *task in self.activeTasks.allValues) {
|
|
[task cancel];
|
|
}
|
|
[self.activeTasks removeAllObjects];
|
|
self.requesting = NO;
|
|
});
|
|
}
|
|
|
|
#pragma mark - URL Mode (Mode A)
|
|
|
|
- (void)requestURLMode:(NSString *)text segmentId:(NSString *)segmentId {
|
|
NSURL *url = [NSURL URLWithString:self.serverURL];
|
|
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
|
request.HTTPMethod = @"POST";
|
|
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
|
|
|
NSDictionary *body = @{
|
|
@"text" : text,
|
|
@"segmentId" : segmentId,
|
|
@"voiceId" : self.voiceId ?: @"JBFqnCBsd6RMkjVDRZzb",
|
|
@"languageCode" : self.languageCode ?: @"zh",
|
|
@"format" : @"mp3" // 或 m4a
|
|
};
|
|
|
|
NSError *jsonError = nil;
|
|
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:body
|
|
options:0
|
|
error:&jsonError];
|
|
if (jsonError) {
|
|
[self reportError:jsonError];
|
|
return;
|
|
}
|
|
request.HTTPBody = jsonData;
|
|
|
|
__weak typeof(self) weakSelf = self;
|
|
NSURLSessionDataTask *task = [self.urlSession
|
|
dataTaskWithRequest:request
|
|
completionHandler:^(NSData *_Nullable data,
|
|
NSURLResponse *_Nullable response,
|
|
NSError *_Nullable error) {
|
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
|
if (!strongSelf)
|
|
return;
|
|
|
|
dispatch_async(strongSelf.networkQueue, ^{
|
|
[strongSelf.activeTasks removeObjectForKey:segmentId];
|
|
|
|
if (error) {
|
|
if (error.code != NSURLErrorCancelled) {
|
|
[strongSelf reportError:error];
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 解析响应
|
|
NSError *parseError = nil;
|
|
NSDictionary *json =
|
|
[NSJSONSerialization JSONObjectWithData:data
|
|
options:0
|
|
error:&parseError];
|
|
if (parseError) {
|
|
[strongSelf reportError:parseError];
|
|
return;
|
|
}
|
|
|
|
NSString *audioURLString = json[@"audioUrl"];
|
|
if (audioURLString) {
|
|
NSURL *audioURL = [NSURL URLWithString:audioURLString];
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([strongSelf.delegate respondsToSelector:@selector
|
|
(ttsClientDidReceiveURL:segmentId:)]) {
|
|
[strongSelf.delegate ttsClientDidReceiveURL:audioURL
|
|
segmentId:segmentId];
|
|
}
|
|
if ([strongSelf.delegate respondsToSelector:@selector
|
|
(ttsClientDidFinishSegment:)]) {
|
|
[strongSelf.delegate ttsClientDidFinishSegment:segmentId];
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}];
|
|
|
|
self.activeTasks[segmentId] = task;
|
|
[task resume];
|
|
|
|
NSLog(@"[TTSServiceClient] URL mode request for segment: %@", segmentId);
|
|
}
|
|
|
|
#pragma mark - Stream Mode (Mode B/C/D)
|
|
|
|
- (void)requestStreamMode:(NSString *)text segmentId:(NSString *)segmentId {
|
|
// WebSocket 连接用于流式接收
|
|
NSString *wsURL =
|
|
[self.serverURL stringByReplacingOccurrencesOfString:@"https://"
|
|
withString:@"wss://"];
|
|
wsURL = [wsURL stringByReplacingOccurrencesOfString:@"http://"
|
|
withString:@"ws://"];
|
|
wsURL = [wsURL stringByAppendingString:@"/stream"];
|
|
|
|
NSURL *url = [NSURL URLWithString:wsURL];
|
|
NSURLSessionWebSocketTask *wsTask =
|
|
[self.urlSession webSocketTaskWithURL:url];
|
|
|
|
self.activeTasks[segmentId] = wsTask;
|
|
[wsTask resume];
|
|
|
|
// 发送请求
|
|
NSDictionary *requestDict = @{
|
|
@"text" : text,
|
|
@"segmentId" : segmentId,
|
|
@"voiceId" : self.voiceId ?: @"JBFqnCBsd6RMkjVDRZzb",
|
|
@"languageCode" : self.languageCode ?: @"zh",
|
|
@"format" : [self formatStringForPayloadType:self.expectedPayloadType]
|
|
};
|
|
|
|
NSError *jsonError = nil;
|
|
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:requestDict
|
|
options:0
|
|
error:&jsonError];
|
|
if (jsonError) {
|
|
[self reportError:jsonError];
|
|
return;
|
|
}
|
|
|
|
NSString *jsonString = [[NSString alloc] initWithData:jsonData
|
|
encoding:NSUTF8StringEncoding];
|
|
NSURLSessionWebSocketMessage *message =
|
|
[[NSURLSessionWebSocketMessage alloc] initWithString:jsonString];
|
|
|
|
__weak typeof(self) weakSelf = self;
|
|
[wsTask sendMessage:message
|
|
completionHandler:^(NSError *_Nullable error) {
|
|
if (error) {
|
|
[weakSelf reportError:error];
|
|
} else {
|
|
[weakSelf receiveStreamMessage:wsTask segmentId:segmentId];
|
|
}
|
|
}];
|
|
|
|
NSLog(@"[TTSServiceClient] Stream mode request for segment: %@", segmentId);
|
|
}
|
|
|
|
- (void)receiveStreamMessage:(NSURLSessionWebSocketTask *)wsTask
|
|
segmentId:(NSString *)segmentId {
|
|
__weak typeof(self) weakSelf = self;
|
|
[wsTask receiveMessageWithCompletionHandler:^(
|
|
NSURLSessionWebSocketMessage *_Nullable message,
|
|
NSError *_Nullable error) {
|
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
|
if (!strongSelf)
|
|
return;
|
|
|
|
if (error) {
|
|
if (error.code != NSURLErrorCancelled && error.code != 57) {
|
|
[strongSelf reportError:error];
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (message.type == NSURLSessionWebSocketMessageTypeData) {
|
|
// 音频数据块
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([strongSelf.delegate respondsToSelector:@selector
|
|
(ttsClientDidReceiveAudioChunk:
|
|
payloadType:segmentId:)]) {
|
|
[strongSelf.delegate
|
|
ttsClientDidReceiveAudioChunk:message.data
|
|
payloadType:strongSelf.expectedPayloadType
|
|
segmentId:segmentId];
|
|
}
|
|
});
|
|
|
|
// 继续接收
|
|
[strongSelf receiveStreamMessage:wsTask segmentId:segmentId];
|
|
} else if (message.type == NSURLSessionWebSocketMessageTypeString) {
|
|
// 控制消息
|
|
NSData *data = [message.string dataUsingEncoding:NSUTF8StringEncoding];
|
|
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data
|
|
options:0
|
|
error:nil];
|
|
|
|
if ([json[@"type"] isEqualToString:@"done"]) {
|
|
dispatch_async(strongSelf.networkQueue, ^{
|
|
[strongSelf.activeTasks removeObjectForKey:segmentId];
|
|
});
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([strongSelf.delegate
|
|
respondsToSelector:@selector(ttsClientDidFinishSegment:)]) {
|
|
[strongSelf.delegate ttsClientDidFinishSegment:segmentId];
|
|
}
|
|
});
|
|
} else {
|
|
// 继续接收
|
|
[strongSelf receiveStreamMessage:wsTask segmentId:segmentId];
|
|
}
|
|
}
|
|
}];
|
|
}
|
|
|
|
- (NSString *)formatStringForPayloadType:(TTSPayloadType)type {
|
|
switch (type) {
|
|
case TTSPayloadTypePCMChunk:
|
|
return @"pcm";
|
|
case TTSPayloadTypeAACChunk:
|
|
return @"aac";
|
|
case TTSPayloadTypeOpusChunk:
|
|
return @"opus";
|
|
default:
|
|
return @"mp3";
|
|
}
|
|
}
|
|
|
|
#pragma mark - Error Reporting
|
|
|
|
- (void)reportError:(NSError *)error {
|
|
self.requesting = NO;
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if ([self.delegate respondsToSelector:@selector(ttsClientDidFail:)]) {
|
|
[self.delegate ttsClientDidFail:error];
|
|
}
|
|
});
|
|
}
|
|
|
|
@end
|